/** * Sprint E: tax jurisdiction, date parsing, payment method, * bounding-box honesty, leftover product fixes. */ import { describe, test, expect } from "./runner"; import { PENDING_VALIDATION, ReceiptData } from "../../src/lib/schema/receipt"; import { reconcileAndEnhanceReceiptData } from "../../src/lib/ai/extractor"; import { inferTaxCountry, resolveTaxRatePercent, snapVatRate, } from "../../src/lib/tax/rates"; import { parseReceiptDate } from "../../src/lib/parse/receiptDate"; import { normalizePaymentMethod } from "../../src/lib/parse/paymentMethod"; import { hasExtractedBoundingBoxes } from "../../src/lib/utils/boundingBoxes"; import { parseTaxRatePercent, taxTotalsByRate, } from "../../src/components/dashboard/receiptFormat"; import { sanitizeExtractionOutput } from "../../src/lib/ai/promptInjection"; function receipt(overrides: Partial = {}): ReceiptData { return { merchant: { name: "Shop", address: null, taxId: null, confidence: 0.9 }, date: { isoDate: "2026-08-12", time: null, confidence: 0.9 }, documentType: "KASSENBON", receiptNumber: null, currency: "EUR", totalAmount: { value: 119, confidence: 0.9 }, netAmount: 100, taxBreakdown: [], lineItems: [], suggestedCategory: "Sonstiges", validation: { ...PENDING_VALIDATION }, ...overrides, }; } describe("Sprint E — Steuerland", () => { test("ATU / Wien → AT, CHE / Zürich → CH, DE → DE, USD → OTHER", () => { expect(inferTaxCountry({ taxId: "ATU12345678", currency: "EUR" })).toBe("AT"); expect(inferTaxCountry({ address: "Kärntner Straße 1, Wien", currency: "EUR" })).toBe("AT"); expect(inferTaxCountry({ taxId: "CHE-123.456.789 MWST", currency: "CHF" })).toBe("CH"); expect(inferTaxCountry({ currency: "CHF" })).toBe("CH"); expect(inferTaxCountry({ taxId: "DE123456789", currency: "EUR" })).toBe("DE"); expect(inferTaxCountry({ currency: "EUR" })).toBe("DACH"); expect(inferTaxCountry({ currency: "USD" })).toBe("OTHER"); }); test("AT 20% wird nicht auf DE 19% gesnappt", () => { expect(snapVatRate(20, "AT")).toBe(20); expect(snapVatRate(19.2, "AT")).toBe(20); expect(snapVatRate(19, "DE")).toBe(19); expect(snapVatRate(8.1, "CH")).toBe(8.1); expect(snapVatRate(8.05, "CH")).toBe(8.1); expect(snapVatRate(8.75, "OTHER")).toBe(8.75); expect(snapVatRate(20, "DACH")).toBe(20); expect(snapVatRate(19, "DACH")).toBe(19); }); test("fehlender Steuersatz wird nicht mit 19 gefüllt", () => { expect(resolveTaxRatePercent({ taxAmount: 0, netAmount: 10 }, "DE")).toBe(0); expect( resolveTaxRatePercent({ taxAmount: 20, netAmount: 100 }, "AT") ).toBe(20); }); }); describe("Sprint E — Reconcile Steuersätze", () => { test("AT-Beleg mit Netto/Brutto bekommt 20%, nicht 19%", () => { const out = reconcileAndEnhanceReceiptData( receipt({ merchant: { name: "Billa", address: "Wien", taxId: "ATU12345678", confidence: 0.9 }, totalAmount: { value: 120, confidence: 0.9 }, netAmount: 100, taxBreakdown: [], }) ); expect(out.taxBreakdown).toHaveLength(1); expect(out.taxBreakdown[0].ratePercent).toBe(20); expect(out.taxBreakdown[0].taxAmount).toBe(20); }); test("CH-Beleg mit CHF wird auf 8.1% gesnappt", () => { const out = reconcileAndEnhanceReceiptData( receipt({ merchant: { name: "Migros", address: "Zürich", taxId: "CHE-123.456.789", confidence: 0.9 }, currency: "CHF", totalAmount: { value: 108.1, confidence: 0.9 }, netAmount: 100, taxBreakdown: [], }) ); expect(out.taxBreakdown[0].ratePercent).toBe(8.1); }); test("DE-Beleg bleibt bei 19%", () => { const out = reconcileAndEnhanceReceiptData( receipt({ merchant: { name: "REWE", address: "Berlin", taxId: "DE123456789", confidence: 0.9 }, totalAmount: { value: 119, confidence: 0.9 }, netAmount: 100, taxBreakdown: [], }) ); expect(out.taxBreakdown[0].ratePercent).toBe(19); }); test("EUR ohne Land-Hinweis: 20% bleibt 20, nicht 19", () => { const out = reconcileAndEnhanceReceiptData( receipt({ merchant: { name: "Spar", address: null, taxId: null, confidence: 0.9 }, currency: "EUR", totalAmount: { value: 120, confidence: 0.9 }, netAmount: 100, taxBreakdown: [], }) ); expect(out.taxBreakdown[0].ratePercent).toBe(20); }); test("US-Beleg ohne DACH-Satz bleibt bei berechnetem Satz", () => { const out = reconcileAndEnhanceReceiptData( receipt({ merchant: { name: "Walgreens", address: "United States", taxId: null, confidence: 0.9 }, currency: "USD", totalAmount: { value: 108.75, confidence: 0.9 }, netAmount: 100, taxBreakdown: [], }) ); expect(out.taxBreakdown[0].ratePercent).toBe(8.75); }); test("leere ratePercent-Zeile wird 0, nicht 19", () => { const out = reconcileAndEnhanceReceiptData( receipt({ taxBreakdown: [{ ratePercent: undefined as unknown as number, taxAmount: 0, netAmount: 50 }], }) ); expect(out.taxBreakdown[0].ratePercent).toBe(0); }); }); describe("Sprint E — Datumsparser", () => { test("ISO bleibt ISO", () => { expect(parseReceiptDate("2026-08-16")).toBe("2026-08-16"); }); test("DACH-Punkt-Datum ist Tag.Monat.Jahr", () => { expect(parseReceiptDate("16.08.2026")).toBe("2026-08-16"); expect(parseReceiptDate("16.08.26")).toBe("2026-08-16"); }); test("Jahr-zuerst mit Punkt/Slash", () => { expect(parseReceiptDate("2026/08/16")).toBe("2026-08-16"); }); test("eindeutige Slash-Daten: Tag > 12 vs Monat > 12", () => { expect(parseReceiptDate("16/08/2026")).toBe("2026-08-16"); expect(parseReceiptDate("08/16/2026")).toBe("2026-08-16"); }); test("mehrdeutiges 01/02/2026: EUR Tag zuerst, USD Monat zuerst", () => { expect(parseReceiptDate("01/02/2026", { currency: "EUR" })).toBe("2026-02-01"); expect(parseReceiptDate("01/02/2026", { currency: "USD" })).toBe("2026-01-02"); }); test("Reconcile wandelt 16.08.2026 nach ISO", () => { const out = reconcileAndEnhanceReceiptData( receipt({ date: { isoDate: "16.08.2026", time: null, confidence: 0.9 } }) ); expect(out.date.isoDate).toBe("2026-08-16"); }); test("Sanitize parst DACH-Datum statt es zu verwerfen", () => { const out = sanitizeExtractionOutput( receipt({ date: { isoDate: "03.01.2026", time: "10:00", confidence: 0.9 } }) ); expect(out.date.isoDate).toBe("2026-01-03"); }); }); describe("Sprint E — Zahlart", () => { test("Enum und gängige Belegtexte werden gemappt", () => { expect(normalizePaymentMethod("EC_KARTE")).toBe("EC_KARTE"); expect(normalizePaymentMethod("girocard")).toBe("EC_KARTE"); expect(normalizePaymentMethod("EC-Karte")).toBe("EC_KARTE"); expect(normalizePaymentMethod("Visa")).toBe("KREDITKARTE"); expect(normalizePaymentMethod("Bar")).toBe("BAR"); expect(normalizePaymentMethod("Apple Pay")).toBe("APPLE_PAY"); expect(normalizePaymentMethod("PayPal")).toBe("PAYPAL"); expect(normalizePaymentMethod("Überweisung")).toBe("UEBERWEISUNG"); expect(normalizePaymentMethod("")).toBeNull(); expect(normalizePaymentMethod(null)).toBeNull(); }); test("Reconcile übernimmt gemappte Zahlart", () => { const out = reconcileAndEnhanceReceiptData( receipt({ paymentMethod: "girocard" as never }) ); expect(out.paymentMethod).toBe("EC_KARTE"); }); }); describe("Sprint E — Manuelle Steuersätze", () => { test("parseTaxRatePercent akzeptiert 7, 19, 20 und Kommawerte", () => { expect(parseTaxRatePercent("7")).toBe(7); expect(parseTaxRatePercent("19%")).toBe(19); expect(parseTaxRatePercent("20")).toBe(20); expect(parseTaxRatePercent("8,1")).toBe(8.1); expect(parseTaxRatePercent("")).toBeNull(); expect(parseTaxRatePercent("150")).toBe(100); }); test("taxTotalsByRate aggregiert beliebige Sätze", () => { const rows = taxTotalsByRate([ receipt({ taxBreakdown: [ { ratePercent: 20, taxAmount: 20, netAmount: 100 }, { ratePercent: 10, taxAmount: 5, netAmount: 50 }, ], }), ]); expect(rows.map((r) => r.rate)).toEqual([10, 20]); expect(rows[1].amount).toBe(20); }); }); describe("Sprint E — Bounding Boxes", () => { test("ohne OCR-Koordinaten keine Overlay-Boxen", () => { expect(hasExtractedBoundingBoxes(undefined)).toBe(false); expect(hasExtractedBoundingBoxes({})).toBe(false); expect( hasExtractedBoundingBoxes({ merchant: { x: 10, y: 10, width: 40, height: 8 }, }) ).toBe(true); }); });