Files
scan-receipts/tests/e2e/sprint_e.test.ts
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

242 lines
8.6 KiB
TypeScript

/**
* 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> = {}): 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);
});
});