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)}${name}>` : ""
+}
+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}${name}>` : ""
+}
+
+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)
+ assert.ok(tag(xml, "ChargeAmount").every((value) => 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")