feat(accounting): Ausgangsrechnungen als ZUGFeRD-E-Rechnung (EN 16931) erzeugen

Rechnungen, Abschlags-, Storno- und Serienrechnungen erhalten beim
Fertigstellen ein eingebettetes factur-x.xml (CII, Profil EN 16931) samt
Factur-X-XMP-Metadaten. Das XML wird serverseitig aus dem gespeicherten
Beleg erzeugt; fehlende Pflichtangaben werden als Hinweis gemeldet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 10:15:23 +01:00
parent bd768e404d
commit 549b469a41
9 changed files with 726 additions and 8 deletions

View File

@@ -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> = {}): 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(`<ram:${name}(?:\\s[^>]*)?>([^<]*)</ram:${name}>`, "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, /<ram:ID>urn:cen.eu:en16931:2017<\/ram:ID>/)
assert.match(xml, /<ram:TypeCode>380<\/ram:TypeCode>/)
assert.match(xml, /Kunde &amp; Söhne KG/)
assert.match(xml, /<ram:IBANID>DE02120300000000202051<\/ram:IBANID>/)
assert.match(xml, /<ram:ID schemeID="VA">DE987654321<\/ram:ID>/)
assert.match(xml, /<ram:SellerAssignedID>A-7<\/ram:SellerAssignedID>/)
assert.match(xml, /<ram:DueDateDateTime><udt:DateTimeString format="102">20261118<\/udt:DateTimeString>/)
assert.match(xml, /<ram:OccurrenceDateTime><udt:DateTimeString format="102">20261102/)
// Negative Preise werden als negative Menge mit positivem Nettopreis abgebildet (BR-27).
assert.match(xml, /<ram:ChargeAmount>5<\/ram:ChargeAmount><\/ram:NetPriceProductTradePrice><\/ram:SpecifiedLineTradeAgreement><ram:SpecifiedLineTradeDelivery><ram:BilledQuantity unitCode="H87">-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, /<fx:ConformanceLevel>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, /<ram:TypeCode>381<\/ram:TypeCode>/)
assert.equal(totals.gross, 119)
assert.match(xml, /<ram:BilledQuantity unitCode="HUR">2<\/ram:BilledQuantity>/)
assert.match(xml, /<ram:InvoiceReferencedDocument><ram:IssuerAssignedID>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, /<ram:TotalPrepaidAmount>595.00<\/ram:TotalPrepaidAmount><ram:DuePayableAmount>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, /<ram:CategoryCode>AE<\/ram:CategoryCode><ram:ExemptionReasonCode>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, /<ram:ExemptionReason>Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG<\/ram:ExemptionReason>/)
assert.match(smallBusiness.xml, /<ram:CategoryCode>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, /<ram:BillingSpecifiedPeriod><ram:StartDateTime><udt:DateTimeString format="102">20261001/)
assert.match(xml, /<ram:CreditorReferenceID>DE98ZZZ09999999999<\/ram:CreditorReferenceID>/)
assert.match(xml, /<ram:TypeCode>59<\/ram:TypeCode>/)
assert.match(xml, /<ram:DirectDebitMandateID>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")
})