import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import { Pool } from "pg"; import { createPool, resetDB, seedParticipants } from "./helpers"; /** * What happens to a scanned receipt when its statement finally arrives. * * This is the case the first design of this lane got wrong, so it is tested first-class. * Reconciliation moves a manual row's overrides, tags and splits onto the statement row and * then hides the manual row from every figure. `expense_metadata` was the one child it left * behind — which did not matter while metadata only ever came from an email that had made * its own transaction, and matters completely now that it carries a shop's line items. Left * unmoved, the contents of the shop vanish at exactly the moment the statement line shows * up, and `COLES 0556 MANOR LAKES` stays as unreadable as it was before anything was * scanned. */ const TOKEN = "test-receipt-token"; let pool: Pool; let ownerId: number; let ingest: typeof import("../../app/api/receipts/ingest/route").POST; let reconcile: typeof import("../../app/api/transactions/reconcile/route").POST; const ingestReq = (body: unknown) => ({ headers: { get: (h: string) => (h === "x-ingest-token" ? TOKEN : null) }, json: async () => body }) as never; // The reconcile route authenticates a browser session rather than a shared secret. const userReq = (body: unknown) => ({ json: async () => body }) as never; const colesSplit = { receipt_uid: "coles:7777:114:2153:2026-07-11", capture_event_id: 412, merchant_name: "Coles", transaction_date: "2026-07-11", total: 114.57, tender_legs: [ { leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" }, { leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" }, ], line_items: [ { description: "Pork Loin Roast", qty: 1, unit: "ea", amount: 15.86, category: "meat" }, { description: "Jasmine Rice 1kg", qty: 1, unit: "ea", amount: 98.71, category: "pantry_dry" }, ], }; async function statementLine(amount: number, date: string, description: string) { const statement = await pool.query( `INSERT INTO statements (filename, account_number, bank_name, billing_start_date, billing_end_date, owner_id) VALUES ('sept.pdf', '1234-5678-9012-3302', 'Test Bank', $1::date - 20, $1::date + 10, $2) RETURNING id`, [date, ownerId] ); const txn = await pool.query( `INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id) VALUES ($1, $2, $3, $4, 'debit', $5) RETURNING id`, [statement.rows[0].id, date, description, amount, ownerId] ); return txn.rows[0].id as number; } beforeAll(async () => { process.env.RECEIPT_INGEST_TOKEN = TOKEN; pool = createPool(); // The reconcile route scopes every query to the signed-in user. Mocked before the import // (doMock is not hoisted, so it can close over `ownerId`, which resetDB reassigns on each // run) because an ES module binding cannot be reassigned afterwards. vi.doMock("@/lib/auth", () => ({ getCurrentUser: async () => ({ id: ownerId, name: "Alice", email: "alice@example.com" }), })); ({ POST: ingest } = await import("../../app/api/receipts/ingest/route")); ({ POST: reconcile } = await import("../../app/api/transactions/reconcile/route")); }); beforeEach(async () => { await resetDB(pool); await pool.query("DELETE FROM expense_metadata"); ({ ownerId } = await seedParticipants(pool)); }); const reconcileAs = (manualId: number, statementId: number) => reconcile(userReq({ matches: [{ manual_id: manualId, statement_tx_id: statementId }] })); describe("a card leg meeting its statement line", () => { it("carries the shop's line items onto the statement row", async () => { await ingest(ingestReq(colesSplit)); const manual = await pool.query( `SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'` ); const manualId = manual.rows[0].id as number; const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE"); const res = await reconcileAs(manualId, statementId); expect(res.status ?? 200).toBe(200); const moved = await pool.query(`SELECT transaction_id, line_items FROM expense_metadata WHERE order_reference = $1`, [ "pantry:coles:7777:114:2153:2026-07-11#2", ]); // The whole point: the items are now on the row that survives, not the one that got hidden. expect(moved.rows[0].transaction_id).toBe(statementId); expect(moved.rows[0].line_items).toHaveLength(2); }); it("counts the shop once, not twice", async () => { await ingest(ingestReq(colesSplit)); const manual = await pool.query( `SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'` ); const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE"); await reconcileAs(manual.rows[0].id, statementId); // Reconciled manual rows are excluded from figures by reconciled_with_id; what remains // live is the gift-card leg plus the statement line = the $114.57 that was actually spent. const { rows } = await pool.query( `SELECT coalesce(sum(amount), 0)::text AS total FROM transactions WHERE reconciled_with_id IS NULL AND superseded_by_id IS NULL` ); expect(Number(rows[0].total)).toBeCloseTo(114.57, 2); }); it("leaves the gift-card leg alone", async () => { await ingest(ingestReq(colesSplit)); const manual = await pool.query( `SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'` ); const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE"); await reconcileAs(manual.rows[0].id, statementId); const gift = await pool.query( `SELECT t.reconciled_with_id, t.payment_method FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#1'` ); expect(gift.rows[0].reconciled_with_id).toBeNull(); expect(gift.rows[0].payment_method).toBe("credits"); }); it("keeps the other source's metadata when the statement row already has some", async () => { // An emailed or Paperless copy of the same purchase may have got there first. // transaction_id is UNIQUE, so one of them has to lose — and it must lose visibly // rather than by constraint violation at 11pm. await ingest(ingestReq(colesSplit)); const manual = await pool.query( `SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'` ); const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE"); await pool.query( `INSERT INTO expense_metadata (transaction_id, source, order_reference, line_items) VALUES ($1, 'email', 'email:already-here', '[]'::jsonb)`, [statementId] ); const res = await reconcileAs(manual.rows[0].id, statementId); expect(res.status ?? 200).toBe(200); const incumbent = await pool.query(`SELECT source FROM expense_metadata WHERE transaction_id = $1`, [statementId]); expect(incumbent.rows.map((r) => r.source)).toEqual(["email"]); const pantryRow = await pool.query(`SELECT flags FROM expense_metadata WHERE order_reference = $1`, [ "pantry:coles:7777:114:2153:2026-07-11#2", ]); expect(JSON.stringify(pantryRow.rows[0].flags)).toContain("metadata_collision_on_reconcile"); }); });