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:
Timo
2026-08-19 20:59:04 +02:00
parent 650a74da97
commit 84b9987c49
415 changed files with 96619 additions and 0 deletions

View 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");
});
});