Files
Wabenhain/src/lib/einvoice/cii.test.ts
T
Nick SzaunigandClaude Opus 5.5 508ff24aa7 feat(invoice): Rechnungen als E-Rechnung (ZUGFeRD/Factur-X EN 16931)
Jedes Rechnungs-PDF ist jetzt eine hybride E-Rechnung: PDF/A-3b mit
eingebettetem factur-x.xml (CII D16B, Profil EN 16931). Netto und Steuer je
Satz kommen unveraendert aus der Aufstellung des PDFs, Versand und Rabatt
stehen als Zu- und Abschlag je Satz; XML und PDF tragen dieselben Summen.

- lib/einvoice/cii.ts: XML-Erzeugung (Rechnung 380, Stornorechnung 381 mit
  Bezug), ohne USt-IdNr. oder bei Positionen ohne Satz nur PDF mit Warnung
- lib/einvoice/facturx-pdf.ts: PDF/A-3b, Factur-X-XMP mit Erweiterungsschema,
  eingebettete Liberation-Schriften (gleiche Laufweiten wie Helvetica/Times)
- invoice-pdf: Darstellung als Stornorechnung (negative Betraege, Bezug)
- Geprueft mit XSD, CEN-Schematron EN 16931 1.3.16 und Mustang 2.26
  (veraPDF: PDF/A-3b konform, XML valid)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:32:44 +02:00

172 lines
6.6 KiB
TypeScript

import { describe, test, expect } from "vitest";
import { amount, buildCiiXml, escapeXml, type EInvoiceInput } from "./cii";
import { vatBreakdown } from "@/lib/vat";
function input(overrides: Partial<EInvoiceInput> = {}): EInvoiceInput {
const items = [
{ name: "Waldhonig 500 g", quantity: 3, totalCents: 2697, vatRatePercent: 7 },
{ name: "Bienenwachskerze", quantity: 1, totalCents: 1290, vatRatePercent: 19 },
{ name: "Blütenhonig 250 g", quantity: 2, totalCents: 1198, vatRatePercent: 7 },
];
return {
kind: "invoice",
number: "RE-2026-0042",
issuedAt: new Date(2026, 9, 12, 10, 0),
currency: "EUR",
orderNumber: "WH-2026-0042",
seller: {
name: "Wabenhain",
street: "Musterweg 1",
zip: "12345",
city: "Musterstadt",
countryCode: "DE",
vatId: "DE123456789",
email: "[email protected]",
},
buyer: { name: "Erika Musterfrau", street: "Musterstraße 1", zip: "80331", city: "München", countryCode: "DE" },
items,
shippingCents: 490,
discountCents: 300,
discountCode: "SOMMER",
totalCents: 2697 + 1290 + 1198 + 490 - 300,
paymentNote: "Bereits bezahlt (Online-Zahlung über Stripe).",
...overrides,
};
}
function values(xml: string, element: string): string[] {
return [...xml.matchAll(new RegExp(`<${element}[^>]*>([^<]*)</${element}>`, "g"))].map((match) => match[1]);
}
function cents(value: string): number {
return Math.round(Number(value) * 100);
}
function single(xml: string, element: string): number {
const found = values(xml, element);
expect(found).toHaveLength(1);
return cents(found[0]);
}
function headerLineTotal(xml: string): number {
const match = /<ram:SpecifiedTradeSettlementHeaderMonetarySummation><ram:LineTotalAmount>([^<]*)</.exec(xml);
if (!match) throw new Error("BT-106 fehlt");
return cents(match[1]);
}
function ok(data: EInvoiceInput): string {
const result = buildCiiXml(data);
if (!result.ok) throw new Error(result.reason);
return result.xml;
}
describe("buildCiiXml", () => {
test("Summen stimmen mit der Steueraufstellung des PDFs auf den Cent ueberein", () => {
// Arrange
const data = input();
const rated = data.items.map((item) => ({ totalCents: item.totalCents, vatRatePercent: item.vatRatePercent ?? 0 }));
const expected = vatBreakdown({ items: rated, shippingCents: 490, discountCents: 300 });
// Act
const xml = ok(data);
// Assert
expect(values(xml, "ram:BasisAmount").map(cents)).toEqual(expected.map((line) => line.netCents));
expect(values(xml, "ram:CalculatedAmount").map(cents)).toEqual(expected.map((line) => line.vatCents));
const basis = single(xml, "ram:TaxBasisTotalAmount");
const tax = single(xml, "ram:TaxTotalAmount");
expect(headerLineTotal(xml) - single(xml, "ram:AllowanceTotalAmount") + single(xml, "ram:ChargeTotalAmount")).toBe(basis);
expect(basis + tax).toBe(data.totalCents);
expect(single(xml, "ram:GrandTotalAmount")).toBe(data.totalCents);
expect(single(xml, "ram:TotalPrepaidAmount")).toBe(data.totalCents);
expect(single(xml, "ram:DuePayableAmount")).toBe(0);
});
test("Positionen ergeben zusammen genau die Positionssumme (BT-106)", () => {
// Act
const xml = ok(input());
// Assert
const lineAmounts = [
...xml.matchAll(/<ram:SpecifiedTradeSettlementLineMonetarySummation><ram:LineTotalAmount>([^<]*)</g),
].map((match) => cents(match[1]));
expect(lineAmounts).toHaveLength(3);
expect(lineAmounts.reduce((sum, value) => sum + value, 0)).toBe(headerLineTotal(xml));
expect(values(xml, "ram:BilledQuantity")).toEqual(["3", "2", "1"]);
});
test("Kopfdaten: Profil EN 16931, Typ 380, Datum, Verkaeufer mit USt-IdNr.", () => {
// Act
const xml = ok(input());
// Assert
expect(xml).toContain("<ram:ID>urn:cen.eu:en16931:2017</ram:ID>");
expect(xml).toContain("<ram:TypeCode>380</ram:TypeCode>");
expect(xml).toContain('<udt:DateTimeString format="102">20261012</udt:DateTimeString>');
expect(xml).toContain('<ram:ID schemeID="VA">DE123456789</ram:ID>');
expect(xml).toContain("<ram:Reason>Rabatt (SOMMER)</ram:Reason>");
expect(xml).toContain("<ram:Reason>Versand</ram:Reason>");
});
test("Stornorechnung ist eine Gutschrift (381) mit Bezug, offen zur Erstattung", () => {
// Arrange
const data = input({
kind: "creditNote",
number: "ST-2026-0001",
precedingInvoice: { number: "RE-2026-0042", issuedAt: new Date(2026, 9, 1, 12, 0) },
paymentNote: "Der Betrag wird über die ursprüngliche Zahlungsart erstattet.",
});
// Act
const xml = ok(data);
// Assert
expect(xml).toContain("<ram:TypeCode>381</ram:TypeCode>");
expect(xml).toMatch(
/<ram:InvoiceReferencedDocument><ram:IssuerAssignedID>RE-2026-0042<\/ram:IssuerAssignedID><ram:FormattedIssueDateTime><qdt:DateTimeString format="102">20261001</
);
expect(single(xml, "ram:TotalPrepaidAmount")).toBe(0);
expect(single(xml, "ram:DuePayableAmount")).toBe(data.totalCents);
});
test("Kleinunternehmer: Kategorie E mit Befreiungsgrund", () => {
// Arrange
const items = [{ name: "Honig", quantity: 1, totalCents: 1000, vatRatePercent: 0 }];
// Act
const xml = ok(input({ items, shippingCents: 0, discountCents: 0, totalCents: 1000 }));
// Assert
expect(values(xml, "ram:CategoryCode").every((code) => code === "E")).toBe(true);
expect(xml).toContain("§ 19 UStG");
expect(single(xml, "ram:TaxTotalAmount")).toBe(0);
});
test("ohne USt-IdNr. oder mit Position ohne Steuersatz keine E-Rechnung", () => {
expect(buildCiiXml(input({ seller: { ...input().seller, vatId: " " } }))).toMatchObject({ ok: false });
const items = [{ name: "Alt", quantity: 1, totalCents: 1000, vatRatePercent: null }];
expect(buildCiiXml(input({ items, shippingCents: 0, discountCents: 0, totalCents: 1000 }))).toMatchObject({ ok: false });
});
test("Sonderzeichen im Namen werden maskiert", () => {
// Act
const xml = ok(input({ buyer: { ...input().buyer, name: 'Müller & Söhne <GmbH> "Bio"' } }));
// Assert
expect(xml).toContain("<ram:Name>Müller &amp; Söhne &lt;GmbH&gt; &quot;Bio&quot;</ram:Name>");
});
});
describe("Hilfsfunktionen", () => {
test("amount formatiert Cent ohne Gleitkommafehler", () => {
expect(amount(0)).toBe("0.00");
expect(amount(5)).toBe("0.05");
expect(amount(123456)).toBe("1234.56");
expect(amount(-250)).toBe("-2.50");
});
test("escapeXml maskiert alle fuenf Sonderzeichen", () => {
expect(escapeXml(`<a href="x">'&'</a>`)).toBe("&lt;a href=&quot;x&quot;&gt;&apos;&amp;&apos;&lt;/a&gt;");
});
});