Files
scan-receipts/tests/e2e/extraction_quality.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

317 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Extraktions-Qualität: Regressionen aus dem Wechsel auf openai/gpt-5.6-luna.
*
* Deckt drei Befunde ab, die live an den Demo-Belegen reproduziert wurden:
* 1. Platzhalter-Händlernamen ("Nicht lesbar") kamen mit hoher Confidence durch
* und landeten ungeprüft als Händlername in der Excel.
* 2. Trinkgeld auf Bewirtungsbelegen darf nicht gegen den Bruttobetrag gerechnet
* werden — sonst meldet Check 5 bei jedem Beleg mit Tip eine Abweichung.
* 3. Das Modell-Schema darf `validation` nicht enthalten (wird lokal berechnet)
* und muss für OpenAI-strict jedes Feld in `required` führen.
*/
import { describe, test, expect } from "./runner";
import {
validateReceiptMath,
isPlaceholderMerchantName,
isTipLineItem,
CONFIDENCE_THRESHOLDS,
} from "../../src/lib/ai/mathValidator";
import ExcelJS from "exceljs";
import {
ProcessedReceipt,
ReceiptData,
ReceiptExtractionModelSchema,
PENDING_VALIDATION,
grossWithTip,
} from "../../src/lib/schema/receipt";
import { generateDualSheetExcel } from "../../src/lib/export/excelGenerator";
import { generateAccountingCsv } from "../../src/lib/export/csvGenerator";
/** Extraktionsergebnis zu einem gespeicherten Beleg aufwerten. */
function stored(data: ReceiptData): ProcessedReceipt {
return {
...data,
id: "rcpt_test",
imageHash: "hash_test",
originalFileName: "test.jpg",
fileSizeBytes: 1024,
createdAt: "2026-08-12T10:00:00.000Z",
updatedAt: "2026-08-12T10:00:00.000Z",
status: "ready",
};
}
function receipt(overrides: Partial<ReceiptData> = {}): ReceiptData {
return {
merchant: { name: "REWE", address: null, taxId: null, confidence: 0.97 },
date: { isoDate: "2026-08-12", time: null, confidence: 0.95 },
documentType: "KASSENBON",
receiptNumber: null,
currency: "EUR",
totalAmount: { value: 11.9, confidence: 0.98 },
netAmount: 10.0,
taxBreakdown: [{ ratePercent: 19, taxAmount: 1.9, netAmount: 10.0 }],
lineItems: [],
suggestedCategory: "Sonstiges",
validation: { ...PENDING_VALIDATION },
...overrides,
} as ReceiptData;
}
describe("Extraktion: Platzhalter-Händlernamen", () => {
const placeholders = [
"Nicht lesbar",
"nicht lesbar",
"Taxiunternehmen (Name unleserlich)",
"Unbekannter Händler",
"Unknown",
"n/a",
"N/A",
"—",
"-",
" ",
"",
];
for (const name of placeholders) {
test(`EQ-1: "${name || "(leer)"}" gilt als Platzhalter`, () => {
expect(isPlaceholderMerchantName(name)).toBe(true);
});
}
const realNames = [
"REWE",
"Aral Tankstelle Station",
"BISTRO AM MARKT",
"Apotheke am Stadtpark",
"Trattoria Bella Vista",
"MediaMarkt",
"Deutsche Bahn AG",
];
for (const name of realNames) {
test(`EQ-2: "${name}" gilt NICHT als Platzhalter`, () => {
expect(isPlaceholderMerchantName(name)).toBe(false);
});
}
test("EQ-3: Platzhalter wird trotz hoher Confidence zur Prüfung markiert", () => {
const r = validateReceiptMath(
receipt({
merchant: { name: "Nicht lesbar", address: null, taxId: null, confidence: 0.99 },
})
);
expect(r.needsUserReview).toBe(true);
expect(r.reviewField).toBe("merchant");
});
test("EQ-4: Echter Händlername mit hoher Confidence bleibt ungeflaggt", () => {
const r = validateReceiptMath(receipt());
expect(r.needsUserReview).toBe(false);
});
test("EQ-5: Niedrige Confidence flaggt weiterhin unabhängig vom Namen", () => {
const r = validateReceiptMath(
receipt({
merchant: {
name: "REWE",
address: null,
taxId: null,
confidence: CONFIDENCE_THRESHOLDS.merchant - 0.01,
},
})
);
expect(r.needsUserReview).toBe(true);
expect(r.reviewField).toBe("merchant");
});
});
describe("Extraktion: Trinkgeld auf Bewirtungsbelegen", () => {
test("EQ-6: Trinkgeld-Positionen werden erkannt", () => {
expect(isTipLineItem("Trinkgeld")).toBe(true);
expect(isTipLineItem("trinkgeld")).toBe(true);
expect(isTipLineItem("Tip")).toBe(true);
expect(isTipLineItem("Gratuity")).toBe(true);
});
test("EQ-7: Normale Positionen sind kein Trinkgeld", () => {
expect(isTipLineItem("Pizza Margherita")).toBe(false);
expect(isTipLineItem("San Pellegrino 0.75l")).toBe(false);
expect(isTipLineItem("Tiramisu")).toBe(false);
expect(isTipLineItem(null)).toBe(false);
});
test("EQ-8: Trattoria-Fall — Tip zählt nicht gegen den Bruttobetrag", () => {
// Realer Beleg: Total 31,80 (Netto 26,72 + 19% 5,08), handschriftlich
// Trinkgeld 5,00 und Gesamtbetrag 36,80.
const r = validateReceiptMath(
receipt({
merchant: { name: "Trattoria Bella Vista", address: null, taxId: null, confidence: 0.98 },
documentType: "BEWIRTUNGSBELEG",
totalAmount: { value: 31.8, confidence: 0.98 },
netAmount: 26.72,
taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }],
lineItems: [
{ description: "Pizza Margherita", quantity: 2, price: 25.0, unitPrice: 12.5, taxRate: 19 },
{ description: "San Pellegrino 0.75l", quantity: 1, price: 6.8, unitPrice: null, taxRate: 19 },
{ description: "Trinkgeld", quantity: 1, price: 5.0, unitPrice: null, taxRate: 0 },
],
})
);
expect(r.isMathValid).toBe(true);
expect(r.needsUserReview).toBe(false);
expect(r.issues.some((i) => i.field === "lineItems")).toBe(false);
});
test("EQ-9: Ohne Tip-Ausnahme bliebe eine echte Artikel-Abweichung erkennbar", () => {
const r = validateReceiptMath(
receipt({
totalAmount: { value: 31.8, confidence: 0.98 },
netAmount: 26.72,
taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }],
lineItems: [
{ description: "Pizza Margherita", quantity: 2, price: 25.0, unitPrice: 12.5, taxRate: 19 },
{ description: "Dessert", quantity: 1, price: 11.8, unitPrice: null, taxRate: 19 },
],
})
);
expect(r.issues.some((i) => i.field === "lineItems")).toBe(true);
});
});
describe("Trinkgeld: tipAmount-Feld", () => {
const trattoria = () =>
receipt({
merchant: { name: "Trattoria Bella Vista", address: null, taxId: null, confidence: 0.98 },
documentType: "BEWIRTUNGSBELEG",
totalAmount: { value: 31.8, confidence: 0.98 },
netAmount: 26.72,
tipAmount: 5.0,
taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }],
lineItems: [
{ description: "Pizza Margherita", quantity: 2, price: 25.0, unitPrice: 12.5, taxRate: 19 },
{ description: "San Pellegrino 0.75l", quantity: 1, price: 6.8, unitPrice: null, taxRate: 19 },
],
});
test("EQ-15: grossWithTip addiert das Trinkgeld auf den Rechnungsbetrag", () => {
expect(grossWithTip(trattoria())).toBe(36.8);
});
test("EQ-16: ohne Trinkgeld bleibt grossWithTip der Bruttobetrag", () => {
expect(grossWithTip(receipt())).toBe(11.9);
expect(grossWithTip(receipt({ tipAmount: null }))).toBe(11.9);
});
test("EQ-17: Trinkgeld verfälscht die Netto/MwSt-Gegenprobe nicht", () => {
const r = validateReceiptMath(trattoria());
expect(r.isMathValid).toBe(true);
expect(r.needsUserReview).toBe(false);
});
test("EQ-18: negatives Trinkgeld ist ein Fehler", () => {
const r = validateReceiptMath(receipt({ tipAmount: -2 }));
expect(r.isMathValid).toBe(false);
expect(r.needsUserReview).toBe(true);
});
test("EQ-19: Trinkgeld über dem Rechnungsbetrag wird zur Prüfung markiert", () => {
// Typischer Lesefehler: handschriftlicher Gesamtbetrag als Tip erfasst.
const r = validateReceiptMath(
receipt({ totalAmount: { value: 31.8, confidence: 0.98 }, netAmount: 26.72, tipAmount: 36.8, taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }] })
);
expect(r.needsUserReview).toBe(true);
});
test("EQ-20: Excel führt Trinkgeld- und Gesamt-gezahlt-Spalte nur bei Bedarf", async () => {
const withTip = await generateDualSheetExcel([stored(trattoria())]);
const withoutTip = await generateDualSheetExcel([stored(receipt())]);
const headerOf = async (buf: Buffer) => {
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(buf as unknown as ArrayBuffer);
const row = wb.worksheets[0].getRow(1);
const out: string[] = [];
row.eachCell((c) => out.push(String(c.value ?? "")));
return out;
};
const h1 = await headerOf(withTip);
expect(h1.some((h) => h.startsWith("Trinkgeld"))).toBe(true);
expect(h1.some((h) => h.startsWith("Gesamt gezahlt"))).toBe(true);
const h2 = await headerOf(withoutTip);
expect(h2.some((h) => h.startsWith("Trinkgeld"))).toBe(false);
});
test("EQ-21: CSV enthält Trinkgeld und Gesamt gezahlt", () => {
const csv = generateAccountingCsv([stored(trattoria())]);
const [header, row] = csv.replace(/^/, "").split("\r\n");
expect(header.includes("Trinkgeld")).toBe(true);
expect(header.includes("Gesamt gezahlt")).toBe(true);
// Rechnungsbetrag bleibt 31,80, gezahlt wurden 36,80.
expect(row.includes('"31,80"')).toBe(true);
expect(row.includes('"5,00"')).toBe(true);
expect(row.includes('"36,80"')).toBe(true);
});
test("EQ-22: CSV ohne Trinkgeld führt die Spalten nicht", () => {
const csv = generateAccountingCsv([stored(receipt())]);
expect(csv.split("\r\n")[0].includes("Trinkgeld")).toBe(false);
});
});
describe("Extraktion: Modell-Schema für OpenAI strict mode", () => {
test("EQ-10: validation ist nicht Teil des Modell-Schemas", () => {
const keys = Object.keys(ReceiptExtractionModelSchema.shape);
expect(keys.includes("validation")).toBe(false);
expect(keys.includes("merchant")).toBe(true);
expect(keys.includes("totalAmount")).toBe(true);
expect(keys.includes("taxBreakdown")).toBe(true);
});
test("EQ-11: kein Feld ist optional (strict verlangt required für jeden Key)", () => {
// .optional() erzeugt eine Lücke in `required` -> HTTP 400 invalid_json_schema.
const optional = Object.entries(ReceiptExtractionModelSchema.shape)
.filter(([, v]) => (v as { isOptional?: () => boolean }).isOptional?.())
.map(([k]) => k);
expect(optional.length).toBe(0);
});
test("EQ-12: Modell-Ausgabe ohne validation ist gültig", () => {
const sample = {
merchant: { name: "REWE", address: null, taxId: null, confidence: 0.97 },
date: { isoDate: "2026-08-12", time: "14:32", confidence: 0.95 },
documentType: "KASSENBON",
receiptNumber: "2026-004871",
currency: "EUR",
totalAmount: { value: 9.06, confidence: 0.98 },
netAmount: 8.18,
taxBreakdown: [
{ ratePercent: 7, taxAmount: 0.4, netAmount: 5.67 },
{ ratePercent: 19, taxAmount: 0.48, netAmount: 2.51 },
],
lineItems: [
{ description: "Vollmilch", quantity: 2, price: 2.58, unitPrice: 1.29, taxRate: 7 },
],
suggestedCategory: "Verpflegungsmehraufwand",
hospitality: null,
tipAmount: null,
paymentMethod: null,
};
expect(ReceiptExtractionModelSchema.safeParse(sample).success).toBe(true);
});
test("EQ-14: tipAmount ist gegenüber dem Modell Pflicht (nullable, nicht optional)", () => {
// Fehlt das Feld, wäre es nicht in `required` -> HTTP 400 im strict mode.
const shape = ReceiptExtractionModelSchema.shape as Record<string, { isOptional?: () => boolean }>;
expect("tipAmount" in shape).toBe(true);
expect(shape.tipAmount.isOptional?.() ?? false).toBe(false);
});
test("EQ-13: PENDING_VALIDATION ist neutral (flaggt nichts vor)", () => {
expect(PENDING_VALIDATION.needsUserReview).toBe(false);
expect(PENDING_VALIDATION.isMathValid).toBe(true);
expect(PENDING_VALIDATION.reviewField).toBe("none");
});
});