diff --git a/backend/package.json b/backend/package.json index 6302391b..6ab1b596 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,7 @@ "fill": "ts-node src/webdav/fill-file-sizes.ts", "dev:dav": "tsx watch src/webdav/server.ts", "build": "tsc", - "test:einvoice": "tsx --test tests/einvoice.test.ts", + "test:einvoice": "tsx --test tests/einvoice.test.ts tests/zugferdInvoice.test.ts", "start": "node dist/src/index.js", "schema:index": "ts-node scripts/generate-schema-index.ts", "bankcodes:update": "tsx scripts/generate-de-bank-codes.ts", diff --git a/backend/src/modules/einvoice/buildZugferdInvoice.ts b/backend/src/modules/einvoice/buildZugferdInvoice.ts new file mode 100644 index 00000000..b08e7861 --- /dev/null +++ b/backend/src/modules/einvoice/buildZugferdInvoice.ts @@ -0,0 +1,342 @@ +import dayjs from "dayjs" +import { buildCashDiscountTerms } from "../cash-discount" + +/** + * Erzeugt das strukturierte CII-XML (ZUGFeRD 2.x / Factur-X, Profil EN 16931) für Ausgangsrechnungen. + * Grundlage ist der gespeicherte Beleg, nicht die formatierten PDF-Daten, damit alle Beträge numerisch bleiben. + */ + +export const ZUGFERD_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices", "serialInvoices"] +export const ZUGFERD_GUIDELINE_ID = "urn:cen.eu:en16931:2017" +export const ZUGFERD_FILENAME = "factur-x.xml" + +export type ZugferdInvoiceContext = { + tenant: any + customer: any + contact?: any + contactPerson?: any + project?: any + contract?: any + linkedDocument?: any + sepaMandate?: any + units: any[] + products: any[] + services: any[] + bankAccounts: any[] + advanceInvoices: any[] +} + +export type ZugferdInvoiceResult = { + xml: string + warnings: string[] + totals: { lineNet: number, tax: number, gross: number, prepaid: number, payable: number } +} + +type TaxCategory = "S" | "Z" | "E" | "AE" +type TaxGroup = { category: TaxCategory, rate: number, basis: number, exemptionReason?: string, exemptionReasonCode?: string } + +const round = (value: number, digits = 2) => { + const factor = 10 ** digits + const rounded = Math.round((value + Number.EPSILON * Math.sign(value)) * factor) / factor + return Object.is(rounded, -0) ? 0 : rounded +} +const amount = (value: number) => round(value).toFixed(2) +const decimal = (value: number, maxDigits = 4) => String(round(value, maxDigits)) + +const escapeXml = (value: unknown) => String(value ?? "") + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + +const text = (value: unknown) => String(value ?? "").trim() +const el = (name: string, value: unknown, attributes = "") => { + const content = text(value) + return content ? `<${name}${attributes}>${escapeXml(content)}` : "" +} +const date102 = (value: unknown) => { + if (!value) return "" + const parsed = dayjs(value as any) + return parsed.isValid() ? parsed.format("YYYYMMDD") : "" +} +const dateElement = (name: string, value: unknown) => { + const formatted = date102(value) + return formatted ? `<${name}>${formatted}` : "" +} + +const countryCodes: Record = { + deutschland: "DE", germany: "DE", österreich: "AT", oesterreich: "AT", austria: "AT", schweiz: "CH", switzerland: "CH", + liechtenstein: "LI", frankreich: "FR", france: "FR", italien: "IT", italy: "IT", niederlande: "NL", netherlands: "NL", + belgien: "BE", belgium: "BE", luxemburg: "LU", luxembourg: "LU", dänemark: "DK", daenemark: "DK", denmark: "DK", + polen: "PL", poland: "PL", tschechien: "CZ", "tschechische republik": "CZ", spanien: "ES", spain: "ES", + portugal: "PT", schweden: "SE", sweden: "SE", norwegen: "NO", norway: "NO", finnland: "FI", finland: "FI", + ungarn: "HU", hungary: "HU", slowakei: "SK", slowenien: "SI", kroatien: "HR", rumänien: "RO", bulgarien: "BG", + griechenland: "GR", irland: "IE", ireland: "IE", "vereinigtes königreich": "GB", großbritannien: "GB", england: "GB", + "united kingdom": "GB", usa: "US", "vereinigte staaten": "US", estland: "EE", lettland: "LV", litauen: "LT", + malta: "MT", zypern: "CY", türkei: "TR", +} + +export function countryCode(value: unknown) { + const normalized = text(value).toLocaleLowerCase("de") + if (!normalized) return "DE" + if (/^[a-z]{2}$/.test(normalized)) return normalized.toUpperCase() + return countryCodes[normalized] || "DE" +} + +// UN/ECE Recommendation 20/21, abgebildet über das Kürzel der FEDEO-Einheit. +const unitCodes: Record = { + stk: "H87", stück: "H87", m: "MTR", meter: "MTR", km: "KMT", kilometer: "KMT", tag: "DAY", tage: "DAY", + std: "HUR", h: "HUR", stunde: "HUR", stunden: "HUR", min: "MIN", minute: "MIN", minuten: "MIN", + pausch: "LS", pauschal: "LS", psch: "LS", mon: "MON", monat: "MON", jahr: "ANN", woche: "WEE", + pck: "PK", packung: "PK", pers: "IE", person: "IE", l: "LTR", liter: "LTR", ha: "HAR", hektar: "HAR", + "m²": "MTK", m2: "MTK", qm: "MTK", quadratmeter: "MTK", "m³": "MTQ", m3: "MTQ", kubikmeter: "MTQ", + kg: "KGM", kilogramm: "KGM", g: "GRM", t: "TNE", tonne: "TNE", gb: "E34", gigabyte: "E34", kwp: "KWT", kwh: "KWH", +} + +export function unitCode(unit: any) { + for (const candidate of [unit?.short, unit?.single, unit?.name]) { + const code = unitCodes[text(candidate).toLocaleLowerCase("de")] + if (code) return code + } + return "C62" +} + +const isItemRow = (row: any) => !["pagebreak", "title", "text"].includes(row?.mode) && !row?.optional && !row?.alternative + +function taxGroupFor(taxType: string | null | undefined, rate: number): Omit { + if (taxType === "13b UStG") return { + category: "AE", rate: 0, exemptionReasonCode: "VATEX-EU-AE", + exemptionReason: "Steuerschuldnerschaft des Leistungsempfängers gemäß § 13b UStG", + } + if (taxType === "19 UStG") return { + category: "E", rate: 0, + exemptionReason: "Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG", + } + if (taxType === "12.3 UStG") return { category: "Z", rate: 0 } + return rate > 0 ? { category: "S", rate } : { category: "Z", rate: 0 } +} + +function advanceInvoiceGross(invoice: any) { + const row = (invoice?.rows || []).find((item: any) => item.advanceInvoiceData) + return row ? Number(row.price) * ((100 + Number(row.taxPercent)) / 100) : 0 +} + +function typeCode(type: string) { + if (type === "advanceInvoices") return "386" + if (type === "cancellationInvoices") return "381" + return "380" +} + +function partyAddress(address: { zip?: unknown, street?: unknown, special?: unknown, city?: unknown, country?: unknown }) { + return `${el("ram:PostcodeCode", address.zip)}${el("ram:LineOne", address.street)}${el("ram:LineTwo", address.special)}${el("ram:CityName", address.city)}${countryCode(address.country)}` +} + +function tradeContact(name: unknown, phone?: unknown, email?: unknown) { + if (!text(name) && !text(phone) && !text(email)) return "" + return `${el("ram:PersonName", name)}${text(phone) ? `${el("ram:CompleteNumber", phone)}` : ""}${text(email) ? `${el("ram:URIID", email)}` : ""}` +} + +function taxRegistrations(vatId: unknown, taxNumber?: unknown) { + return [ + text(vatId) ? `${escapeXml(text(vatId).replace(/\s+/g, ""))}` : "", + text(taxNumber) ? `${escapeXml(text(taxNumber))}` : "", + ].join("") +} + +export function buildZugferdInvoiceXml(document: any, context: ZugferdInvoiceContext): ZugferdInvoiceResult { + const warnings: string[] = [] + const businessInfo = context.tenant?.businessInfo || {} + const customer = context.customer || {} + const customerInfo = customer.infoData || {} + const address = document.address || {} + // Stornorechnungen tragen negative Preise; als Gutschrift (381) werden die Beträge positiv ausgewiesen. + const sign = document.type === "cancellationInvoices" ? -1 : 1 + + if (!text(document.documentNumber)) warnings.push("Rechnungsnummer fehlt") + if (!date102(document.documentDate)) warnings.push("Rechnungsdatum fehlt") + if (!text(businessInfo.name)) warnings.push("Firmenname des Verkäufers fehlt (Einstellungen > Firmendaten)") + if (!text(businessInfo.street) || !text(businessInfo.zip) || !text(businessInfo.city)) warnings.push("Anschrift des Verkäufers ist unvollständig") + if (!text(businessInfo.vatId) && !text(businessInfo.taxNumber)) warnings.push("Weder USt-IdNr. noch Steuernummer des Verkäufers hinterlegt") + if (!text(customer.name)) warnings.push("Name des Kunden fehlt") + if (!text(address.city) && !text(customerInfo.city)) warnings.push("Anschrift des Kunden ist unvollständig") + if (document.taxType === "13b UStG" && !text(customerInfo.ustid)) warnings.push("Für § 13b UStG ist die USt-IdNr. des Kunden erforderlich") + if (document.taxType === "13b UStG" && !text(businessInfo.vatId)) warnings.push("Für § 13b UStG ist die USt-IdNr. des Verkäufers erforderlich") + + const groups = new Map() + const lines: string[] = [] + let lineIndex = 0 + + for (const row of document.rows || []) { + if (!isItemRow(row)) continue + lineIndex++ + const discountPercent = Number(row.discountPercent) || 0 + const grossUnitPrice = sign * (Number(row.price) || 0) + let netUnitPrice = grossUnitPrice * (1 - discountPercent / 100) + let quantity = Number(row.quantity) || 0 + let grossPrice = grossUnitPrice + // EN 16931 verlangt nicht-negative Einzelpreise (BR-27); Minderungen laufen über eine negative Menge. + if (netUnitPrice < 0 || (netUnitPrice === 0 && grossPrice < 0)) { + netUnitPrice = -netUnitPrice + grossPrice = -grossPrice + quantity = -quantity + } + const lineTotal = round(quantity * netUnitPrice) + const rate = Number(row.taxPercent) || 0 + const group = taxGroupFor(document.taxType, rate) + const key = `${group.category}:${group.rate}` + const existing = groups.get(key) + if (existing) existing.basis = round(existing.basis + lineTotal) + else groups.set(key, { ...group, basis: lineTotal }) + + const product = row.mode === "normal" ? context.products.find((item) => item.id === row.product) : null + const service = row.mode === "service" ? context.services.find((item) => item.id === row.service) : null + const name = text(product?.name || service?.name || row.text) || `Position ${row.pos ?? lineIndex}` + const sellerId = product?.article_number + const description = text(row.agriculture?.description || row.description) + const unit = context.units.find((item) => item.id === row.unit) + const allowance = round(grossPrice - netUnitPrice, 4) + + lines.push( + `` + + `${escapeXml(text(row.pos) || String(lineIndex))}` + + `${el("ram:SellerAssignedID", sellerId)}${escapeXml(name)}${el("ram:Description", description)}` + + `` + + (allowance > 0 + ? `${decimal(grossPrice)}false${decimal(allowance)}` + : "") + + `${decimal(netUnitPrice)}` + + `` + + `${decimal(quantity)}` + + `VAT${group.category}${decimal(group.rate)}` + + `${amount(lineTotal)}` + + ``, + ) + } + + if (!lines.length) warnings.push("Der Beleg enthält keine abrechenbaren Positionen") + + const taxGroups = [...groups.values()] + const lineNet = round(taxGroups.reduce((sum, group) => sum + group.basis, 0)) + const taxTotal = round(taxGroups.reduce((sum, group) => sum + round(group.basis * group.rate / 100), 0)) + const grossTotal = round(lineNet + taxTotal) + const prepaid = ["invoices", "cancellationInvoices"].includes(document.type) + ? round(context.advanceInvoices.reduce((sum, invoice) => sum + advanceInvoiceGross(invoice), 0)) + : 0 + const payable = round(grossTotal - prepaid) + + // Zahlungsinformationen + const paymentAccount = context.bankAccounts.find((account) => !account.archived && !account.expired && account.iban) + const iban = text(businessInfo.iban || paymentAccount?.iban).replace(/\s+/g, "") + const bic = text(businessInfo.bic || paymentAccount?.bic).replace(/\s+/g, "") + const accountName = text(businessInfo.bankAccountOwner || paymentAccount?.ownerName || businessInfo.name) + let paymentMeans = "" + if (document.payment_type === "direct-debit") { + paymentMeans = `59SEPA-Lastschrift` + if (!text(context.tenant?.creditorId)) warnings.push("Für SEPA-Lastschrift ist keine Gläubiger-ID hinterlegt") + if (!text(context.sepaMandate?.reference)) warnings.push("Für SEPA-Lastschrift ist keine Mandatsreferenz hinterlegt") + } else if (document.payment_type === "transfer" || !document.payment_type) { + if (!iban) warnings.push("Für Überweisung ist keine IBAN des Verkäufers hinterlegt") + paymentMeans = `58SEPA-Überweisung` + + (iban ? `${escapeXml(iban)}${el("ram:AccountName", accountName)}` : "") + + (iban && bic ? `${escapeXml(bic)}` : "") + + `` + } else { + paymentMeans = `1` + } + + const paymentDays = document.paymentDays === null || document.paymentDays === undefined || document.paymentDays === "" ? null : Number(document.paymentDays) + const dueDate = date102(document.documentDate) && paymentDays !== null && Number.isFinite(paymentDays) + ? dayjs(document.documentDate).add(paymentDays, "day") + : null + const cashDiscount = ["invoices", "advanceInvoices"].includes(document.type) + ? buildCashDiscountTerms({ percent: document.cashDiscountPercent, days: document.cashDiscountDays, documentDate: document.documentDate, amount: payable }) + : null + const termsDescription = [ + payable <= 0 ? "" : dueDate ? `Zahlbar bis zum ${dueDate.format("DD.MM.YYYY")} ohne Abzug.` : "Zahlbar sofort ohne Abzug.", + // Strukturierte Skontoangabe nach XRechnung-Konvention, die von gängigen Empfängern ausgewertet wird. + cashDiscount && cashDiscount.days !== null + ? `#SKONTO#TAGE=${cashDiscount.days}#PROZENT=${cashDiscount.percent.toFixed(2)}#BASISBETRAG=${amount(payable)}#` + : "", + ].filter(Boolean).join("\n") + const paymentTerms = termsDescription || dueDate || context.sepaMandate?.reference + ? `${el("ram:Description", termsDescription)}${dueDate ? dateElement("ram:DueDateDateTime", dueDate.toDate()) : ""}${el("ram:DirectDebitMandateID", document.payment_type === "direct-debit" ? context.sepaMandate?.reference : "")}` + : "" + + const taxElements = taxGroups.map((group) => + `${amount(group.basis * group.rate / 100)}VAT${el("ram:ExemptionReason", group.exemptionReason)}${amount(group.basis)}${group.category}${el("ram:ExemptionReasonCode", group.exemptionReasonCode)}${decimal(group.rate)}`, + ).join("") + + // Leistungsdatum bzw. Leistungszeitraum + const deliveryType = text(document.deliveryDateType) + const isPeriod = ["Lieferzeitraum", "Leistungszeitraum"].includes(deliveryType) + const showDelivery = deliveryType !== "Kein Lieferdatum anzeigen" + const deliveryEvent = showDelivery && !isPeriod && date102(document.deliveryDate) + ? `${dateElement("ram:OccurrenceDateTime", document.deliveryDate)}` + : "" + const billingPeriod = showDelivery && isPeriod && (date102(document.deliveryDate) || date102(document.deliveryDateEnd)) + ? `${dateElement("ram:StartDateTime", document.deliveryDate)}${dateElement("ram:EndDateTime", document.deliveryDateEnd)}` + : "" + + const contactPerson = context.contactPerson || {} + const sellerContact = tradeContact(contactPerson.full_name, document.contactTel || contactPerson.fixed_tel || contactPerson.mobile_tel || businessInfo.phone, contactPerson.email || businessInfo.email) + const seller = `${el("ram:Name", businessInfo.name || context.tenant?.name)}${sellerContact}` + + partyAddress({ zip: businessInfo.zip, street: businessInfo.street, city: businessInfo.city, country: businessInfo.country }) + + (text(businessInfo.email) ? `${escapeXml(text(businessInfo.email))}` : "") + + taxRegistrations(businessInfo.vatId, businessInfo.taxNumber) + + `` + + const buyerEmail = text(customerInfo.invoiceEmail || customerInfo.email) + const contact = context.contact + const buyer = `${el("ram:ID", typeof customer.customerNumber === "object" ? "" : customer.customerNumber)}${el("ram:Name", customer.name)}` + + (contact ? tradeContact([contact.firstName, contact.lastName].filter(Boolean).join(" "), contact.phoneHome || contact.phoneMobile, contact.email) : "") + + partyAddress({ + zip: address.zip ?? customerInfo.zip, + street: address.street ?? customerInfo.street, + special: address.special ?? customerInfo.special, + city: address.city ?? customerInfo.city, + country: address.country ?? customerInfo.country, + }) + + (buyerEmail ? `${escapeXml(buyerEmail)}` : "") + + taxRegistrations(customerInfo.ustid) + + `` + + const contractReference = el("ram:IssuerAssignedID", context.contract?.contractNumber) + const projectReference = context.project?.name + ? `${escapeXml(text(context.project.projectNumber || context.project.id))}${escapeXml(text(context.project.name))}` + : "" + const precedingInvoice = document.type === "cancellationInvoices" && text(context.linkedDocument?.documentNumber) + ? `${escapeXml(text(context.linkedDocument.documentNumber))}${date102(context.linkedDocument.documentDate) ? `${date102(context.linkedDocument.documentDate)}` : ""}` + : "" + + const notes = [ + text(document.title), + text(document.description), + ].filter(Boolean).map((note) => `${escapeXml(note)}`).join("") + + const xml = ` +` + + `${ZUGFERD_GUIDELINE_ID}` + + `${escapeXml(text(document.documentNumber))}${typeCode(document.type)}` + + `${date102(document.documentDate) || dayjs().format("YYYYMMDD")}${notes}` + + `${lines.join("")}` + + `${el("ram:BuyerReference", context.project?.customerRef)}${seller}${buyer}` + + (contractReference ? `${contractReference}` : "") + + `${projectReference}` + + `${deliveryEvent}` + + `` + + (document.payment_type === "direct-debit" ? el("ram:CreditorReferenceID", context.tenant?.creditorId) : "") + + el("ram:PaymentReference", document.documentNumber) + + `EUR${paymentMeans}${taxElements}${billingPeriod}${paymentTerms}` + + `` + + `${amount(lineNet)}` + + `${amount(lineNet)}` + + `${amount(taxTotal)}` + + `${amount(grossTotal)}` + + (prepaid ? `${amount(prepaid)}` : "") + + `${amount(payable)}` + + `${precedingInvoice}` + + `` + + return { xml, warnings, totals: { lineNet, tax: taxTotal, gross: grossTotal, prepaid, payable } } +} diff --git a/backend/src/modules/einvoice/embedZugferd.ts b/backend/src/modules/einvoice/embedZugferd.ts new file mode 100644 index 00000000..68b591ae --- /dev/null +++ b/backend/src/modules/einvoice/embedZugferd.ts @@ -0,0 +1,80 @@ +import { AFRelationship, PDFDocument, PDFName } from "pdf-lib" +import { ZUGFERD_FILENAME } from "./buildZugferdInvoice" + +const escapeXml = (value: string) => value.replace(/&/g, "&").replace(//g, ">") + +function buildXmp(title: string, now: Date) { + const date = now.toISOString() + return ` + + + +application/pdf +${escapeXml(title)} + + +FEDEO +${date} +${date} +${date} + + +FEDEO + + +INVOICE +${ZUGFERD_FILENAME} +1.0 +EN 16931 + + + +Factur-X PDFA Extension Schema +urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0# +fx + +DocumentFileNameTextexternalname of the embedded XML invoice file +DocumentTypeTextexternalINVOICE +VersionTextexternalThe actual version of the Factur-X XML schema +ConformanceLevelTextexternalThe conformance level of the embedded Factur-X data + + + + + +` +} + +/** + * Bettet das CII-XML als factur-x.xml (AFRelationship Alternative) in die PDF ein und ergänzt die + * Factur-X/ZUGFeRD-XMP-Metadaten. Ergebnis ist eine hybride ZUGFeRD-Rechnung (PDF + strukturierte Daten). + */ +export async function embedZugferdXml(pdfBytes: Uint8Array | Buffer, xml: string, options: { title?: string } = {}) { + const pdfDoc = await PDFDocument.load(pdfBytes, { updateMetadata: false }) + const now = new Date() + const title = options.title || "Rechnung" + + await pdfDoc.attach(Buffer.from(xml, "utf8"), ZUGFERD_FILENAME, { + mimeType: "text/xml", + description: "Factur-X/ZUGFeRD Rechnung", + creationDate: now, + modificationDate: now, + afRelationship: AFRelationship.Alternative, + }) + + pdfDoc.setTitle(title, { showInWindowTitleBar: true }) + pdfDoc.setProducer("FEDEO") + pdfDoc.setCreator("FEDEO") + pdfDoc.setCreationDate(now) + pdfDoc.setModificationDate(now) + pdfDoc.setLanguage("de-DE") + + const metadata = pdfDoc.context.stream(Buffer.from(buildXmp(title, now), "utf8"), { + Type: "Metadata", + Subtype: "XML", + }) + pdfDoc.catalog.set(PDFName.of("Metadata"), pdfDoc.context.register(metadata)) + pdfDoc.setSubject("ZUGFeRD / Factur-X EN 16931") + + return Buffer.from(await pdfDoc.save({ useObjectStreams: false })) +} diff --git a/backend/src/modules/einvoice/outgoingZugferd.service.ts b/backend/src/modules/einvoice/outgoingZugferd.service.ts new file mode 100644 index 00000000..a400c061 --- /dev/null +++ b/backend/src/modules/einvoice/outgoingZugferd.service.ts @@ -0,0 +1,87 @@ +import { FastifyInstance } from "fastify" +import { and, eq, inArray } from "drizzle-orm" +import { + authProfiles, + bankaccounts, + contacts, + contracts, + createddocuments, + customers, + outgoingsepamandates, + products, + projects, + services, + tenants, + units, +} from "../../../db/schema" +import { buildZugferdInvoiceXml, ZUGFERD_DOCUMENT_TYPES, ZugferdInvoiceContext } from "./buildZugferdInvoice" +import { embedZugferdXml } from "./embedZugferd" + +export const isZugferdDocument = (document: any) => + Boolean(document && ZUGFERD_DOCUMENT_TYPES.includes(document.type) && document.documentNumber) + +const byId = async (server: FastifyInstance, table: any, id: unknown, tenantId: number) => { + if (id === null || id === undefined || id === "") return null + const [row] = await server.db.select().from(table).where(and(eq(table.id, Number(id)), eq(table.tenant, tenantId))).limit(1) + return row || null +} + +export async function loadZugferdContext(server: FastifyInstance, tenantId: number, document: any): Promise { + const rows = document.rows || [] + const productIds = [...new Set(rows.filter((row: any) => row.product).map((row: any) => Number(row.product)))] as number[] + const serviceIds = [...new Set(rows.filter((row: any) => row.service).map((row: any) => Number(row.service)))] as number[] + const advanceIds = (Array.isArray(document.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []).map(Number) as number[] + const profileUser = document.contactPerson || document.created_by + + const [[tenant], customer, contact, project, contract, linkedDocument, sepaMandate, unitRows, productRows, serviceRows, bankAccountRows, advanceRows, profileRows] = await Promise.all([ + server.db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1), + byId(server, customers, document.customer, tenantId), + byId(server, contacts, document.contact, tenantId), + byId(server, projects, document.project, tenantId), + byId(server, contracts, document.contract, tenantId), + byId(server, createddocuments, document.createddocument ?? document.linkedDocument, tenantId), + byId(server, outgoingsepamandates, document.outgoingsepamandate, tenantId), + server.db.select().from(units), + productIds.length ? server.db.select().from(products).where(and(eq(products.tenant, tenantId), inArray(products.id, productIds))) : [], + serviceIds.length ? server.db.select().from(services).where(and(eq(services.tenant, tenantId), inArray(services.id, serviceIds))) : [], + server.db.select().from(bankaccounts).where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))), + advanceIds.length ? server.db.select().from(createddocuments).where(and(eq(createddocuments.tenant, tenantId), inArray(createddocuments.id, advanceIds))) : [], + profileUser + ? server.db.select().from(authProfiles).where(and(eq(authProfiles.user_id, profileUser), eq(authProfiles.tenant_id, tenantId))).limit(1) + : [], + ]) + + return { + tenant, + customer, + contact, + contactPerson: (profileRows as any[])[0] || null, + project, + contract, + linkedDocument, + sepaMandate, + units: unitRows, + products: productRows as any[], + services: serviceRows as any[], + bankAccounts: bankAccountRows, + advanceInvoices: advanceIds.map((id) => (advanceRows as any[]).find((invoice) => invoice.id === id)).filter(Boolean), + } +} + +/** + * Macht aus der gerenderten Rechnungs-PDF eine ZUGFeRD-Rechnung. Fehler bei der E-Rechnung blockieren + * die Belegerstellung nicht: dann wird die unveränderte PDF mit einer Warnung zurückgegeben. + */ +export async function attachZugferdToPdf(server: FastifyInstance, tenantId: number, document: any, pdf: Buffer) { + if (!isZugferdDocument(document)) return { pdf, warnings: [] as string[], einvoice: false } + try { + const context = await loadZugferdContext(server, tenantId, document) + const { xml, warnings } = buildZugferdInvoiceXml(document, context) + const result = await embedZugferdXml(pdf, xml, { title: document.title || `Rechnung ${document.documentNumber}` }) + if (warnings.length) server.log.warn({ documentId: document.id, warnings }, "E-Rechnung mit Hinweisen erzeugt") + return { pdf: result, warnings, einvoice: true } + } catch (error) { + server.log.error({ err: error, documentId: document.id }, "E-Rechnung konnte nicht erzeugt werden") + return { pdf, warnings: ["E-Rechnung konnte nicht erzeugt werden"], einvoice: false } + } +} diff --git a/backend/src/modules/serialexecution.service.ts b/backend/src/modules/serialexecution.service.ts index f0d62a1c..91f15c30 100644 --- a/backend/src/modules/serialexecution.service.ts +++ b/backend/src/modules/serialexecution.service.ts @@ -9,6 +9,7 @@ import { saveFile } from "../utils/files"; import {FastifyInstance} from "fastify"; import {useNextNumberRangeNumber} from "../utils/functions"; import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen! +import { attachZugferdToPdf } from "./einvoice/outgoingZugferd.service"; import { documentTemplateHandlebars } from "../utils/handlebars"; import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount" @@ -233,8 +234,14 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI console.log(pdfBase64); // E. Datei speichern - // @ts-ignore - const fileBuffer = Buffer.from(pdfBase64.base64, "base64"); + // E-Rechnung (ZUGFeRD) in die PDF einbetten; Grundlage ist der Beleg mit der neuen Nummer + const { pdf: fileBuffer } = await attachZugferdToPdf( + server, + tenantId, + { ...doc, state: "Gebucht", documentNumber: pdfData.documentNumber, title: pdfData.title }, + // @ts-ignore + Buffer.from(pdfBase64.base64, "base64"), + ); const filename = `${pdfData.documentNumber}.pdf`; await saveFile( diff --git a/backend/src/routes/functions.ts b/backend/src/routes/functions.ts index 8f6d96b3..abbf19af 100644 --- a/backend/src/routes/functions.ts +++ b/backend/src/routes/functions.ts @@ -18,7 +18,8 @@ import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js" import duration from "dayjs/plugin/duration.js"; import timezone from "dayjs/plugin/timezone.js"; import {generateTimesEvaluation} from "../modules/time/evaluation.service"; -import {citys, files} from "../../db/schema"; +import {citys, createddocuments, files} from "../../db/schema"; +import { attachZugferdToPdf } from "../modules/einvoice/outgoingZugferd.service"; import {and, eq, isNull, not} from "drizzle-orm"; import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service"; import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service"; @@ -110,6 +111,8 @@ export default async function functionRoutes(server: FastifyInstance) { const body = req.body as { data: any backgroundPath?: string + // Gespeicherter Beleg, aus dem die eingebettete E-Rechnung (ZUGFeRD) erzeugt wird + createdDocumentId?: number } const {type} = req.params as {type:string} @@ -125,6 +128,17 @@ export default async function functionRoutes(server: FastifyInstance) { body.data, body.backgroundPath ) + if (body.createdDocumentId) { + const tenantId = (req as any).user.tenant_id + const [document] = await server.db + .select() + .from(createddocuments) + .where(and(eq(createddocuments.id, Number(body.createdDocumentId)), eq(createddocuments.tenant, tenantId))) + .limit(1) + if (!document) return reply.code(404).send({ error: "Beleg nicht gefunden" }) + const result = await attachZugferdToPdf(server, tenantId, document, Buffer.from(pdf.base64, "base64")) + pdf = { ...pdf, base64: result.pdf.toString("base64"), einvoice: result.einvoice, einvoiceWarnings: result.warnings } + } } else if(type === "timesheet") { pdf = await createTimeSheetPDF( server, diff --git a/backend/tests/zugferdInvoice.test.ts b/backend/tests/zugferdInvoice.test.ts new file mode 100644 index 00000000..8aa8b97d --- /dev/null +++ b/backend/tests/zugferdInvoice.test.ts @@ -0,0 +1,172 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { PDFDocument, StandardFonts } from "pdf-lib" +import { buildZugferdInvoiceXml, countryCode, unitCode, ZugferdInvoiceContext } from "../src/modules/einvoice/buildZugferdInvoice" +import { embedZugferdXml } from "../src/modules/einvoice/embedZugferd" +import { detectElectronicInvoice } from "../src/modules/einvoice/detectElectronicInvoice" +import { parseElectronicInvoice } from "../src/modules/einvoice/parseElectronicInvoice" + +const context = (overrides: Partial = {}): ZugferdInvoiceContext => ({ + tenant: { + name: "Muster GmbH", + creditorId: "DE98ZZZ09999999999", + businessInfo: { + name: "Muster GmbH", street: "Hauptstraße 1", zip: "12345", city: "Musterstadt", + vatId: "DE123456789", taxNumber: "12/345/67890", email: "rechnung@muster.de", + iban: "DE02120300000000202051", bic: "BYLADEM1001", bankAccountOwner: "Muster GmbH", + }, + }, + customer: { + name: "Kunde & Söhne KG", customerNumber: "10001", + infoData: { street: "Kundenweg 5", zip: "54321", city: "Kundenstadt", country: "Deutschland", ustid: "DE987654321", invoiceEmail: "buchhaltung@kunde.de" }, + }, + contactPerson: { full_name: "Erika Muster", email: "erika@muster.de" }, + units: [{ id: 1, short: "Stk" }, { id: 6, short: "Std" }], + products: [{ id: 7, name: "Kabel NYM 3x1,5", article_number: "A-7" }], + services: [{ id: 3, name: "Montage" }], + bankAccounts: [], + advanceInvoices: [], + ...overrides, +}) + +const invoice = (overrides: any = {}) => ({ + id: 1, + type: "invoices", + documentNumber: "RE-2026-0042", + documentDate: "2026-11-04", + deliveryDateType: "Lieferdatum", + deliveryDate: "2026-11-02", + paymentDays: 14, + payment_type: "transfer", + taxType: "Standard", + title: "Rechnung-Nr. RE-2026-0042", + address: { street: "Kundenweg 5", zip: "54321", city: "Kundenstadt" }, + rows: [ + { pos: "1", mode: "normal", product: 7, quantity: 10, unit: 1, price: 2.345, discountPercent: 0, taxPercent: 19 }, + { pos: "2", mode: "service", service: 3, quantity: 1.5, unit: 6, price: 60, discountPercent: 10, taxPercent: 19 }, + { pos: "3", mode: "free", text: "Fachbuch", quantity: 1, unit: 1, price: 20, discountPercent: 0, taxPercent: 7 }, + { mode: "title", text: "Optionales" }, + { pos: "4", mode: "free", text: "Optional", quantity: 1, unit: 1, price: 999, discountPercent: 0, taxPercent: 19, optional: true }, + { pos: "5", mode: "free", text: "Gutschrift Altmaterial", quantity: 1, unit: 1, price: -5, discountPercent: 0, taxPercent: 19 }, + ], + ...overrides, +}) + +const tag = (xml: string, name: string) => [...xml.matchAll(new RegExp(`]*)?>([^<]*)`, "g"))].map((match) => match[1]) + +test("erzeugt eine EN-16931-Rechnung mit konsistenten Summen", () => { + const { xml, warnings, totals } = buildZugferdInvoiceXml(invoice(), context()) + + assert.deepEqual(warnings, []) + // 23,45 + 81,00 + 20,00 - 5,00 (optionale Position ausgeschlossen) + assert.equal(totals.lineNet, 119.45) + // 19 %: 99,45 -> 18,90; 7 %: 20,00 -> 1,40 + assert.equal(totals.tax, 20.3) + assert.equal(totals.gross, 139.75) + assert.equal(totals.payable, 139.75) + assert.match(xml, /urn:cen.eu:en16931:2017<\/ram:ID>/) + assert.match(xml, /380<\/ram:TypeCode>/) + assert.match(xml, /Kunde & Söhne KG/) + assert.match(xml, /DE02120300000000202051<\/ram:IBANID>/) + assert.match(xml, /DE987654321<\/ram:ID>/) + assert.match(xml, /A-7<\/ram:SellerAssignedID>/) + assert.match(xml, /20261118<\/udt:DateTimeString>/) + assert.match(xml, /20261102/) + // Negative Preise werden als negative Menge mit positivem Nettopreis abgebildet (BR-27). + assert.match(xml, /5<\/ram:ChargeAmount><\/ram:NetPriceProductTradePrice><\/ram:SpecifiedLineTradeAgreement>-1 Number(value) >= 0)) + // vier Positionen plus zwei Steuergruppen (19 % und 7 %) + assert.deepEqual(tag(xml, "CategoryCode"), ["S", "S", "S", "S", "S", "S"]) + assert.deepEqual(tag(xml, "RateApplicablePercent").slice(-2), ["19", "7"]) +}) + +test("das eigene XML wird vom FEDEO-Parser ohne Fehler gelesen", async () => { + const { xml } = buildZugferdInvoiceXml(invoice(), context()) + const parsed = parseElectronicInvoice(Buffer.from(xml), "cii") + + assert.deepEqual(parsed.validation.errors, []) + assert.equal(parsed.invoiceNumber, "RE-2026-0042") + assert.equal(parsed.seller.vatId, "DE123456789") + assert.equal(parsed.totals.gross, 139.75) + assert.equal(parsed.items.length, 4) +}) + +test("bettet das XML als factur-x.xml in die PDF ein", async () => { + const source = await PDFDocument.create() + const page = source.addPage() + page.drawText("Rechnung", { font: await source.embedFont(StandardFonts.Helvetica), x: 50, y: 700 }) + const { xml } = buildZugferdInvoiceXml(invoice(), context()) + + const pdf = await embedZugferdXml(await source.save(), xml, { title: "Rechnung RE-2026-0042" }) + const detection = await detectElectronicInvoice(pdf, { name: "RE-2026-0042.pdf", mimeType: "application/pdf" }) + + assert.equal(detection?.container, "pdf") + assert.equal(detection?.syntax, "cii") + assert.equal(detection?.attachmentName, "factur-x.xml") + assert.equal(detection?.xml.toString("utf8"), xml) + const raw = pdf.toString("latin1") + assert.match(raw, /\/AFRelationship \/Alternative/) + assert.match(raw, /EN 16931<\/fx:ConformanceLevel>/) + assert.equal((await PDFDocument.load(pdf)).getPageCount(), 1) +}) + +test("Stornorechnung wird als Gutschrift mit positiven Beträgen und Bezug zur Rechnung ausgegeben", () => { + const { xml, totals } = buildZugferdInvoiceXml(invoice({ + type: "cancellationInvoices", + documentNumber: "RE-2026-0043", + rows: [{ pos: "1", mode: "free", text: "Montage", quantity: 2, unit: 6, price: -50, discountPercent: 0, taxPercent: 19 }], + }), context({ linkedDocument: { documentNumber: "RE-2026-0042", documentDate: "2026-11-04" } })) + + assert.match(xml, /381<\/ram:TypeCode>/) + assert.equal(totals.gross, 119) + assert.match(xml, /2<\/ram:BilledQuantity>/) + assert.match(xml, /RE-2026-0042<\/ram:IssuerAssignedID>/) +}) + +test("Schlussrechnung zieht Abschläge als bereits gezahlten Betrag ab", () => { + const { xml, totals } = buildZugferdInvoiceXml(invoice({ + rows: [{ pos: "1", mode: "free", text: "Gesamtleistung", quantity: 1, unit: 1, price: 1000, discountPercent: 0, taxPercent: 19 }], + }), context({ advanceInvoices: [{ rows: [{ advanceInvoiceData: {}, price: 500, taxPercent: 19 }] }] })) + + assert.equal(totals.gross, 1190) + assert.equal(totals.prepaid, 595) + assert.equal(totals.payable, 595) + assert.match(xml, /595.00<\/ram:TotalPrepaidAmount>595.00<\/ram:DuePayableAmount>/) +}) + +test("§ 13b und Kleinunternehmer verwenden die passenden Steuerkategorien", () => { + const reverseCharge = buildZugferdInvoiceXml(invoice({ taxType: "13b UStG", rows: [{ pos: "1", mode: "free", text: "Bauleistung", quantity: 1, unit: 1, price: 100, taxPercent: 0 }] }), context()) + assert.match(reverseCharge.xml, /AE<\/ram:CategoryCode>VATEX-EU-AE<\/ram:ExemptionReasonCode>/) + assert.equal(reverseCharge.totals.tax, 0) + + const smallBusiness = buildZugferdInvoiceXml(invoice({ taxType: "19 UStG", rows: [{ pos: "1", mode: "free", text: "Beratung", quantity: 1, unit: 1, price: 100, taxPercent: 0 }] }), context()) + assert.match(smallBusiness.xml, /Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG<\/ram:ExemptionReason>/) + assert.match(smallBusiness.xml, /E<\/ram:CategoryCode>/) + + const missingVat = buildZugferdInvoiceXml(invoice({ taxType: "13b UStG" }), context({ customer: { name: "Privat", infoData: { city: "X" } } })) + assert.ok(missingVat.warnings.some((warning) => warning.includes("USt-IdNr. des Kunden"))) +}) + +test("Leistungszeitraum, Lastschrift und Skonto werden strukturiert übertragen", () => { + const { xml, warnings } = buildZugferdInvoiceXml(invoice({ + deliveryDateType: "Leistungszeitraum", deliveryDate: "2026-10-01", deliveryDateEnd: "2026-10-31", + payment_type: "direct-debit", cashDiscountPercent: 2, cashDiscountDays: 7, + }), context({ sepaMandate: { reference: "MANDAT-1" } })) + + assert.deepEqual(warnings, []) + assert.match(xml, /20261001/) + assert.match(xml, /DE98ZZZ09999999999<\/ram:CreditorReferenceID>/) + assert.match(xml, /59<\/ram:TypeCode>/) + assert.match(xml, /MANDAT-1<\/ram:DirectDebitMandateID>/) + assert.match(xml, /#SKONTO#TAGE=7#PROZENT=2.00#BASISBETRAG=139.75#/) + assert.doesNotMatch(xml, /ActualDeliverySupplyChainEvent/) +}) + +test("ordnet Länder und Einheiten den Codelisten zu", () => { + assert.equal(countryCode("Österreich"), "AT") + assert.equal(countryCode(""), "DE") + assert.equal(countryCode("ch"), "CH") + assert.equal(unitCode({ short: "Std" }), "HUR") + assert.equal(unitCode({ short: "m²" }), "MTK") + assert.equal(unitCode({ short: "unbekannt" }), "C62") +}) diff --git a/frontend/composables/useFunctions.js b/frontend/composables/useFunctions.js index 777301e6..b7d245f4 100644 --- a/frontend/composables/useFunctions.js +++ b/frontend/composables/useFunctions.js @@ -34,15 +34,19 @@ export const useFunctions = () => { return (await useNuxtApp().$api(`/api/banking/link/${institutionId}`)).link } - const useCreatePDF = async (data,path,type) => { + // options.createdDocumentId: gespeicherter Beleg, aus dem das Backend die eingebettete E-Rechnung (ZUGFeRD) erzeugt + const useCreatePDF = async (data,path,type,options = {}) => { const returnData = await useNuxtApp().$api(`/api/functions/pdf/${type}`, { method: "POST", body: { data: data, backgroundPath: path, + ...(options.createdDocumentId ? { createdDocumentId: options.createdDocumentId } : {}), } }) + if (options.onResult) options.onResult(returnData) + console.log(returnData) return `data:${returnData.mimeType};base64,${returnData.base64}` diff --git a/frontend/pages/createDocument/edit/[[id]].vue b/frontend/pages/createDocument/edit/[[id]].vue index 31ddab9c..22fe87a2 100644 --- a/frontend/pages/createDocument/edit/[[id]].vue +++ b/frontend/pages/createDocument/edit/[[id]].vue @@ -1764,7 +1764,7 @@ const getDocumentData = async () => { const showDocument = ref(false) const uri = ref("") -const generateDocument = async () => { +const generateDocument = async (options = {}) => { showDocument.value = false const selectedLetterhead = getSelectedLetterhead() @@ -1775,7 +1775,7 @@ const generateDocument = async () => { const path = selectedLetterhead.path - uri.value = await useFunctions().useCreatePDF(await getDocumentData(), path, "createdDocument") + uri.value = await useFunctions().useCreatePDF(await getDocumentData(), path, "createdDocument", options) /*uri.value = await useNuxtApp().$api("/api/functions/createinvoicepdf",{ method: "POST", body: { @@ -2048,7 +2048,19 @@ const closeDocument = async () => { // Erzeugt die PDF aus dem aktuellen Stand und hängt sie an den Beleg an, ohne den Datensatz zu speichern const uploadGeneratedDocument = async () => { - await generateDocument() + // Rechnungen werden als E-Rechnung (ZUGFeRD) abgelegt; das XML erzeugt das Backend aus dem gespeicherten Beleg + await generateDocument({ + createdDocumentId: itemInfo.value.id, + onResult: (result) => { + if (result?.einvoiceWarnings?.length) { + toast.add({ + title: result.einvoice ? "E-Rechnung mit Hinweisen erstellt" : "E-Rechnung konnte nicht erstellt werden", + description: result.einvoiceWarnings.join(" · "), + color: "warning", + }) + } + }, + }) if (!uri.value) throw new Error("PDF konnte nicht erzeugt werden")