ci / lint-test (push) Successful in 48s
The order-details panel reads qty/description/amount from expense_metadata.line_items. A grocery shop is a receipt like any other, so it stores the same keys rather than name/quantity/line_total — otherwise the rows arrive complete and display blank, which is exactly the failure ING-8 names. unit and category are the two fields a grocery line has and a delivery line does not; nothing renders them yet and the category composition will.
232 lines
10 KiB
TypeScript
232 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|