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>
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
/**
|
||
* 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");
|
||
});
|
||
});
|