import { describe, it, expect, beforeAll, beforeEach } from "vitest"; import { Pool } from "pg"; import { createPool, resetDB, seedParticipants } from "./helpers"; /** * The receipt lane, exercised against the real schema. * * Every fixture here is a receipt that actually exists: a Coles split-tender e-receipt * (7777 Werribee, 11/07/2026, $114.57 settled $40.75 gift card + $73.82 Mastercard), a * Woolworths e-receipt (3345 Wyndham Vale, 29/06/2026, $40.75, single gift card) and a * Coles photo (556 Manor Lakes, 29/07/2026, $23.18). The split one is the reason the lane * writes one transaction per tender leg rather than one per receipt, so it is the case * these tests are built around. */ const TOKEN = "test-receipt-token"; let pool: Pool; let POST: typeof import("../../app/api/receipts/ingest/route").POST; const req = (body: unknown, token: string | null = TOKEN) => ({ headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) }, json: async () => body, }) as never; const colesSplit = () => ({ receipt_uid: "coles:7777:114:2153:2026-07-11", capture_event_id: 412, image_sha256: "sha-coles-split", merchant_name: "Coles", store_detail: "7777", transaction_date: "2026-07-11", total: 114.57, tender_raw: "EFT $40.75\nEFT $73.82\n***** 0443 MASTERCARD\nCREDIT ACCOUNT STORE CARD", tender_legs: [ { leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" as const }, { leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" as const }, ], line_items: [ { description: "Pork Loin Roast", qty: 1, unit: "ea", amount: 15.86, category: "meat" }, { description: "Tomatoes", qty: 1, unit: "ea", amount: 98.71, category: "produce" }, ], }); const woolworthsGiftCard = () => ({ receipt_uid: "woolworths:3345:62:148:2026-06-29", capture_event_id: 500, merchant_name: "Woolworths", store_detail: "3345", transaction_date: "2026-06-29", total: 40.75, tender_legs: [{ leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" as const }], line_items: [{ description: "Oat Milk", qty: 2, unit: "ea", amount: 40.75, category: "dairy" }], }); beforeAll(async () => { process.env.RECEIPT_INGEST_TOKEN = TOKEN; pool = createPool(); ({ POST } = await import("../../app/api/receipts/ingest/route")); }); beforeEach(async () => { await resetDB(pool); await pool.query("DELETE FROM expense_metadata"); // transactions.owner_id references participants, and resetDB truncates it with RESTART // IDENTITY — so DEFAULT_OWNER_ID (1) has to be re-seeded or every insert here fails the // foreign key. Same dependency the order lane has; it just never had to say so. await seedParticipants(pool); }); const legsOf = async (group: string) => (await pool.query( `SELECT t.id, t.amount::text, t.payment_method, t.statement_id, em.order_reference, em.line_items, em.flags FROM expense_metadata em JOIN transactions t ON t.id = em.transaction_id WHERE em.receipt_group = $1 ORDER BY em.order_reference`, [group] )).rows; describe("auth", () => { it("rejects a missing or wrong token", async () => { expect((await POST(req(colesSplit(), null))).status).toBe(401); expect((await POST(req(colesSplit(), "nope"))).status).toBe(401); }); it("rejects a body missing what it needs to book money", async () => { expect((await POST(req({ merchant_name: "Coles" }))).status).toBe(400); }); }); describe("a split-tender receipt", () => { it("books one transaction per leg, not one for the total", async () => { const res = await POST(req(colesSplit())); expect(res.status).toBe(200); const body = await res.json(); expect(body.legs).toHaveLength(2); const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11"); expect(rows.map((r) => r.amount)).toEqual(["40.75", "73.82"]); // Not one $114.57 row: marked credits it would let the $73.82 statement line // double-count; marked card it would be searched for at ±1% of $114.57 and never match. expect(rows.some((r) => r.amount === "114.57")).toBe(false); }); it("sums the legs back to the printed total", async () => { await POST(req(colesSplit())); const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11"); const total = rows.reduce((sum, r) => sum + Number(r.amount), 0); expect(Number(total.toFixed(2))).toBe(114.57); }); it("marks the gift-card leg credits and the card leg card", async () => { await POST(req(colesSplit())); const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11"); expect(rows.find((r) => r.amount === "40.75")!.payment_method).toBe("credits"); expect(rows.find((r) => r.amount === "73.82")!.payment_method).toBe("card"); }); it("leaves every leg as a manual row for the reconciliation queue", async () => { await POST(req(colesSplit())); const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11"); expect(rows.every((r) => r.statement_id === null)).toBe(true); }); it("puts the line items on the card leg only", async () => { // They describe the whole shop but transaction_id is UNIQUE on expense_metadata, so // they can attach to one row — and the card leg is the one that reconciles onto the // statement line, which is where an unreadable descriptor gets its contents. await POST(req(colesSplit())); const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11"); expect(rows.find((r) => r.amount === "73.82")!.line_items).toHaveLength(2); expect(rows.find((r) => r.amount === "40.75")!.line_items).toHaveLength(0); }); it("flags the split so it can be shown as one purchase", async () => { await POST(req(colesSplit())); const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11"); expect(rows[0].flags).toContain("split_tender"); }); }); describe("a gift-card receipt", () => { it("becomes a visible transaction on the day it is scanned", async () => { const res = await POST(req(woolworthsGiftCard())); expect(res.status).toBe(200); const rows = await legsOf("pantry:woolworths:3345:62:148:2026-06-29"); expect(rows).toHaveLength(1); expect(rows[0].amount).toBe("40.75"); expect(rows[0].payment_method).toBe("credits"); }); it("is owned, so it is not invisible in every view", async () => { // Owner scoping is COALESCE(t.owner_id, s.owner_id) and these rows carry no statement; // a NULL owner is how 85 backfilled order rows ended up in the table and on no screen. await POST(req(woolworthsGiftCard())); const { rows } = await pool.query("SELECT owner_id FROM transactions WHERE merchant_name = 'Woolworths'"); expect(rows[0].owner_id).not.toBeNull(); }); }); describe("idempotency", () => { it("keys on the receipt, so the same shop from two files lands once", async () => { // A photo and the store's e-receipt PDF are different files with different hashes. // Keying on the capture would let one purchase arrive twice. await POST(req(colesSplit())); await POST(req({ ...colesSplit(), capture_event_id: 999, image_sha256: "sha-different-file" })); const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11"); expect(rows).toHaveLength(2); // still just the two legs }); it("reports the replay rather than silently doing nothing", async () => { await POST(req(colesSplit())); const body = await (await POST(req(colesSplit()))).json(); expect(body.legs.every((l: { skipped?: string }) => l.skipped === "already_ingested")).toBe(true); }); it("falls back to the capture when the receipt did not identify itself", async () => { // A crumpled photo can lose the header entirely. That receipt still becomes spend; it // just cannot be recognised if the same shop is scanned again from another file. await POST(req({ ...colesSplit(), receipt_uid: null })); expect(await legsOf("pantry:capture:412")).toHaveLength(2); }); }); describe("validation — what becomes money", () => { it("refuses a receipt whose tender does not add up to its total", async () => { // A missed leg is spend that never appears; booking the rest would put a number in the // ledger nobody can stand behind. const broken = { ...colesSplit(), tender_legs: [colesSplit().tender_legs[0]] }; const res = await POST(req(broken)); expect(res.status).toBe(422); expect((await res.json()).reason).toContain("tender legs sum to 40.75"); expect(await legsOf("pantry:coles:7777:114:2153:2026-07-11")).toHaveLength(0); }); it("refuses a receipt with no tender at all", async () => { expect((await POST(req({ ...colesSplit(), tender_legs: [] }))).status).toBe(422); }); it("flags but accepts lines that do not sum, because promo rows are skipped by design", async () => { const res = await POST(req({ ...colesSplit(), line_items: [{ description: "One line", amount: 10 }] })); expect(res.status).toBe(200); expect((await res.json()).flags.some((f: string) => f.startsWith("line_items_sum_"))).toBe(true); }); it("writes nothing at all when validation fails", async () => { await POST(req({ ...colesSplit(), tender_legs: [] })); const { rows } = await pool.query("SELECT count(*)::int AS n FROM transactions"); expect(rows[0].n).toBe(0); }); it("marks a leg nobody could classify as reconcilable rather than deciding for them", async () => { const unknown = { ...colesSplit(), tender_legs: [{ leg_index: 1, amount: 114.57, card_last4: "9999", card_product: null, class: null }] }; const res = await POST(req(unknown)); expect(res.status).toBe(200); const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11"); // NULL is what needsCardMatch() treats as still needing a card match, so it surfaces in // the queue instead of being silently excluded from it. expect(rows[0].payment_method).toBeNull(); expect(rows[0].flags).toContain("unclassified_tender"); }); }); describe("dry run", () => { it("validates without writing", async () => { const res = await POST(req({ ...colesSplit(), dryRun: true })); expect(res.status).toBe(200); expect((await res.json()).dryRun).toBe(true); const { rows } = await pool.query("SELECT count(*)::int AS n FROM transactions"); expect(rows[0].n).toBe(0); }); });