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>
This commit is contained in:
316
tests/e2e/extraction_quality.test.ts
Normal file
316
tests/e2e/extraction_quality.test.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user