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>
242 lines
8.6 KiB
TypeScript
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);
|
|
});
|
|
});
|