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,524 @@
/**
* Milestone 2 (R2): Side-by-Side Receipt Inspector & Split Review Modal — Adversarial Stress Suite
* Empirical Challenger Verification
*
* Stress-tests:
* 1. Modal lifecycle: mount/unmount, null/undefined receipt resilience, dynamic receipt swapping
* 2. Rapid navigation: indexing boundary enforcement, cyclic navigation, rapid back-and-forth
* 3. Keyboard shortcuts: Alt+Left/Right receipt navigation and Escape modal dismiss
* 4. Audit trail & Field reverts: Multi-field dirty tracking, 1-click revert across all field types
* 5. Corrupted & partial data resilience: missing fields, null nested objects, negative amounts, 0 line items
* 6. Bounding box edge cases: extreme coordinates, NaN, negative, boundary hit-testing, empty layout generation
* 7. LineItemsEditor stress: high-precision decimals, 0 quantity, fractional quantities, deletion to empty array
* 8. Responsive mobile tab switching: state synchronization and view toggle
*/
import { describe, test, it, expect, beforeEach } from "./runner";
import {
ProcessedReceipt,
ReceiptData,
PaymentMethodSchema,
BoundingBoxRectSchema,
ReceiptBoundingBoxesSchema,
ReceiptExtractionSchema,
} from "../../src/lib/schema/receipt";
import {
clampPercent,
createBoundingBoxRect,
pixelRectToPercent,
isPointInsideBox,
generateDefaultBoundingBoxes,
getFieldBoundingBox,
getFieldLabel,
findFieldAtCoordinates,
} from "../../src/lib/utils/boundingBoxes";
import { recalculateReceipt, confirmReceiptReviewed, receiptNeedsAttention } from "../../src/lib/ai/recalculate";
function createAdversarialReceipt(overrides: Partial<ProcessedReceipt> = {}): ProcessedReceipt {
const base: ProcessedReceipt = {
id: "adv-rcpt-001",
imageHash: "adv-hash-999",
originalFileName: "stress_test_receipt.png",
fileSizeBytes: 180000,
previewUrl: "blob:http://localhost/adv-receipt.png",
createdAt: "2026-08-15T12:00:00.000Z",
updatedAt: "2026-08-15T12:00:00.000Z",
status: "ready",
documentType: "KASSENBON",
receiptNumber: "ADV-2026-001",
currency: "EUR",
merchant: {
name: "Adversarial Hardware GmbH",
address: "Musterstraße 42, 80331 München",
taxId: "DE987654321",
confidence: 0.95,
},
date: {
isoDate: "2026-08-15",
time: "15:45",
confidence: 0.99,
},
totalAmount: {
value: 119.00,
confidence: 0.98,
},
netAmount: 100.00,
taxBreakdown: [
{ ratePercent: 19, taxAmount: 19.00, netAmount: 100.00 },
],
lineItems: [
{
description: "Cat6 Ethernet Kabel 10m",
quantity: 2,
unitPrice: 25.00,
price: 50.00,
taxRate: 19,
},
{
description: "Gigabit Switch 8-Port",
quantity: 1,
unitPrice: 69.00,
price: 69.00,
taxRate: 19,
},
],
suggestedCategory: "Bürobedarf & IT",
paymentMethod: "EC_KARTE",
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: false,
reviewField: "none",
reviewReason: null,
issues: [],
userConfirmed: false,
},
originalExtraction: {
merchant: {
name: "Adversarial Hardware GmbH",
address: "Musterstraße 42, 80331 München",
taxId: "DE987654321",
confidence: 0.95,
},
date: {
isoDate: "2026-08-15",
time: "15:45",
confidence: 0.99,
},
totalAmount: {
value: 119.00,
confidence: 0.98,
},
netAmount: 100.00,
taxBreakdown: [
{ ratePercent: 19, taxAmount: 19.00, netAmount: 100.00 },
],
lineItems: [
{
description: "Cat6 Ethernet Kabel 10m",
quantity: 2,
unitPrice: 25.00,
price: 50.00,
taxRate: 19,
},
{
description: "Gigabit Switch 8-Port",
quantity: 1,
unitPrice: 69.00,
price: 69.00,
taxRate: 19,
},
],
suggestedCategory: "Bürobedarf & IT",
documentType: "KASSENBON",
receiptNumber: "ADV-2026-001",
},
};
return { ...base, ...overrides };
}
describe("Milestone 2: Adversarial Lifecycle, Navigation & Keyboard Event Stress", () => {
test("ADV-M2.1: Rapid receipt switching updates active form data and clears dirty state cleanly", () => {
const receiptA = createAdversarialReceipt({ id: "rcpt-A", merchant: { name: "Store A", address: null, taxId: null, confidence: 1.0 } });
const receiptB = createAdversarialReceipt({ id: "rcpt-B", merchant: { name: "Store B", address: null, taxId: null, confidence: 1.0 } });
// Simulate modal state manager
let currentReceipt: ProcessedReceipt | null = receiptA;
let editedFields = new Set<string>();
// User edits merchant in receipt A
editedFields.add("merchant");
expect(editedFields.has("merchant")).toBe(true);
// Rapid navigation to receipt B -> should sync to receipt B and reset edited fields
currentReceipt = receiptB;
editedFields = new Set<string>();
expect(currentReceipt.id).toBe("rcpt-B");
expect(currentReceipt.merchant.name).toBe("Store B");
expect(editedFields.size).toBe(0);
});
test("ADV-M2.2: Keyboard shortcut dispatcher processes Alt+ArrowLeft, Alt+ArrowRight and Escape correctly", () => {
let navigatedDirection: "prev" | "next" | null = null;
let closed = false;
const handleKeyDown = (event: { key: string; altKey?: boolean; preventDefault: () => void }) => {
if (event.key === "Escape") {
closed = true;
} else if (event.altKey && event.key === "ArrowLeft") {
event.preventDefault();
navigatedDirection = "prev";
} else if (event.altKey && event.key === "ArrowRight") {
event.preventDefault();
navigatedDirection = "next";
}
};
let prevented = false;
const fakePreventDefault = () => { prevented = true; };
// Test Alt + Left
prevented = false;
handleKeyDown({ key: "ArrowLeft", altKey: true, preventDefault: fakePreventDefault });
expect(navigatedDirection).toBe("prev");
expect(prevented).toBe(true);
// Test Alt + Right
prevented = false;
handleKeyDown({ key: "ArrowRight", altKey: true, preventDefault: fakePreventDefault });
expect(navigatedDirection).toBe("next");
expect(prevented).toBe(true);
// Test Escape (Dismiss Modal)
handleKeyDown({ key: "Escape", preventDefault: fakePreventDefault });
expect(closed).toBe(true);
// Test Unrelated key (no action)
navigatedDirection = null;
prevented = false;
handleKeyDown({ key: "ArrowLeft", altKey: false, preventDefault: fakePreventDefault });
expect(navigatedDirection).toBeNull();
expect(prevented).toBe(false);
});
test("ADV-M2.3: Boundary navigation clamping prevents out-of-bounds index overflow or underflow", () => {
const totalReceipts = 3;
let currentIndex = 0;
const navigate = (direction: "prev" | "next") => {
if (direction === "prev") {
currentIndex = Math.max(0, currentIndex - 1);
} else {
currentIndex = Math.min(totalReceipts - 1, currentIndex + 1);
}
};
// Attempt to navigate backwards at lower bound (index 0)
navigate("prev");
expect(currentIndex).toBe(0);
navigate("prev");
expect(currentIndex).toBe(0);
// Navigate to upper bound
navigate("next");
expect(currentIndex).toBe(1);
navigate("next");
expect(currentIndex).toBe(2);
// Attempt to navigate forward past upper bound
navigate("next");
expect(currentIndex).toBe(2);
navigate("next");
expect(currentIndex).toBe(2);
});
test("ADV-M2.4: Debounced auto-save timer handles rapid unmount without memory leaks or race conditions", async () => {
let savedReceipt: ProcessedReceipt | null = null;
let saveCount = 0;
let timer: any = null;
const triggerEdit = (newReceipt: ProcessedReceipt) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
savedReceipt = newReceipt;
saveCount++;
}, 50);
};
// Trigger 5 rapid edits within 20ms
const r1 = createAdversarialReceipt({ id: "edit-1" });
const r2 = createAdversarialReceipt({ id: "edit-2" });
const r3 = createAdversarialReceipt({ id: "edit-3" });
triggerEdit(r1);
triggerEdit(r2);
triggerEdit(r3);
// Wait 100ms for debounce timer to settle
await new Promise((res) => setTimeout(res, 100));
// Only the final edit should have been committed
expect(saveCount).toBe(1);
expect((savedReceipt as ProcessedReceipt | null)?.id).toBe("edit-3");
});
});
describe("Milestone 2: Adversarial Field Audit Trail & Revert Capabilities", () => {
let receipt: ProcessedReceipt;
beforeEach(() => {
receipt = createAdversarialReceipt();
});
test("ADV-M2.5: Comprehensive 1-Click Revert restores each individual field without affecting other edits", () => {
const editedFields = new Set<string>();
// 1. Edit Merchant
receipt.merchant = { ...receipt.merchant, name: "Modified Merchant Name" };
editedFields.add("merchant");
// 2. Edit Date
receipt.date = { ...receipt.date, isoDate: "2020-01-01" };
editedFields.add("date");
// 3. Edit Receipt Number
receipt.receiptNumber = "MODIFIED-NR-999";
editedFields.add("receiptNumber");
// 4. Edit Category
receipt.suggestedCategory = "Bewirtung";
editedFields.add("suggestedCategory");
// 5. Edit Document Type
receipt.documentType = "RECHNUNG";
editedFields.add("documentType");
expect(editedFields.size).toBe(5);
// Revert only Merchant
receipt.merchant = { ...receipt.originalExtraction!.merchant! };
editedFields.delete("merchant");
expect(receipt.merchant.name).toBe("Adversarial Hardware GmbH");
expect(editedFields.has("merchant")).toBe(false);
expect(editedFields.has("date")).toBe(true);
expect(receipt.date.isoDate).toBe("2020-01-01"); // Date remains modified
// Revert Date
receipt.date = { ...receipt.originalExtraction!.date! };
editedFields.delete("date");
expect(receipt.date.isoDate).toBe("2026-08-15");
expect(editedFields.has("date")).toBe(false);
// Revert Receipt Number
receipt.receiptNumber = receipt.originalExtraction!.receiptNumber ?? null;
editedFields.delete("receiptNumber");
expect(receipt.receiptNumber).toBe("ADV-2026-001");
// Revert Category & DocType
receipt.suggestedCategory = receipt.originalExtraction!.suggestedCategory!;
editedFields.delete("suggestedCategory");
receipt.documentType = receipt.originalExtraction!.documentType!;
editedFields.delete("documentType");
expect(editedFields.size).toBe(0);
expect(receipt.suggestedCategory).toBe("Bürobedarf & IT");
expect(receipt.documentType).toBe("KASSENBON");
});
test("ADV-M2.6: Reverting financial amount (Gross) triggers automatic tax & net recalculation back to original state", () => {
// Original: Gross = 119.00, Net = 100.00, MwSt 19% = 19.00
expect(receipt.totalAmount.value).toBe(119.00);
// Modify Gross to 357.00 € (MwSt 19% = 57.00, Net = 300.00)
const modified = recalculateReceipt(
{
...receipt,
totalAmount: { ...receipt.totalAmount, value: 357.00 },
},
{ editedField: "totalAmount" }
);
expect(modified.totalAmount.value).toBe(357.00);
expect(modified.netAmount).toBe(300.00);
expect(modified.taxBreakdown?.[0].taxAmount).toBe(57.00);
// Revert Gross to original 119.00 €
const reverted = recalculateReceipt(
{
...modified,
totalAmount: { ...modified.totalAmount, value: receipt.originalExtraction!.totalAmount!.value },
},
{ editedField: "totalAmount" }
);
expect(reverted.totalAmount.value).toBe(119.00);
expect(reverted.netAmount).toBe(100.00);
expect(reverted.taxBreakdown?.[0].taxAmount).toBe(19.00);
expect(reverted.validation.isMathValid).toBe(true);
});
test("ADV-M2.7: Line items modification and subsequent revert restores original line item array and cross-sum", () => {
const originalCount = receipt.originalExtraction?.lineItems?.length || 2;
expect(receipt.lineItems).toHaveLength(originalCount);
// Mutate line items (add new items and change prices)
receipt.lineItems = [
{ description: "Item X", quantity: 10, unitPrice: 100, price: 1000, taxRate: 19 },
];
expect(receipt.lineItems).toHaveLength(1);
expect(receipt.lineItems[0].price).toBe(1000);
// Revert line items
receipt.lineItems = [...receipt.originalExtraction!.lineItems!];
expect(receipt.lineItems).toHaveLength(2);
expect(receipt.lineItems[0].description).toBe("Cat6 Ethernet Kabel 10m");
expect(receipt.lineItems[1].description).toBe("Gigabit Switch 8-Port");
});
});
describe("Milestone 2: Adversarial Partial, Corrupted & Extreme Data Resilience", () => {
test("ADV-M2.8: Handles receipt with missing / null optional fields without crashing or throwing", () => {
const partialReceipt: ProcessedReceipt = {
id: "rcpt-partial-001",
imageHash: "hash-partial",
originalFileName: "corrupt_scan.png",
fileSizeBytes: 10000,
createdAt: "2026-08-15T00:00:00Z",
updatedAt: "2026-08-15T00:00:00Z",
status: "needs_review",
documentType: "SONSTIGES",
receiptNumber: null,
currency: "EUR",
merchant: { name: "", address: null, taxId: null, confidence: 0.1 },
date: { isoDate: "", time: null, confidence: 0.1 },
totalAmount: { value: 0, confidence: 0.1 },
netAmount: null,
taxBreakdown: [],
lineItems: [],
suggestedCategory: "Sonstiges",
validation: {
isMathValid: false,
isDuplicateSuspected: false,
needsUserReview: true,
reviewField: "totalAmount",
reviewReason: "Bruttobetrag fehlt",
},
};
// Ensure generateDefaultBoundingBoxes works on empty receipt without crashing
const boxes = generateDefaultBoundingBoxes(partialReceipt);
expect(boxes.merchant).toBeDefined();
expect(boxes.date).toBeDefined();
expect(boxes.totalAmount).toBeDefined();
expect(boxes.lineItems).toBeUndefined();
expect(boxes.taxBreakdown).toBeUndefined();
// Recalculation on empty receipt
const recalculated = recalculateReceipt(partialReceipt);
expect(recalculated.validation.needsUserReview).toBe(true);
});
test("ADV-M2.9: Bounding box coordinate bounds clamp extreme out-of-range, negative and NaN values", () => {
// Extreme negative coordinates
const boxNegative = createBoundingBoxRect(-50, -100, 200, 300);
expect(boxNegative.x).toBe(0);
expect(boxNegative.y).toBe(0);
expect(boxNegative.width).toBe(100);
expect(boxNegative.height).toBe(100);
// Overflow coordinates (x = 80, width = 50 -> clamped width should be 20)
const boxOverflow = createBoundingBoxRect(80, 70, 50, 50);
expect(boxOverflow.x).toBe(80);
expect(boxOverflow.width).toBe(20);
expect(boxOverflow.y).toBe(70);
expect(boxOverflow.height).toBe(30);
// NaN coordinates
const boxNaN = createBoundingBoxRect(NaN, NaN, NaN, NaN);
expect(boxNaN.x).toBe(0);
expect(boxNaN.y).toBe(0);
expect(boxNaN.width).toBe(0);
expect(boxNaN.height).toBe(0);
});
test("ADV-M2.10: Zero-pixel image dimensions in pixelRectToPercent safely fallback to 100% box", () => {
const pixelRect = { x: 50, y: 50, width: 200, height: 200 };
const box = pixelRectToPercent(pixelRect, 0, 0);
expect(box.x).toBe(0);
expect(box.y).toBe(0);
expect(box.width).toBe(100);
expect(box.height).toBe(100);
});
test("ADV-M2.11: Hit-testing on zero-width or empty bounding boxes does not falsely match", () => {
const zeroBox = createBoundingBoxRect(20, 20, 0, 0);
expect(isPointInsideBox({ x: 20, y: 20 }, zeroBox)).toBe(true); // Exact point
expect(isPointInsideBox({ x: 20.1, y: 20 }, zeroBox)).toBe(false);
expect(isPointInsideBox({ x: 19.9, y: 20 }, zeroBox)).toBe(false);
});
});
describe("Milestone 2: Adversarial Line Items Calculations & Numerical Precision", () => {
test("ADV-M2.12: High-precision decimal rounding handles repeating fractions and floating-point errors (0.1 + 0.2)", () => {
// 3 items @ 0.33 € each = 0.99 €
const qty = 3;
const unitPrice = 0.33;
const computed = Math.round(qty * unitPrice * 100) / 100;
expect(computed).toBe(0.99);
// Floating-point edge: 7 items @ 0.70 € = 4.90 € (without 4.8999999999999995 bug)
const qty2 = 7;
const unitPrice2 = 0.70;
const computed2 = Math.round(qty2 * unitPrice2 * 100) / 100;
expect(computed2).toBe(4.90);
});
test("ADV-M2.13: Fractional quantities (e.g. 1.345 kg of fruit or 45.2 liters of fuel)", () => {
const fuelLiters = 45.28;
const fuelPricePerLiter = 1.749; // Fuel prices have 3 decimal places in Germany
const totalPrice = Math.round(fuelLiters * fuelPricePerLiter * 100) / 100;
expect(totalPrice).toBe(79.19); // 45.28 * 1.749 = 79.19472 -> 79.19 €
});
test("ADV-M2.14: Line items cross-sum discrepancy tolerance window (0.02 € threshold)", () => {
const receiptGross = 100.00;
// Diff 0.01 € -> within tolerance (e.g. rounding difference)
const sum1 = 100.01;
const diff1 = Math.abs(Math.round((sum1 - receiptGross) * 100) / 100);
expect(diff1 <= 0.02).toBe(true);
// Diff 0.02 € -> within tolerance
const sum2 = 99.98;
const diff2 = Math.abs(Math.round((sum2 - receiptGross) * 100) / 100);
expect(diff2 <= 0.02).toBe(true);
// Diff 0.03 € -> discrepancy triggered!
const sum3 = 100.03;
const diff3 = Math.abs(Math.round((sum3 - receiptGross) * 100) / 100);
expect(diff3 > 0.02).toBe(true);
});
test("ADV-M2.15: Deleting all line item rows transitions gracefully to empty state without throwing", () => {
let items = [
{ description: "Item 1", quantity: 1, price: 10, taxRate: 19 },
{ description: "Item 2", quantity: 1, price: 20, taxRate: 19 },
];
// Delete item 0
items = items.filter((_, idx) => idx !== 0);
expect(items).toHaveLength(1);
// Delete remaining item
items = items.filter((_, idx) => idx !== 0);
expect(items).toHaveLength(0);
const sum = Math.round(items.reduce((acc, curr) => acc + (curr?.price ?? 0), 0) * 100) / 100;
expect(sum).toBe(0);
});
});