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,216 @@
/**
* PDF-Export: Struktur, Lokalisierung und Datenintegrität.
*
* Die Textebene wird mit pdfjs-dist (Legacy-Build, wie im ImageProcessor)
* extrahiert der schnellste Weg, zu prüfen, dass die Zahlen wirklich im
* PDF stehen und die Lokalisierung sauber ist.
*/
import { describe, test, expect } from "./runner";
import { generateReceiptPdf } from "../../src/lib/export/pdfGenerator";
import { ProcessedReceipt } from "../../src/lib/schema/receipt";
function receipt(overrides: Partial<ProcessedReceipt> = {}): ProcessedReceipt {
return {
merchant: { name: "REWE", address: null, taxId: "DE123456789", 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: [
{ description: "Vollmilch 3,5%", quantity: 2, price: 2.38, unitPrice: 1.19, taxRate: 7 },
{ description: "Bio-Baguette", quantity: 1, price: 1.79, unitPrice: 1.79, taxRate: 19 },
],
suggestedCategory: "Material & Einkauf",
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;
}
/** Extrahiert alle Texte eines generierten PDFs (pdfjs-Legacy-Build, Node). */
async function pdfText(bytes: Uint8Array): Promise<{ text: string[]; pages: number }> {
const mod: any = await import("pdfjs-dist/legacy/build/pdf.mjs");
const pdfjs = mod.default ?? mod;
const task = pdfjs.getDocument({
// Kopie: pdfjs übernimmt den Puffer.
data: new Uint8Array(bytes),
isEvalSupported: false,
useSystemFonts: false,
disableFontFace: true,
});
const doc: any = await task.promise;
const text: string[] = [];
try {
for (let i = 1; i <= doc.numPages; i++) {
const page: any = await doc.getPage(i);
const content: any = await page.getTextContent();
for (const item of content.items ?? []) text.push(String(item.str ?? ""));
page.cleanup();
}
} finally {
await task.destroy().catch(() => undefined);
}
return { text, pages: doc.numPages };
}
const joined = (lines: string[]) => lines.join("\n");
describe("PDF-Export: Struktur", () => {
test("P-1: erzeugt eine gültige PDF-Datei", async () => {
const bytes = await generateReceiptPdf([receipt()]);
expect(bytes[0]).toBe(0x25); // '%'
expect(String.fromCharCode(bytes[1])).toBe("P");
expect(String.fromCharCode(bytes[2])).toBe("D");
expect(String.fromCharCode(bytes[3])).toBe("F");
expect(bytes.length).toBeGreaterThan(1000);
});
test("P-2: leerer Datensatz erzeugt eine gültige, lesbare PDF", async () => {
const bytes = await generateReceiptPdf([]);
const { text } = await pdfText(bytes);
expect(joined(text)).toContain("BELEGE EXPORT");
expect(bytes[0]).toBe(0x25);
});
test("P-3: die Kartenzahl wächst mit der Belegzahl (Seitenumbruch)", async () => {
const one = await generateReceiptPdf([receipt()]);
const many = await generateReceiptPdf(
Array.from({ length: 14 }, (_, i) =>
receipt({ id: `r${i}`, totalAmount: { value: 31.8 + i, confidence: 0.98 } })
)
);
const { pages: pOne } = await pdfText(one);
const { pages: pMany } = await pdfText(many);
expect(pMany).toBeGreaterThanOrEqual(pOne);
});
});
describe("PDF-Export: Datenintegrität", () => {
test("P-4: Händler, Beträge und Positionen stehen im Dokument", async () => {
const { text } = await pdfText(await generateReceiptPdf([receipt()]));
const all = joined(text);
expect(all).toContain("REWE");
expect(all).toContain("26,72 €"); // Netto
expect(all).toContain("5,08 €"); // MwSt 19 %
expect(all).toContain("31,80 €"); // Brutto
expect(all).toContain("Vollmilch 3,5%");
expect(all).toContain("1,19 €"); // Einzelpreis
expect(all).toContain("DE123456789"); // Steuernummer
});
test("P-5: Trinkgeld erscheint als eigene Zeile inkl. Gesamt gezahlt", async () => {
const r = receipt({ tipAmount: 5, totalAmount: { value: 31.8, confidence: 0.98 } });
const { text } = await pdfText(await generateReceiptPdf([r]));
const all = joined(text);
expect(all).toContain("Trinkgeld");
expect(all).toContain("5,00 €");
expect(all).toContain("Gesamt gezahlt");
expect(all).toContain("36,80 €");
});
test("P-6: ungeprüfter Beleg erhält Status Prüfen (n)", async () => {
const r = receipt({
validation: {
isMathValid: false,
isDuplicateSuspected: false,
needsUserReview: true,
reviewField: "totalAmount",
reviewReason: "x",
issues: [{ field: "totalAmount", severity: "error", message: "x" }],
},
});
const { text } = await pdfText(await generateReceiptPdf([r]));
expect(joined(text)).toContain("Prüfen (1)");
});
test("P-7: Bewirtungsangaben werden übernommen", async () => {
const r = receipt({
documentType: "BEWIRTUNGSBELEG",
suggestedCategory: "Bewirtung",
hospitality: { occasion: "Kundenbesuch", participants: "Max Mustermann" },
});
const { text } = await pdfText(await generateReceiptPdf([r]));
const all = joined(text);
expect(all).toContain("Kundenbesuch");
expect(all).toContain("Max Mustermann");
});
test("P-8: gemischte Währungen werden nicht zu einer Summe vermischt", async () => {
const eur = receipt({ id: "a", currency: "EUR", totalAmount: { value: 10, confidence: 0.98 } });
const usd = receipt({ id: "b", currency: "USD", totalAmount: { value: 5, confidence: 0.98 } });
const { text } = await pdfText(await generateReceiptPdf([eur, usd]));
const all = joined(text);
expect(all).toContain("mehrere Währungen");
expect(all).toContain("EUR: 10,00 €");
expect(all).toContain("USD: 5,00 USD");
});
test("P-9: unicode-gefährliche Zeichen werden entschärft statt zu brechen", async () => {
const r = receipt({
merchant: { name: "CAFÉ ✓ BERLIN ☕", address: null, taxId: null, confidence: 0.9 },
});
const bytes = await generateReceiptPdf([r]);
const { text } = await pdfText(bytes);
expect(joined(text)).toContain("CAFÉ");
expect(joined(text)).toContain("BERLIN");
});
});
describe("PDF-Export: Lokalisierung", () => {
test("P-10: Standard bleibt Deutsch", async () => {
const { text } = await pdfText(await generateReceiptPdf([receipt()]));
expect(joined(text)).toContain("BELEGE EXPORT");
expect(joined(text)).toContain("Netto");
expect(joined(text)).toContain("Brutto Gesamt");
});
test("P-11: locale 'en' übersetzt Titel und Beschriftungen", async () => {
const { text } = await pdfText(await generateReceiptPdf([receipt()], { locale: "en" }));
const all = joined(text);
expect(all).toContain("RECEIPT EXPORT");
expect(all).toContain("Net");
expect(all).toContain("Gross total");
expect(all).toContain("VAT 19%");
});
test("P-12: im englischen Export bleibt kein deutsches Label stehen", async () => {
const { text } = await pdfText(await generateReceiptPdf([receipt()], { locale: "en" }));
const german = ["BELEGE EXPORT", "Netto", "Brutto Gesamt", "MwSt gesamt", "Trinkgeld", "Prüfen"];
const all = joined(text);
for (const g of german) expect(all).not.toContain(g);
});
test("P-13: unbekannte Sprache fällt auf Deutsch zurück", async () => {
const bytes = await generateReceiptPdf(
[receipt()],
{ locale: "fr" as unknown as "de" }
);
const { text } = await pdfText(bytes);
expect(joined(text)).toContain("BELEGE EXPORT");
});
test("P-14: Zeitraum erscheint im Untertitel", async () => {
const { text } = await pdfText(
await generateReceiptPdf([receipt()], { dateFrom: "2026-01-01", dateTo: "2026-12-31" })
);
expect(joined(text)).toContain("Zeitraum");
expect(joined(text)).toContain("01.01.2026");
});
});