import assert from "node:assert/strict" import test from "node:test" import { PDFDocument, StandardFonts } from "pdf-lib" import { buildZugferdInvoiceXml, checkZugferdRequirements, 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", customer: 5, 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, issues, totals } = buildZugferdInvoiceXml(invoice(), context()) assert.deepEqual(issues, []) // 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>/) }) test("Leistungszeitraum, Lastschrift und Skonto werden strukturiert übertragen", () => { const { xml, issues } = 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(issues, []) 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") }) const breakingMessages = (document: any, ctx: ZugferdInvoiceContext, requireNumber = false) => checkZugferdRequirements(document, ctx, { requireNumber }).filter((issue) => issue.type === "breaking").map((issue) => issue.message) test("Pflichtangaben: vollständiger Beleg hat keine blockierenden Fehler", () => { assert.deepEqual(breakingMessages(invoice(), context(), true), []) assert.deepEqual(breakingMessages(invoice({ documentNumber: null }), context()), []) assert.deepEqual(breakingMessages(invoice({ documentNumber: null }), context(), true), ["E-Rechnung: Die Rechnungsnummer fehlt"]) }) test("Pflichtangaben: fehlende Firmendaten, Kundenanschrift und IBAN blockieren", () => { const ctx = context({ tenant: { businessInfo: { name: "Muster GmbH", street: "", zip: "12345", city: "Musterstadt" } }, }) const messages = breakingMessages(invoice({ address: { street: "Kundenweg 5", zip: "", city: "Kundenstadt" } }), ctx) assert.ok(messages.some((message) => message.includes("Firmenanschrift"))) assert.ok(messages.some((message) => message.includes("weder eine USt-IdNr. noch eine Steuernummer"))) assert.ok(messages.some((message) => message.includes("Anschrift des Kunden"))) assert.ok(messages.some((message) => message.includes("keine IBAN"))) // Ein Bankkonto mit IBAN genügt anstelle der IBAN in den Firmendaten const withAccount = context({ bankAccounts: [{ iban: "DE02120300000000202051" }] }) withAccount.tenant.businessInfo.iban = "" assert.deepEqual(breakingMessages(invoice(), withAccount), []) }) test("Pflichtangaben: § 13b und Lastschrift verlangen ihre Zusatzangaben", () => { const reverseCharge = breakingMessages(invoice({ taxType: "13b UStG" }), context({ customer: { name: "Bau AG", infoData: {} } })) assert.deepEqual(reverseCharge, ["E-Rechnung: Für § 13b UStG muss beim Kunden eine USt-Id hinterlegt sein"]) const directDebit = breakingMessages(invoice({ payment_type: "direct-debit" }), context({ tenant: { ...context().tenant, creditorId: "" } })) assert.deepEqual(directDebit, [ "E-Rechnung: Für SEPA-Lastschrift ist in den Firmendaten keine Gläubiger-ID hinterlegt", "E-Rechnung: Für SEPA-Lastschrift ist kein Mandat mit Mandatsreferenz ausgewählt", ]) const noDate = checkZugferdRequirements(invoice({ deliveryDateType: "Kein Lieferdatum anzeigen" }), context()) assert.deepEqual(noDate.map((issue) => issue.type), ["info"]) })