Accept grocery receipts scanned in pantry as candidate spend
ci / lint-test (push) Successful in 48s

Adds /api/receipts/ingest as a sibling to the order lane, sharing its shape but
making one decision differently: nothing is parked. An order can wait for its
statement because it is already visible as an email; a gift-card grocery shop is
visible nowhere at all, so a scan that produces no transaction produces nothing
anyone can see. Every payment becomes a manual row immediately and the existing
pending-reconciliation queue resolves the ones with a card leg coming.

One transaction per tender leg. A $114.57 shop settled $40.75 gift card +
$73.82 Mastercard has a statement line for $73.82 only. A single row marked
credits is excluded from the queue while that line double-counts; marked card it
is searched for at 1% of $114.57 and never matches. Either way the shop books
$188.39. Per-leg rows make each amount the settled amount, so the matcher works
untouched.

Reconciliation now carries expense_metadata across. It already moved overrides,
tags and splits from the manual row to the statement row and left metadata
behind, which did not matter while metadata only came from an email that made
its own transaction. It matters now that it carries a shop's line items:
unmoved, the contents vanish at exactly the moment the statement line appears,
and COLES 0556 MANOR LAKES stays as unreadable as before anything was scanned.
transaction_id is UNIQUE, so a statement row that already has metadata keeps it
and the pantry row is flagged rather than raising a constraint violation.

Also regenerates the Prisma model. card_last4, currency, flags, reconciled_at,
matched_transaction_id, platform and route have been in the database since
migrations 0019/0020 and were absent from schema.prisma — regenerating the
client from it would have dropped columns the order lane writes on every ingest.

23 integration tests against the real schema, built from the three receipts that
drove the design. Existing suites unchanged: 104 unit, 144 integration.
This commit is contained in:
2026-07-30 13:40:32 +10:00
parent 69b3ed8ea9
commit 17028c79ff
7 changed files with 745 additions and 0 deletions
@@ -0,0 +1,166 @@
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: [
{ name: "Pork Loin Roast", quantity: 1, unit: "ea", line_total: 15.86, category: "meat" },
{ name: "Jasmine Rice 1kg", quantity: 1, unit: "ea", line_total: 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");
});
});