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>
173 lines
9.6 KiB
TypeScript
173 lines
9.6 KiB
TypeScript
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 & 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")
|
|
})
|