import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest"; import { Pool } from "pg"; import { readFileSync, mkdtempSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join, resolve } from "path"; import type { NextRequest } from "next/server"; import { createPool, mockDbWithPool, resetDB, seedParticipants } from "./helpers"; import type { ExtractedOrder } from "../../lib/intake-rules"; /** * The intake router against the real schema (plan §11 C1, C2, C5). * * The model is stubbed at the HTTP boundary — `fetch` to LiteLLM answers with a * canned extraction, `fetch` to pantry with a canned capture — so the PDF text * path, the persistence, the match and the writes all run for real. The two * PDFs are the user's own Uber Eats exports; the transactions seeded here are * shaped like production rows 6846 and 6857 (platform `ubereats`, `line_items` * empty, the email pipeline's booking of the same two orders). */ const INTAKE = "test-intake-token"; const RECEIPT = "test-receipt-token"; const fixtures = resolve(__dirname, "../fixtures/intake"); const BURGERTORY = readFileSync(resolve(fixtures, "ubereats-burgertory-2026-08-19.pdf")); const COLES = readFileSync(resolve(fixtures, "ubereats-coles-2026-08-18.pdf")); const burgertory: ExtractedOrder = { merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, subtotal: 28, currency: "AUD", items: [ { quantity: 1, description: "Michael", unit_price: 14, line_total: 14, options: ["Milk Bun"] }, { quantity: 1, description: "Joel", unit_price: 14, line_total: 14, options: [] }, ], adjustments: [{ label: "Delivery Fee", amount: 5.49 }, { label: "Promotion", amount: -8 }], }; const coles: ExtractedOrder = { merchant: "Coles (Wyndham Vale)", order_date: "2026-08-18", total: 85.74, subtotal: 118.94, currency: "AUD", items: [ { quantity: 3, description: "Coca-Cola Zero Sugar Soft Drink (2L)", unit_price: 4.4, line_total: 13.2, options: [] }, { quantity: 1, description: "Coles RSPCA Approved Chicken Thigh Cutlets approx. 1.1kg", unit_price: 11, line_total: 12.76, options: ["Final 1.16 kg $11.00/kg"] }, { quantity: 1, description: "everything else", unit_price: 92.98, line_total: 92.98, options: [] }, ], adjustments: [], }; let pool: Pool; let ownerId: number; let otherId: number; let fileDir: string; let intakePOST: (req: NextRequest) => Promise; let lineItemsPUT: (req: NextRequest, ctx: { params: Promise<{ id: string }> }) => Promise; let slackPOST: (req: NextRequest) => Promise; let orderGET: (req: NextRequest, ctx: { params: Promise<{ id: string }> }) => Promise; /** What the next LiteLLM call answers, and what the next pantry call answers. */ let modelReply: ExtractedOrder | Error = burgertory; let pantryReply: { status: number; body: unknown } = { status: 200, body: { captureId: 901, items: [1, 2, 3] } }; const calls: { url: string; body: unknown }[] = []; function stubFetch() { vi.stubGlobal("fetch", async (url: string | URL, init?: RequestInit) => { const u = String(url); if (u.includes("/v1/chat/completions")) { calls.push({ url: u, body: JSON.parse(String(init?.body)) }); if (modelReply instanceof Error) return new Response("boom", { status: 500 }); return Response.json({ choices: [{ message: { content: JSON.stringify(modelReply) }, finish_reason: "stop" }] }); } if (u.includes("/api/capture")) { const form = init?.body as FormData; calls.push({ url: u, body: { intake: JSON.parse(String(form.get("intake"))), files: form.getAll("image").map((f) => (f as File).name), headers: init?.headers } }); return Response.json(pantryReply.body, { status: pantryReply.status }); } if (u.includes("hooks.slack.com")) { calls.push({ url: u, body: JSON.parse(String(init?.body)) }); return new Response("ok"); } throw new Error(`unexpected fetch ${u}`); }); } const multipart = (fields: Record, files: { name: string; type: string; bytes: Buffer }[], token: string | null = INTAKE) => { const fd = new FormData(); for (const [k, v] of Object.entries(fields)) fd.append(k, v); for (const f of files) fd.append("file", new File([new Uint8Array(f.bytes)], f.name, { type: f.type })); return new Request("http://finance.test/api/intake", { method: "POST", headers: token ? { "x-ingest-token": token } : {}, body: fd }) as unknown as NextRequest; }; const jsonReq = (body: unknown, token: string | null) => ({ headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) }, json: async () => body }) as unknown as NextRequest; const put = (id: number | string, body: unknown, token: string | null = RECEIPT) => lineItemsPUT(jsonReq(body, token), { params: Promise.resolve({ id: String(id) }) }); const uuid = () => crypto.randomUUID(); /** A transaction shaped like the email pipeline's booking of an order, line_items empty. */ async function seedOrder(over: { merchant: string; date: string; amount: number; platform?: string | null; owner?: number; withMeta?: boolean }) { const t = await pool.query( `INSERT INTO transactions (owner_id, statement_id, transaction_date, description, amount, amount_aud, transaction_type, category, merchant_name, merchant_normalized, payment_method, row_index) VALUES ($1, NULL, $2, $3, $4, $4, 'debit', 'dining', $5, $5, 'credits', 0) RETURNING id`, [over.owner ?? ownerId, over.date, `Order - ${over.merchant}`, over.amount, over.merchant] ); const id = t.rows[0].id as number; if (over.withMeta !== false) { await pool.query( `INSERT INTO expense_metadata (transaction_id, source, platform, order_reference, line_items, merchant_normalized, amount, transaction_date, currency) VALUES ($1, 'email', $2, $3, '[]'::jsonb, $4, $5, $6, 'AUD')`, [id, over.platform === undefined ? "ubereats" : over.platform, uuid(), over.merchant, over.amount, over.date] ); } return id; } /** What `components/order-details.tsx` would actually render for a transaction. */ const orderPanel = async (txnId: number) => (await (await orderGET({ headers: { get: () => null } } as unknown as NextRequest, { params: Promise.resolve({ id: String(txnId) }) })).json()) as { line_items?: unknown[] } | null; const metaRows = async () => (await pool.query(`SELECT id, platform, line_items FROM expense_metadata ORDER BY id`)).rows as { id: number; platform: string | null; line_items: { description: string }[] }[]; const lineItemsOf = async (txnId: number) => (await pool.query(`SELECT line_items FROM expense_metadata WHERE transaction_id = $1 OR matched_transaction_id = $1`, [txnId])).rows[0]?.line_items; const batch = async (id: string) => (await pool.query(`SELECT * FROM intake_batches WHERE id = $1`, [id])).rows[0]; beforeAll(async () => { process.env.INTAKE_TOKEN = INTAKE; process.env.RECEIPT_INGEST_TOKEN = RECEIPT; process.env.ORDER_INGEST_TOKEN = "test-order-token"; process.env.LITELLM_BASE_URL = "http://litellm.test"; process.env.LITELLM_KEY = "k"; process.env.PANTRY_BASE_URL = "http://pantry.test"; process.env.FINANCE_PUBLIC_URL = "https://finance.test"; fileDir = mkdtempSync(join(tmpdir(), "intake-test-")); process.env.INTAKE_FILE_DIR = fileDir; pool = createPool(); mockDbWithPool(pool); // The Slack signature gate is exercised by slack-verify.test.ts; here it is // bypassed so the intake branch of the route is what is under test. vi.doMock("@/lib/slack-verify", () => ({ verifySlackSignature: () => true, participantForSlackUser: () => 1, slackUserForParticipant: () => null, })); // The order-detail panel is the surface an enrichment has to appear on, so // the read is exercised beside the write rather than assumed. vi.doMock("@/lib/auth", () => ({ getCurrentUser: async () => ({ id: 1, name: "Owner", email: "o@x" }) })); vi.doMock("@/lib/queries", async (importOriginal) => ({ ...(await importOriginal>()), canAccessTransactions: async () => true, })); ({ POST: intakePOST } = await import("../../app/api/intake/route")); ({ PUT: lineItemsPUT } = await import("../../app/api/transactions/[id]/line-items/route")); ({ POST: slackPOST } = await import("../../app/api/slack/interactive/route")); ({ GET: orderGET } = await import("../../app/api/transactions/[id]/order/route")); stubFetch(); }); afterAll(() => { vi.unstubAllGlobals(); rmSync(fileDir, { recursive: true, force: true }); }); beforeEach(async () => { await resetDB(pool); await pool.query(`TRUNCATE intake_batches, intake_merchant_kinds`); ({ ownerId, otherId } = await seedParticipants(pool, ["Alice", "Bob"])); await pool.query(`UPDATE participants SET email = 'bob@example.test' WHERE id = $1`, [otherId]); modelReply = burgertory; pantryReply = { status: 200, body: { captureId: 901, items: [1, 2, 3] } }; calls.length = 0; }); // ─── C2 ─────────────────────────────────────────────────────────────────────── describe("PUT /api/transactions/[id]/line-items (C2)", () => { const items = [{ qty: 1, description: "Michael", amount: 14, options: ["Milk Bun"] }, { qty: 1, description: "Joel", amount: 14 }]; it("fails closed: no token, wrong token", async () => { expect((await put(1, { line_items: items, source: "intake", replace: true }, null)).status).toBe(401); expect((await put(1, { line_items: items, source: "intake", replace: true }, "nope")).status).toBe(401); }); it("accepts either token", async () => { const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); expect((await put(id, { line_items: items, source: "pantry", replace: true }, RECEIPT)).status).toBe(200); expect((await put(id, { line_items: items, source: "intake", replace: true }, INTAKE)).status).toBe(200); }); it("404 when the transaction does not exist — it never creates one", async () => { const res = await put(999999, { line_items: items, source: "intake", replace: true }); expect(res.status).toBe(404); expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0); }); it("rejects a body that is not a replace, a bad source, or nameless items", async () => { const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); expect((await put(id, { line_items: items, source: "intake" })).status).toBe(400); expect((await put(id, { line_items: items, source: "n8n", replace: true })).status).toBe(400); expect((await put(id, { line_items: [{ qty: 1 }], source: "intake", replace: true })).status).toBe(400); }); it("idempotent replace: returns previous + new, and a second identical call is a no-op", async () => { const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); const first = await (await put(id, { line_items: items, source: "intake", replace: true })).json(); expect(first.previous_line_items).toEqual([]); expect(first.line_items).toHaveLength(2); expect(first.created_metadata).toBe(false); expect(await lineItemsOf(id)).toHaveLength(2); const corrected = [{ qty: 1, description: "Michael (corrected)", amount: 14 }]; const second = await (await put(id, { line_items: corrected, source: "pantry", replace: true })).json(); expect(second.previous_line_items.map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]); expect(await lineItemsOf(id)).toEqual(corrected); const third = await (await put(id, { line_items: corrected, source: "pantry", replace: true })).json(); expect(third.previous_line_items).toEqual(corrected); expect((await pool.query(`SELECT count(*)::int AS n FROM expense_metadata`)).rows[0].n).toBe(1); }); it("undo shape: PUT previous_line_items back restores the row", async () => { const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); const r = await (await put(id, { line_items: items, source: "pantry", replace: true })).json(); await put(id, { line_items: r.previous_line_items, source: "pantry", replace: true }); expect(await lineItemsOf(id)).toEqual([]); }); it("creates the expense_metadata row when the transaction has none, with source and platform", async () => { const id = await seedOrder({ merchant: "Coles", date: "2026-08-18", amount: 85.74, withMeta: false }); const res = await (await put(id, { line_items: items, source: "intake", platform: "ubereats", batch_id: "b1", replace: true })).json(); expect(res.created_metadata).toBe(true); const em = (await pool.query(`SELECT source, platform, order_reference, merchant_normalized, amount::text FROM expense_metadata WHERE transaction_id = $1`, [id])).rows[0]; expect(em).toEqual({ source: "intake", platform: "ubereats", order_reference: "intake:b1", merchant_normalized: "Coles", amount: "85.74" }); }); it("the undo baseline is recorded ONCE per batch: a retry gets the original, not its own write", async () => { const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); const original = [{ qty: 1, description: "what the order already had", amount: 22.4 }]; await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [txn, JSON.stringify(original)]); const id = uuid(); await pool.query(`INSERT INTO intake_batches (id, source, file_sha256, status) VALUES ($1::uuid, 'other', '{}', 'received')`, [id]); const first = await (await put(txn, { line_items: items, source: "pantry", batch_id: id, replace: true })).json(); expect(first.previous_line_items).toEqual(original); // Pantry re-calls on every confirm and after a timeout. Before, the retry // was handed its OWN write back, so undo restored the thing it undid. const retry = await (await put(txn, { line_items: items, source: "pantry", batch_id: id, replace: true })).json(); expect(retry.previous_line_items).toEqual(original); expect((await pool.query(`SELECT previous_line_items FROM intake_batches WHERE id = $1`, [id])).rows[0].previous_line_items).toEqual(original); await put(txn, { line_items: retry.previous_line_items, source: "pantry", batch_id: id, replace: true }); expect(await lineItemsOf(txn)).toEqual(original); }); it("without a batch id the baseline is still the row as read — nothing else changes", async () => { const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); const first = await (await put(txn, { line_items: items, source: "pantry", replace: true })).json(); expect(first.previous_line_items).toEqual([]); const second = await (await put(txn, { line_items: items, source: "pantry", replace: true })).json(); expect(second.previous_line_items).toEqual(items); }); it("a pinned row that no longer exists falls back to the live one, rather than reporting a phantom write", async () => { const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); const live = (await pool.query(`SELECT id FROM expense_metadata WHERE transaction_id = $1`, [txn])).rows[0].id as number; const { replaceLineItems } = await import("../../lib/line-items"); const r = await replaceLineItems(txn, items, { source: "intake", metadataId: live + 999 }); expect(r.metadata_id).toBe(live); expect(await lineItemsOf(txn)).toHaveLength(2); }); it("two writes racing to create the metadata row both resolve to the same one", async () => { const txn = await seedOrder({ merchant: "Coles", date: "2026-08-18", amount: 85.74, withMeta: false }); const both = await Promise.all([ put(txn, { line_items: items, source: "intake", replace: true }), put(txn, { line_items: items, source: "intake", replace: true }), ]); expect(both.map((r) => r.status)).toEqual([200, 200]); expect((await pool.query(`SELECT count(*)::int AS n FROM expense_metadata WHERE transaction_id = $1`, [txn])).rows[0].n).toBe(1); }); it("the create path records an undo baseline too, before it writes", async () => { const txn = await seedOrder({ merchant: "Coles", date: "2026-08-18", amount: 85.74, withMeta: false }); const id = uuid(); await pool.query(`INSERT INTO intake_batches (id, source, file_sha256, status) VALUES ($1::uuid, 'other', '{}', 'received')`, [id]); await put(txn, { line_items: items, source: "intake", batch_id: id, currency: "AUD", replace: true }); // [] rather than NULL: there was nothing there, and "nothing" is the state // undo has to be able to restore. expect((await pool.query(`SELECT previous_line_items FROM intake_batches WHERE id = $1`, [id])).rows[0].previous_line_items).toEqual([]); }); it("the panel reads the row the write landed on, whichever order the rows were inserted in", async () => { const t = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false }); // Inserted first: the delivery receipt pointing at the statement line. await pool.query( `INSERT INTO expense_metadata (transaction_id, matched_transaction_id, source, platform, order_reference, line_items) VALUES (NULL, $1, 'email', 'ubereats', $2, '[]'::jsonb)`, [t, uuid()] ); // Inserted second: a row the transaction owns. An unordered LIMIT 1 reads // the first one, the write takes the second, and the enrichment reports // success against a row nothing renders. await pool.query( `INSERT INTO expense_metadata (transaction_id, source, order_reference, line_items) VALUES ($1, 'pantry', $2, '[]'::jsonb)`, [t, uuid()] ); const written = await (await put(t, { line_items: items, source: "intake", replace: true })).json(); const panel = await orderPanel(t); expect(panel?.line_items).toHaveLength(2); expect((await metaRows()).find((r) => r.id === written.metadata_id)!.line_items).toHaveLength(2); }); it("finds the row through matched_transaction_id — a card-settled order has only that", async () => { const statementLine = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false }); await pool.query( `INSERT INTO expense_metadata (transaction_id, matched_transaction_id, source, platform, order_reference, line_items) VALUES (NULL, $1, 'email', 'ubereats', $2, '[]'::jsonb)`, [statementLine, uuid()] ); const res = await (await put(statementLine, { line_items: items, source: "intake", replace: true })).json(); expect(res.created_metadata).toBe(false); expect((await pool.query(`SELECT count(*)::int AS n FROM expense_metadata`)).rows[0].n).toBe(1); expect(await lineItemsOf(statementLine)).toHaveLength(2); }); }); // ─── C1 ─────────────────────────────────────────────────────────────────────── describe("POST /api/intake — gates", () => { const pdf = { name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY }; it("fails closed without INTAKE_TOKEN match", async () => { expect((await intakePOST(multipart({ source: "uber_eats" }, [pdf], null))).status).toBe(401); expect((await intakePOST(multipart({ source: "uber_eats" }, [pdf], "nope"))).status).toBe(401); }); it("400 on a bad source, no files, too many files, a non-uuid batch_id", async () => { expect((await intakePOST(multipart({ source: "menulog" }, [pdf]))).status).toBe(400); expect((await intakePOST(multipart({ source: "uber_eats" }, []))).status).toBe(400); expect((await intakePOST(multipart({ source: "uber_eats" }, Array(7).fill(pdf)))).status).toBe(400); expect((await intakePOST(multipart({ source: "uber_eats", batch_id: "not-a-uuid" }, [pdf]))).status).toBe(400); expect((await pool.query(`SELECT count(*)::int AS n FROM intake_batches`)).rows[0].n).toBe(0); }); it("declines a file type it cannot read, after persisting the batch", async () => { const id = uuid(); const res = await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [{ name: "clip.mp4", type: "video/mp4", bytes: Buffer.from("x") }])); const body = await res.json(); expect(body.status).toBe("unsupported"); expect(body.text).toMatch(/not this/); expect((await batch(id)).status).toBe("unsupported"); expect(calls).toHaveLength(0); }); }); describe("POST /api/intake — food (enrich vs candidates)", () => { const pdf = { name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY }; it("food + exact (platform, merchant, date, total) match → enriches the existing order, one model call, PDF as text", async () => { const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); const id = uuid(); const res = await intakePOST(multipart({ source: "uber_eats", shared_by: "bob@example.test", batch_id: id }, [pdf])); const body = await res.json(); expect(res.status).toBe(200); expect(body).toMatchObject({ kind: "food", status: "enriched", transaction_id: txn, batch_id: id }); expect(body.text).toBe("Added 2 items to your $22.40 Uber Eats (Burgertory (Werribee)) order ✓"); expect(JSON.stringify(body.slack_blocks)).toContain("https://finance.test/transactions?q=Burgertory"); const stored = await lineItemsOf(txn); expect(stored.map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]); expect(stored[0].options).toEqual(["Milk Bun"]); // one joint call, the PDF's text in it (not an image) expect(calls).toHaveLength(1); const content = calls[0].body as { messages: { content: { type: string; text?: string }[] }[]; model: string; temperature: number; response_format: { type: string } }; expect(content.model).toBe("cloud-budget"); expect(content.temperature).toBe(0); expect(content.response_format.type).toBe("json_schema"); expect(content.messages[0].content.some((c) => c.type === "image_url")).toBe(false); expect(content.messages[0].content.some((c) => c.text?.includes("1 Michael $14.00"))).toBe(true); const row = await batch(id); expect(row).toMatchObject({ source: "uber_eats", shared_by: "bob@example.test", kind: "food", status: "enriched", transaction_id: txn }); expect(row.header).toMatchObject({ merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, platform: "ubereats" }); expect(row.resolved_at).not.toBeNull(); expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1); }); it("the platform is part of the key: the same merchant/date/total on DoorDash is not the match — but it IS offered", async () => { const doordash = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4, platform: "doordash" }); const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json(); expect(body.status).toBe("candidates"); // The candidates card is a human choice, not a decision. The platform is one // of the things that may be wrong, so it must not narrow the list — a // platform-filtered card showed nothing at all here. expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id)).toEqual([doordash]); }); it("a NULL platform is 'no platform', not a wildcard — an in-store share does not overwrite the delivery order", async () => { await pool.query(`INSERT INTO intake_merchant_kinds (merchant_key, kind) VALUES ('burgertory werribee', 'food')`); const delivery = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4, platform: "ubereats" }); const already = [{ qty: 1, description: "what the delivery order already had", amount: 22.4 }]; await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [delivery, JSON.stringify(already)]); // `other` implies no platform; the delivery row carries one, so it is not // the match — it is only a candidate. const body = await (await intakePOST(multipart({ source: "other" }, [pdf]))).json(); expect(body.status).toBe("candidates"); expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id)).toEqual([delivery]); expect(await lineItemsOf(delivery)).toEqual(already); }); it("a reconciled manual row is neither matched nor offered — EXCLUDE_RECONCILED_SOURCE, as everywhere else", async () => { const live = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); const reconciled = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); await pool.query(`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, [live, reconciled]); const near = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-17", amount: 19.9 }); await pool.query(`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, [live, near]); // Two rows at the exact key would have read as an ambiguity; one of them is // the same spend counted twice. const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json(); expect(body).toMatchObject({ status: "enriched", transaction_id: live }); }); it("the write lands on the row the MATCH resolved through, not on whichever row sorts first", async () => { const t = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false }); // The statement line owns a receipt row of its own (a pantry confirm) … const grocery = [{ qty: 1, description: "a grocery shop", amount: 22.4 }]; const own = (await pool.query( `INSERT INTO expense_metadata (transaction_id, source, order_reference, merchant_normalized, line_items) VALUES ($1, 'pantry', $2, 'Woolworths', $3::jsonb) RETURNING id`, [t, uuid(), JSON.stringify(grocery)] )).rows[0].id as number; // … and is also the card leg of the Uber Eats order being enriched. const delivery = (await pool.query( `INSERT INTO expense_metadata (transaction_id, matched_transaction_id, source, platform, order_reference, merchant_normalized, line_items) VALUES (NULL, $1, 'email', 'ubereats', $2, 'Burgertory (Werribee)', '[]'::jsonb) RETURNING id`, [t, uuid()] )).rows[0].id as number; const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json(); expect(body).toMatchObject({ status: "enriched", transaction_id: t }); const rows = await metaRows(); expect(rows.find((r) => r.id === delivery)!.line_items.map((i) => i.description)).toEqual(["Michael", "Joel"]); expect(rows.find((r) => r.id === own)!.line_items).toEqual(grocery); }); it("an upload with no prices never replaces a receipt that already has items", async () => { const priceless = { ...burgertory, subtotal: null, total: 22.4, items: burgertory.items.map((i) => ({ ...i, unit_price: null, line_total: null })) }; const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); const already = [{ qty: 1, description: "the itemised receipt already there", amount: 22.4 }]; await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [txn, JSON.stringify(already)]); modelReply = priceless; const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json(); expect(body).toMatchObject({ kind: "held", status: "held" }); expect(body.text).toMatch(/left the existing items alone/); expect(await lineItemsOf(txn)).toEqual(already); // …but an EMPTY receipt is exactly what this slice exists to fill. const empty = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-20", amount: 22.4 }); modelReply = { ...priceless, order_date: "2026-08-20" }; const second = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json(); expect(second).toMatchObject({ status: "enriched", transaction_id: empty }); expect((await lineItemsOf(empty)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]); }); it("food + no match → candidates (same merchant ±3 days, any amount) with Use-this / Create buttons; NEVER auto-creates", async () => { const near = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-17", amount: 19.9 }); await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-10", amount: 22.4 }); // too far await seedOrder({ merchant: "Mad Mex", date: "2026-08-19", amount: 22.4 }); // wrong merchant const id = uuid(); const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json(); expect(body.status).toBe("candidates"); expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id)).toEqual([near]); const blocks = JSON.stringify(body.slack_blocks); expect(blocks).toContain('"action_id":"intake_use_candidate"'); expect(blocks).toContain('"action_id":"intake_create"'); expect(blocks).toContain(JSON.stringify(JSON.stringify({ batch_id: id, transaction_id: near }))); expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(3); expect(await lineItemsOf(near)).toEqual([]); expect((await batch(id)).items).toHaveLength(2); }); it("two exact hits is an ambiguity, not a coin toss", async () => { const a = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); const b = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json(); expect(body.status).toBe("candidates"); expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id).sort()).toEqual([a, b].sort()); }); it("missing header (no total) → needs_header, nothing written but the batch row", async () => { const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); modelReply = { ...burgertory, total: null }; const id = uuid(); const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json(); expect(body.status).toBe("needs_header"); expect(body.text).toMatch(/share the top of the screen/); expect(await lineItemsOf(txn)).toEqual([]); expect((await batch(id)).status).toBe("needs_header"); }); it("items that do not add up → held, nothing written", async () => { const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); modelReply = { ...burgertory, items: [burgertory.items[0]] }; const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json(); expect(body).toMatchObject({ kind: "held", status: "held" }); expect(body.text).toMatch(/is a shot missing/); expect(await lineItemsOf(txn)).toEqual([]); }); it("a model failure is a 502 with the batch marked error, not a silent 200", async () => { modelReply = new Error("down"); const res = await intakePOST(multipart({ source: "uber_eats" }, [pdf])); expect(res.status).toBe(502); expect((await res.json()).status).toBe("error"); }); it("re-share of the same file after it was handled → duplicate with a pointer", async () => { const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); const first = uuid(); await intakePOST(multipart({ source: "uber_eats", batch_id: first }, [pdf])); calls.length = 0; const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json(); expect(body.status).toBe("duplicate"); expect(body.duplicate_of).toEqual({ batch_id: first, transaction_id: txn, capture_id: null }); expect(body.text).toMatch(/Already handled/); expect(calls).toHaveLength(0); // no model call spent on a known file }); it("re-share of the same order under a different file (screenshot after PDF) → duplicate by header", async () => { const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); const first = uuid(); await intakePOST(multipart({ source: "uber_eats", batch_id: first }, [pdf])); const body = await (await intakePOST(multipart({ source: "uber_eats" }, [{ name: "IMG_1.png", type: "image/png", bytes: Buffer.from("fake-png") }]))).json(); expect(body.status).toBe("duplicate"); expect(body.duplicate_of.transaction_id).toBe(txn); }); it("a repeated batch_id is refused, not re-run", async () => { const id = uuid(); await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf])); expect((await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).status).toBe(409); }); }); describe("POST /api/intake — grocery → pantry", () => { const pdf = { name: "Receipt_18Aug2026.pdf", type: "application/pdf", bytes: COLES }; it("grocery + match → the ORIGINAL file goes to pantry with the target transaction and action enrich; finance is NOT touched", async () => { modelReply = coles; const txn = await seedOrder({ merchant: "Coles (Wyndham Vale)", date: "2026-08-18", amount: 85.74 }); const id = uuid(); const body = await (await intakePOST(multipart({ source: "uber_eats", shared_by: "bob@example.test", batch_id: id }, [pdf]))).json(); expect(body).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901, transaction_id: txn }); expect(body.text).toBe("Coles (Wyndham Vale) $85.74 → pantry board (3 items) — finance updates on confirm"); const pantryCall = calls.find((c) => c.url === "http://pantry.test/api/capture")!.body as { intake: Record; files: string[]; headers: Record }; expect(pantryCall.headers["x-ingest-token"]).toBe(RECEIPT); expect(pantryCall.files).toEqual(["Receipt_18Aug2026.pdf"]); expect(pantryCall.intake).toEqual({ batch_id: id, platform: "ubereats", origin: "intake", shared_by: "bob@example.test", finance_target_transaction_id: txn, finance_action: "enrich" }); expect(await lineItemsOf(txn)).toEqual([]); expect((await batch(id))).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901, transaction_id: txn }); }); it("an in-store tap does not hand pantry the delivery order as its finance target", async () => { modelReply = coles; // The Uber Eats Coles order the email pipeline already booked. const delivery = await seedOrder({ merchant: "Coles (Wyndham Vale)", date: "2026-08-18", amount: 85.74, platform: "ubereats" }); // The same shop, same day, same money — but shared from the Coles app, so // it carries no platform and is a different purchase. const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json(); expect(body.status).toBe("forwarded_to_pantry"); const pantryCall = calls.find((c) => c.url === "http://pantry.test/api/capture")!.body as { intake: Record }; expect(pantryCall.intake).toMatchObject({ finance_action: "create", finance_target_transaction_id: null, platform: null }); expect(await lineItemsOf(delivery)).toEqual([]); }); it("grocery + no match → pantry with finance_action create (ING-10 as today)", async () => { modelReply = coles; const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json(); expect(body.status).toBe("forwarded_to_pantry"); expect(body.text).toMatch(/finance books it on confirm/); const pantryCall = calls.find((c) => c.url.includes("/api/capture"))!.body as { intake: Record }; expect(pantryCall.intake).toMatchObject({ platform: null, finance_target_transaction_id: null, finance_action: "create" }); }); it("pantry 409 → duplicate", async () => { modelReply = coles; pantryReply = { status: 409, body: { error: "This receipt has already been added to the pantry.", captureId: 77 } }; const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json(); expect(body).toMatchObject({ kind: "grocery", status: "duplicate", capture_id: 77 }); }); it("pantry down → 502 error, batch marked error", async () => { modelReply = coles; pantryReply = { status: 500, body: { error: "nope" } }; const res = await intakePOST(multipart({ source: "coles" }, [pdf])); expect(res.status).toBe(502); expect((await res.json()).status).toBe("error"); }); }); // ─── C5 ─────────────────────────────────────────────────────────────────────── const slackPress = (action_id: string, value: unknown) => { const payload = { type: "block_actions", user: { id: "U1", name: "alice" }, response_url: "https://hooks.slack.com/r/1", actions: [{ action_id, value: JSON.stringify(value) }] }; const raw = new URLSearchParams({ payload: JSON.stringify(payload) }).toString(); return { headers: { get: (h: string) => (h === "x-ingest-token" ? "test-order-token" : "x") }, text: async () => raw } as unknown as NextRequest; }; const lastSlackUpdate = () => calls.filter((c) => c.url.includes("hooks.slack.com")).at(-1)?.body as { replace_original?: boolean; text: string; blocks?: unknown[] }; describe("Slack taps (C5)", () => { const pdf = { name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY }; it("intake_use_candidate enriches the chosen transaction and replaces the card", async () => { const near = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-17", amount: 19.9 }); const id = uuid(); await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf])); const res = await slackPOST(slackPress("intake_use_candidate", { batch_id: id, transaction_id: near })); expect(res.status).toBe(200); expect((await lineItemsOf(near)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]); expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: near }); expect(lastSlackUpdate()).toMatchObject({ replace_original: true, text: "Added 2 items to your $22.40 Uber Eats (Burgertory (Werribee)) order ✓" }); }); it("intake_create creates the transaction ING-10-style: manual, owner from shared_by, items attached; a second press is a no-op", async () => { const id = uuid(); await intakePOST(multipart({ source: "uber_eats", shared_by: "bob@example.test", batch_id: id }, [pdf])); expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0); await slackPOST(slackPress("intake_create", { batch_id: id })); const t = (await pool.query(`SELECT statement_id, owner_id, description, amount::text, category, transaction_date::text, source, source_ref FROM transactions`)).rows; expect(t).toEqual([{ statement_id: null, owner_id: otherId, description: "Order - Burgertory (Werribee)", amount: "22.40", category: "dining", transaction_date: "2026-08-19", source: "intake", source_ref: id }]); const em = (await pool.query(`SELECT source, platform, line_items FROM expense_metadata`)).rows[0]; expect(em.source).toBe("intake"); expect(em.platform).toBe("ubereats"); expect(em.line_items.map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]); expect(lastSlackUpdate().text).toMatch(/^Created a \$22\.40 Burgertory \(Werribee\) transaction with 2 items/); await slackPOST(slackPress("intake_create", { batch_id: id })); expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1); }); it("intake_create re-runs the match: an order booked between the card and the tap is enriched, not duplicated", async () => { const id = uuid(); const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json(); expect(body.status).toBe("candidates"); // The email/order bridge books the same purchase while the card sits in // Slack. The card's "no match" is minutes stale by the time it is pressed. const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); await slackPOST(slackPress("intake_create", { batch_id: id })); expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1); expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: txn }); expect((await lineItemsOf(txn)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]); expect(lastSlackUpdate().text).toMatch(/^Added 2 items/); }); it("two pending cards for one order book it ONCE", async () => { const a = uuid(); const b = uuid(); await intakePOST(multipart({ source: "uber_eats", batch_id: a }, [pdf])); await intakePOST(multipart({ source: "uber_eats", batch_id: b }, [{ name: "shot.png", type: "image/png", bytes: Buffer.from("shot") }])); expect((await batch(a)).status).toBe("candidates"); expect((await batch(b)).status).toBe("candidates"); await slackPOST(slackPress("intake_create", { batch_id: a })); await slackPOST(slackPress("intake_create", { batch_id: b })); expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1); expect((await batch(b)).transaction_id).toBe((await batch(a)).transaction_id); }); it("a sibling already resolved to the pantry board makes Create a duplicate, not a second row", async () => { // Two shares of one receipt, resolved out of order: nothing the second card // can match on, because the first one never produced a transaction. modelReply = { ...coles, merchant: "Romeo's Food Hall" }; const a = uuid(); const b = uuid(); await intakePOST(multipart({ source: "other", batch_id: b }, [{ name: "shot.png", type: "image/png", bytes: Buffer.from("shot") }])); await intakePOST(multipart({ source: "other", batch_id: a }, [{ name: "Receipt_18Aug2026.pdf", type: "application/pdf", bytes: COLES }])); expect((await batch(a)).status).toBe("ask"); await slackPOST(slackPress("intake_grocery", { batch_id: a })); expect((await batch(a)).status).toBe("forwarded_to_pantry"); await slackPOST(slackPress("intake_meal", { batch_id: b })); expect((await batch(b)).status).toBe("candidates"); await slackPOST(slackPress("intake_create", { batch_id: b })); expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0); expect(await batch(b)).toMatchObject({ status: "duplicate", capture_id: 901 }); expect(lastSlackUpdate().text).toMatch(/^Already handled/); }); it("two taps that land together still book one transaction, and neither reports an error", async () => { const id = uuid(); await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf])); await Promise.all([slackPOST(slackPress("intake_create", { batch_id: id })), slackPOST(slackPress("intake_create", { batch_id: id }))]); expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1); const texts = calls.filter((c) => c.url.includes("hooks.slack.com")).map((c) => (c.body as { text: string }).text); expect(texts.every((t) => !/Couldn't process/.test(t))).toBe(true); }); it("a foreign receipt is booked in its own currency, with no invented AUD figure", async () => { modelReply = { ...burgertory, currency: "USD" }; const id = uuid(); await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf])); await slackPOST(slackPress("intake_create", { batch_id: id })); const t = (await pool.query(`SELECT amount::text, amount_aud, foreign_currency_amount::text, foreign_currency_code FROM transactions`)).rows[0]; expect(t).toEqual({ amount: "22.40", amount_aud: null, foreign_currency_amount: "22.40", foreign_currency_code: "USD" }); expect(lastSlackUpdate().text).toMatch(/USD 22\.40/); // OrderDetails prefers expense_metadata.currency, so a hard-coded 'AUD' // here printed "$" on a USD order's receipt panel. expect((await pool.query(`SELECT currency FROM expense_metadata`)).rows[0].currency).toBe("USD"); }); it("the hold applies to the Create tap's re-match, not only to the upload path", async () => { modelReply = { ...burgertory, subtotal: null, items: burgertory.items.map((i) => ({ ...i, unit_price: null, line_total: null })) }; const id = uuid(); const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json(); expect(body.status).toBe("candidates"); // Booked WITH its items between the card and the tap. The re-match walked // past the guard routeFood applies, so the same upload destroyed the same // receipt depending on which button reached it. const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); const already = [{ qty: 1, description: "the itemised receipt already there", amount: 22.4 }]; await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [txn, JSON.stringify(already)]); await slackPOST(slackPress("intake_create", { batch_id: id })); expect(await lineItemsOf(txn)).toEqual(already); expect(await batch(id)).toMatchObject({ status: "held" }); expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1); }); it("two DIFFERENT batches for one purchase, tapped together, book it once", async () => { const a = uuid(); const b = uuid(); await intakePOST(multipart({ source: "uber_eats", batch_id: a }, [pdf])); await intakePOST(multipart({ source: "uber_eats", batch_id: b }, [{ name: "shot.png", type: "image/png", bytes: Buffer.from("shot") }])); // Their source_refs differ, so ON CONFLICT cannot save this; both taps // clear their match and duplicate checks before either inserts. await Promise.all([slackPOST(slackPress("intake_create", { batch_id: a })), slackPOST(slackPress("intake_create", { batch_id: b }))]); const rows = (await pool.query(`SELECT id FROM transactions`)).rows; expect(rows).toHaveLength(1); expect((await batch(a)).transaction_id).toBe(rows[0].id); expect((await batch(b)).transaction_id).toBe(rows[0].id); }); it("unknown shared_by falls back to the default owner — never a NULL owner", async () => { const id = uuid(); await intakePOST(multipart({ source: "uber_eats", shared_by: "nobody@example.test", batch_id: id }, [pdf])); await slackPOST(slackPress("intake_create", { batch_id: id })); expect((await pool.query(`SELECT owner_id FROM transactions`)).rows[0].owner_id).toBe(1); }); it("`other` + unrecognised merchant asks; intake_meal routes it as food and remembers the merchant", async () => { modelReply = { ...burgertory, merchant: "Romeo's Food Hall" }; const txn = await seedOrder({ merchant: "Romeo's Food Hall", date: "2026-08-19", amount: 22.4, platform: null }); const id = uuid(); const body = await (await intakePOST(multipart({ source: "other", batch_id: id }, [pdf]))).json(); expect(body).toMatchObject({ kind: "ask", status: "ask" }); expect(JSON.stringify(body.slack_blocks)).toContain("intake_grocery"); await slackPOST(slackPress("intake_meal", { batch_id: id })); expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: txn }); expect(await lineItemsOf(txn)).toHaveLength(2); expect((await pool.query(`SELECT kind FROM intake_merchant_kinds WHERE merchant_key = 'romeo s food hall'`)).rows[0].kind).toBe("food"); // The answer sticks: the next share of that merchant does not ask. const again = await (await intakePOST(multipart({ source: "other" }, [{ name: "IMG.png", type: "image/png", bytes: Buffer.from("png2") }]))).json(); expect(again.status).toBe("duplicate"); // same order — but it was routed, not asked modelReply = { ...burgertory, merchant: "Romeo's Food Hall", order_date: "2026-08-20" }; const next = await (await intakePOST(multipart({ source: "other" }, [{ name: "IMG3.png", type: "image/png", bytes: Buffer.from("png3") }]))).json(); expect(next).toMatchObject({ kind: "food", status: "candidates" }); }); it("intake_grocery forwards the stored original files to pantry", async () => { modelReply = { ...coles, merchant: "Romeo's Food Hall" }; const id = uuid(); await intakePOST(multipart({ source: "other", batch_id: id }, [{ name: "Receipt_18Aug2026.pdf", type: "application/pdf", bytes: COLES }])); expect((await batch(id)).status).toBe("ask"); await slackPOST(slackPress("intake_grocery", { batch_id: id })); const pantryCall = calls.find((c) => c.url.includes("/api/capture"))!.body as { intake: Record; files: string[] }; expect(pantryCall.files).toEqual(["Receipt_18Aug2026.pdf"]); expect(pantryCall.intake).toMatchObject({ batch_id: id, finance_action: "create" }); expect(await batch(id)).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901 }); expect((await pool.query(`SELECT kind FROM intake_merchant_kinds`)).rows[0].kind).toBe("grocery"); }); it("a tap on a resolved batch returns its card and writes nothing; an unknown batch says so", async () => { const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); const id = uuid(); await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf])); await slackPOST(slackPress("intake_create", { batch_id: id })); expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1); expect(lastSlackUpdate().text).toMatch(/^Added 2 items/); expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: txn }); await slackPOST(slackPress("intake_create", { batch_id: uuid() })); expect(lastSlackUpdate()).toMatchObject({ replace_original: false, text: "I can't find that upload any more." }); }); });