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]>
172 lines
6.6 KiB
TypeScript
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 & Söhne <GmbH> "Bio"</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("<a href="x">'&'</a>");
|
|
});
|
|
});
|