Files
scan-receipts/tests/e2e/export_localization.test.ts
Timo 84b9987c49 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>
2026-08-19 20:59:04 +02:00

291 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});
});