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,231 @@
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: [
{ name: "Pork Loin Roast", quantity: 1, unit: "ea", line_total: 15.86, category: "meat" },
{ name: "Tomatoes", quantity: 1, unit: "ea", line_total: 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: [{ name: "Oat Milk", quantity: 2, unit: "ea", line_total: 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: [{ name: "One line", line_total: 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);
});
});