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,115 @@
/**
* Sprint C: image quality — min width, contrast gate, crop box, deskew.
*/
import { describe, test, expect } from "./runner";
import {
AI_IMAGE_LONG_EDGE_PX,
AI_IMAGE_MIN_WIDTH_PX,
computeReceiptResize,
} from "../../src/lib/image/resize";
import {
contentBoundingBox,
estimateSkewDegrees,
needsContrastBoost,
} from "../../src/lib/image/enhance";
import { processReceiptImage } from "../../src/lib/image/processor";
describe("Sprint C — Mindestbreite", () => {
test("Querformat bleibt am 1536-Long-Edge", () => {
const out = computeReceiptResize(2000, 1400);
expect(out.width).toBe(1536);
expect(out.height).toBe(1075);
});
test("quadratische kleine Fotos werden nicht hochskaliert", () => {
expect(computeReceiptResize(400, 400)).toEqual({ width: 400, height: 400 });
});
test("hoher Thermobon wird breiter als bei reinem Long-Edge-Cap", () => {
const srcW = 800;
const srcH = 3500;
const longEdgeOnlyW = Math.round(srcW * (AI_IMAGE_LONG_EDGE_PX / srcH));
const out = computeReceiptResize(srcW, srcH);
expect(longEdgeOnlyW).toBeLessThan(400);
expect(out.width).toBeGreaterThan(longEdgeOnlyW);
expect(out.width).toBeGreaterThanOrEqual(650);
expect(Math.max(out.width, out.height)).toBeLessThanOrEqual(4096);
});
test("Min-Width-Konstante ist 1100", () => {
expect(AI_IMAGE_MIN_WIDTH_PX).toBe(1100);
});
});
describe("Sprint C — Kontrast", () => {
test("verwaschenes Thermobild (niedrige Stdev) bekommt Boost", () => {
expect(
needsContrastBoost({
channels: [{ mean: 170, stdev: 12, min: 140, max: 200 }],
})
).toBe(true);
});
test("farbstarkes Foto bekommt keinen Boost", () => {
expect(
needsContrastBoost({
channels: [
{ mean: 90, stdev: 55, min: 10, max: 240 },
{ mean: 100, stdev: 60, min: 8, max: 250 },
{ mean: 80, stdev: 50, min: 5, max: 230 },
],
})
).toBe(false);
});
});
describe("Sprint C — Crop", () => {
test("dunkles Rechteck auf weißem Grund wird als Inhalt erkannt", () => {
const w = 80;
const h = 80;
const data = new Uint8Array(w * h).fill(250);
for (let y = 18; y < 62; y++) {
for (let x = 16; x < 64; x++) {
data[y * w + x] = 20;
}
}
const box = contentBoundingBox(data, w, h);
expect(box).not.toBeNull();
expect(box!.left).toBeLessThanOrEqual(16);
expect(box!.top).toBeLessThanOrEqual(18);
expect(box!.left + box!.width).toBeGreaterThanOrEqual(64);
expect(box!.top + box!.height).toBeGreaterThanOrEqual(62);
});
test("einfarbiges Bild wird nicht beschnitten", () => {
const data = new Uint8Array(40 * 40).fill(200);
expect(contentBoundingBox(data, 40, 40)).toBeNull();
});
});
describe("Sprint C — Deskew", () => {
test("waagerechte Textzeilen → Winkel ~ 0", () => {
const w = 64;
const h = 80;
const data = new Uint8Array(w * h);
for (let y = 0; y < h; y++) {
const dark = y % 8 < 3;
data.fill(dark ? 15 : 230, y * w, y * w + w);
}
expect(Math.abs(estimateSkewDegrees(data, w, h))).toBeLessThan(1);
});
});
describe("Sprint C — Pipeline", () => {
test("1×1-PNG überlebt Deskew/Crop/Resize", async () => {
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64"
);
const result = await processReceiptImage(png, "image/png");
expect(result.mimeType).toBe("image/jpeg");
expect(result.width).toBeGreaterThan(0);
expect(result.height).toBeGreaterThan(0);
});
});