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