import { describe, test, expect } from "vitest"; import { amount, buildCiiXml, escapeXml, type EInvoiceInput } from "./cii"; import { vatBreakdown } from "@/lib/vat"; function input(overrides: Partial = {}): 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: "hallo@wabenhain.com", }, 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}[^>]*>([^<]*)`, "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 = /([^<]*) { 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(/([^<]*) 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("urn:cen.eu:en16931:2017"); expect(xml).toContain("380"); expect(xml).toContain('20261012'); expect(xml).toContain('DE123456789'); expect(xml).toContain("Rabatt (SOMMER)"); expect(xml).toContain("Versand"); }); 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("381"); expect(xml).toMatch( /RE-2026-0042<\/ram:IssuerAssignedID>20261001 { // 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 "Bio"' } })); // Assert expect(xml).toContain("Müller & Söhne <GmbH> "Bio""); }); }); 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(`'&'`)).toBe("<a href="x">'&'</a>"); }); });