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,290 @@
/**
* Export-Lokalisierung und Trinkgeld in den KPI-Kacheln.
*
* Der deutsche Export ist der Bestand — Blattnamen und Kopfzeilen dürfen sich
* nicht verändern, nur weil eine englische Variante dazugekommen ist.
*/
import ExcelJS from "exceljs";
import { describe, test, expect } from "./runner";
import { generateDualSheetExcel } from "../../src/lib/export/excelGenerator";
import { generateAccountingCsv } from "../../src/lib/export/csvGenerator";
import { calculateDashboardKPIs } from "../../src/components/dashboard/KPICards";
import { ProcessedReceipt } from "../../src/lib/schema/receipt";
function receipt(overrides: Partial<ProcessedReceipt> = {}): ProcessedReceipt {
return {
merchant: { name: "REWE", address: null, taxId: null, confidence: 0.97 },
date: { isoDate: "2026-08-12", time: null, confidence: 0.95 },
documentType: "KASSENBON",
receiptNumber: "1",
currency: "EUR",
totalAmount: { value: 31.8, confidence: 0.98 },
netAmount: 26.72,
tipAmount: null,
taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }],
lineItems: [],
suggestedCategory: "Sonstiges",
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: false,
reviewField: "none",
reviewReason: null,
},
id: "r1",
imageHash: "h1",
originalFileName: "r1.jpg",
fileSizeBytes: 1024,
createdAt: "2026-08-12T10:00:00.000Z",
updatedAt: "2026-08-12T10:00:00.000Z",
status: "ready",
...overrides,
} as ProcessedReceipt;
}
async function headersOf(receipts: ProcessedReceipt[], locale?: "de" | "en") {
const buf = await generateDualSheetExcel(receipts, locale ? { locale } : {});
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(buf as unknown as ArrayBuffer);
const out: string[] = [];
wb.worksheets[0].getRow(1).eachCell((c) => out.push(String(c.value ?? "")));
return { wb, headers: out };
}
describe("Export-Lokalisierung: Excel", () => {
test("L-1: Standard ohne Option bleibt Deutsch", async () => {
const { wb, headers } = await headersOf([receipt()]);
expect(wb.worksheets[0].name).toBe("Belegübersicht");
expect(wb.worksheets[1].name).toBe("Einzelpositionen Detail");
expect(headers[2]).toBe("Händler / Aussteller");
});
test("L-2: locale 'en' übersetzt Blattnamen und Kopfzeile", async () => {
const { wb, headers } = await headersOf([receipt()], "en");
expect(wb.worksheets[0].name).toBe("Receipts");
expect(wb.worksheets[1].name).toBe("Line items");
expect(headers[2]).toBe("Merchant / Issuer");
expect(headers.some((h) => h.startsWith("Gross total"))).toBe(true);
});
test("L-3: im englischen Export bleibt kein deutsches Label stehen", async () => {
const { headers } = await headersOf([receipt({ tipAmount: 5 })], "en");
const german = ["Netto", "Brutto", "Währung", "MwSt", "Händler", "Trinkgeld", "Plausibilität"];
const leftovers = headers.filter((h) => german.some((g) => h.includes(g)));
expect(leftovers).toEqual([]);
});
test("L-4: Datumsformat ist sprachabhängig, Geldformat nicht", async () => {
const de = await generateDualSheetExcel([receipt()], { locale: "de" });
const en = await generateDualSheetExcel([receipt()], { locale: "en" });
const read = async (buf: Buffer) => {
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(buf as unknown as ArrayBuffer);
const row = wb.worksheets[0].getRow(2);
return { date: row.getCell(2).numFmt, money: row.getCell(7).numFmt };
};
const a = await read(de);
const b = await read(en);
expect(a.date).toBe("DD.MM.YYYY");
expect(b.date).toBe("YYYY-MM-DD");
expect(a.money).toBe(b.money);
});
test("L-5: unbekannte Sprache fällt auf Deutsch zurück", async () => {
const { wb } = await headersOf([receipt()], "fr" as unknown as "de");
expect(wb.worksheets[0].name).toBe("Belegübersicht");
});
test("L-6: negative Beträge bekommen ein Rot-Format", async () => {
const { wb } = await headersOf([receipt()]);
expect(wb.worksheets[0].getRow(2).getCell(7).numFmt.includes("[Red]")).toBe(true);
});
test("L-7: Statusspalte ist farblich hinterlegt", async () => {
const buf = await generateDualSheetExcel(
[
receipt(),
receipt({
id: "r2",
validation: {
isMathValid: false,
isDuplicateSuspected: false,
needsUserReview: true,
reviewField: "totalAmount",
reviewReason: "x",
issues: [{ field: "totalAmount", severity: "error", message: "x" }],
},
}),
],
{ locale: "de" }
);
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(buf as unknown as ArrayBuffer);
const sheet = wb.worksheets[0];
const headers: string[] = [];
sheet.getRow(1).eachCell((c) => headers.push(String(c.value ?? "")));
const col = headers.indexOf("Plausibilität") + 1;
const okFill = sheet.getRow(2).getCell(col).fill as { fgColor?: { argb?: string } };
const warnFill = sheet.getRow(3).getCell(col).fill as { fgColor?: { argb?: string } };
expect(okFill.fgColor?.argb).toBe("FFDCFCE7");
expect(warnFill.fgColor?.argb).toBe("FFFEF3C7");
});
test("L-8: Kopfzeile und erste Spalten sind fixiert", async () => {
const { wb } = await headersOf([receipt()]);
const view = wb.worksheets[0].views[0] as { xSplit?: number; ySplit?: number };
expect(view.ySplit).toBe(1);
expect(view.xSplit).toBe(3);
});
});
describe("Export-Lokalisierung: CSV", () => {
test("L-9: Standard bleibt Deutsch", () => {
const head = generateAccountingCsv([receipt()]).replace(/^/, "").split("\r\n")[0];
expect(head.includes("Händler / Aussteller")).toBe(true);
expect(head.includes("Umsatz Brutto")).toBe(true);
});
test("L-10: locale 'en' übersetzt die Kopfzeile", () => {
const head = generateAccountingCsv([receipt({ tipAmount: 5 })], { locale: "en" })
.replace(/^/, "")
.split("\r\n")[0];
expect(head.includes("Merchant / Issuer")).toBe(true);
expect(head.includes("Total paid")).toBe(true);
// Steuersatz englisch ohne Leerzeichen, wie in der Excel-Mappe.
expect(head.includes("VAT 19%")).toBe(true);
expect(head.includes("MwSt")).toBe(false);
});
test("L-11: Statuswerte sind übersetzt", () => {
const rows = generateAccountingCsv([receipt()], { locale: "en" }).split("\r\n");
expect(rows[1].includes("Valid")).toBe(true);
expect(rows[1].includes("Valide")).toBe(false);
});
});
describe("Kleinbetragsrechnung (§ 33 UStDV)", () => {
async function cellFor(r: ProcessedReceipt, locale: "de" | "en" = "de") {
const buf = await generateDualSheetExcel([r], { locale });
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(buf as unknown as ArrayBuffer);
const sheet = wb.worksheets[0];
const headers: string[] = [];
sheet.getRow(1).eachCell((c) => headers.push(String(c.value ?? "")));
const col = headers.findIndex((h) => h.includes("§ 33")) + 1;
return { value: String(sheet.getRow(2).getCell(col).value ?? ""), col, sheet };
}
const gross = (value: number, currency = "EUR") =>
receipt({ totalAmount: { value, confidence: 0.98 }, currency, netAmount: null, taxBreakdown: [] });
test("K-1: 250,00 € ist noch Kleinbetrag (Grenze inklusive)", async () => {
expect((await cellFor(gross(250))).value).toBe("Ja");
});
test("K-2: 250,01 € ist keiner mehr", async () => {
expect((await cellFor(gross(250.01))).value).toBe("Nein");
});
test("K-3: typischer Kassenbon ist Kleinbetrag", async () => {
expect((await cellFor(gross(9.06))).value).toBe("Ja");
});
test("K-4: Trinkgeld zählt nicht in die Grenze", async () => {
// Rechnungsbetrag 248 € + 10 € Tip = 258 € gezahlt, aber die Rechnung
// selbst bleibt eine Kleinbetragsrechnung.
const r = receipt({
totalAmount: { value: 248, confidence: 0.98 },
tipAmount: 10,
netAmount: null,
taxBreakdown: [],
});
expect((await cellFor(r)).value).toBe("Ja");
});
test("K-5: Gutschrift wird über den Betrag ohne Vorzeichen bewertet", async () => {
expect((await cellFor(gross(-300))).value).toBe("Nein");
expect((await cellFor(gross(-12.5))).value).toBe("Ja");
});
test("K-6: Fremdwährung bleibt leer statt geraten", async () => {
expect((await cellFor(gross(100, "CHF"))).value).toBe("");
expect((await cellFor(gross(100, "USD"))).value).toBe("");
});
test("K-7: englischer Export nutzt Yes/No", async () => {
expect((await cellFor(gross(9.06), "en")).value).toBe("Yes");
expect((await cellFor(gross(999), "en")).value).toBe("No");
});
test("K-8: Rechtsgrundlage hängt als Kommentar an der Überschrift", async () => {
const { sheet, col } = await cellFor(gross(9.06));
const note = sheet.getRow(1).getCell(col).note;
const noteText = typeof note === "string" ? note : (note?.texts ?? []).map((t) => t.text).join("");
expect(noteText.includes("§ 33 UStDV")).toBe(true);
expect(noteText.includes("250")).toBe(true);
});
test("K-9: CSV führt dieselbe Spalte", () => {
const csv = generateAccountingCsv([gross(9.06), gross(999), gross(50, "CHF")]);
const rows = csv.replace(/^/, "").split("\r\n");
const idx = rows[0].split(";").findIndex((h) => h.includes("§ 33"));
expect(idx).toBeGreaterThan(-1);
const valueAt = (row: string) => row.split(";")[idx].replace(/"/g, "");
expect(valueAt(rows[1])).toBe("Ja");
expect(valueAt(rows[2])).toBe("Nein");
expect(valueAt(rows[3])).toBe("");
});
test("K-10: die Spalte verschiebt Steuernummer und Status nicht durcheinander", async () => {
const { sheet, col } = await cellFor(gross(9.06));
const headers: string[] = [];
sheet.getRow(1).eachCell((c) => headers.push(String(c.value ?? "")));
expect(headers[col]).toBe("Steuernummer / USt-IdNr.");
expect(headers[col + 1]).toBe("Plausibilität");
});
});
describe("KPI-Kacheln: Trinkgeld", () => {
const withTips = [
receipt({ id: "a", tipAmount: 5, totalAmount: { value: 31.8, confidence: 0.98 } }),
receipt({ id: "b", tipAmount: 2.5, totalAmount: { value: 20, confidence: 0.98 } }),
receipt({ id: "c", tipAmount: null, totalAmount: { value: 10, confidence: 0.98 } }),
];
test("L-12: totalGross bleibt ohne Trinkgeld (MwSt-tragend)", () => {
const k = calculateDashboardKPIs(withTips);
expect(Number(k.totalGross.toFixed(2))).toBe(61.8);
});
test("L-13: totalTips summiert alle Trinkgelder", () => {
expect(Number(calculateDashboardKPIs(withTips).totalTips.toFixed(2))).toBe(7.5);
});
test("L-14: totalPaid ist Brutto plus Trinkgeld", () => {
expect(Number(calculateDashboardKPIs(withTips).totalPaid.toFixed(2))).toBe(69.3);
});
test("L-15: Monatsausgaben enthalten das Trinkgeld", () => {
const k = calculateDashboardKPIs(withTips);
expect(Number(k.monthlySpendPaid.toFixed(2))).toBe(
Number((k.monthlySpend + k.monthlySpendTips).toFixed(2))
);
expect(k.monthlySpendPaid).toBeGreaterThan(k.monthlySpend);
});
test("L-16: ohne Trinkgeld bleiben die Kennzahlen unverändert", () => {
const k = calculateDashboardKPIs([receipt({ tipAmount: null })]);
expect(k.totalTips).toBe(0);
expect(k.totalPaid).toBe(k.totalGross);
expect(k.monthlySpendPaid).toBe(k.monthlySpend);
});
test("L-17: leerer Datensatz erzeugt keine NaN", () => {
const k = calculateDashboardKPIs([]);
expect(k.totalTips).toBe(0);
expect(k.totalPaid).toBe(0);
expect(k.monthlySpendPaid).toBe(0);
});
});