diff --git a/src/__tests__/fixtures/intake/ubereats-burgertory-2026-08-19.pdf b/src/__tests__/fixtures/intake/ubereats-burgertory-2026-08-19.pdf new file mode 100644 index 0000000..c5eb431 Binary files /dev/null and b/src/__tests__/fixtures/intake/ubereats-burgertory-2026-08-19.pdf differ diff --git a/src/__tests__/fixtures/intake/ubereats-coles-2026-08-18.pdf b/src/__tests__/fixtures/intake/ubereats-coles-2026-08-18.pdf new file mode 100644 index 0000000..0726e13 Binary files /dev/null and b/src/__tests__/fixtures/intake/ubereats-coles-2026-08-18.pdf differ diff --git a/src/__tests__/integration/intake.test.ts b/src/__tests__/integration/intake.test.ts new file mode 100644 index 0000000..2297221 --- /dev/null +++ b/src/__tests__/integration/intake.test.ts @@ -0,0 +1,513 @@ +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; + +/** 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; +} +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, + })); + ({ 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")); + 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("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", async () => { + 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"); + }); + + 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("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("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." }); + }); +}); diff --git a/src/__tests__/unit/intake-live.test.ts b/src/__tests__/unit/intake-live.test.ts new file mode 100644 index 0000000..0ae6dfb --- /dev/null +++ b/src/__tests__/unit/intake-live.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { extractPdfText, liteLLMExtractor } from "../../lib/intake-extract"; +import { reconcileItems, toLineItems, validateHeader } from "../../lib/intake-rules"; + +/** + * Opt-in: the real model over the real Burgertory export. + * + * INTAKE_LIVE_TEST=1 LITELLM_BASE_URL=http://localhost:4200 LITELLM_KEY=... npm test + * + * Skipped otherwise — CI has no LiteLLM, and a unit run must not depend on one. + * What it proves is the thing the mock cannot: that `cloud-budget` keeps the + * names verbatim ("Michael", "Joel", not "Burger") under the order-item prompt. + */ +const live = process.env.INTAKE_LIVE_TEST === "1" && !!process.env.LITELLM_BASE_URL && !!process.env.LITELLM_KEY; + +describe.skipIf(!live)("live extraction via LiteLLM (opt-in)", () => { + it("Burgertory PDF: header exact, two items named verbatim, reconciles", async () => { + const text = await extractPdfText(readFileSync(resolve(__dirname, "../fixtures/intake/ubereats-burgertory-2026-08-19.pdf"))); + const extracted = await liteLLMExtractor()([{ kind: "text", text, name: "Receipt_19Aug2026.pdf" }]); + const header = validateHeader(extracted); + expect(header.ok && header.header).toEqual({ merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4 }); + const items = toLineItems(extracted.items); + expect(items.map((i) => i.description)).toEqual(["Michael", "Joel"]); + expect(reconcileItems(extracted).ok).toBe(true); + }, 120_000); + + it("Coles PDF: header exact, 15 items", async () => { + const text = await extractPdfText(readFileSync(resolve(__dirname, "../fixtures/intake/ubereats-coles-2026-08-18.pdf"))); + const extracted = await liteLLMExtractor()([{ kind: "text", text, name: "Receipt_18Aug2026.pdf" }]); + const header = validateHeader(extracted); + expect(header.ok && header.header).toEqual({ merchant: "Coles (Wyndham Vale)", order_date: "2026-08-18", total: 85.74 }); + expect(toLineItems(extracted.items)).toHaveLength(15); + expect(reconcileItems(extracted).ok).toBe(true); + }, 120_000); +}); diff --git a/src/__tests__/unit/intake-rules.test.ts b/src/__tests__/unit/intake-rules.test.ts new file mode 100644 index 0000000..5e5a115 --- /dev/null +++ b/src/__tests__/unit/intake-rules.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { + classifyInput, + merchantKey, + merchantMatches, + platformForSource, + reconcileItems, + resolveMerchantKind, + toLineItems, + validateHeader, + type ExtractedOrder, +} from "../../lib/intake-rules"; +import { buildMessageContent, extractPdfText, parseExtraction, EXTRACT_RESPONSE_FORMAT } from "../../lib/intake-extract"; +import { askCard, candidatesCard, enrichedCard } from "../../lib/intake-blocks"; + +/** + * The intake router's pure rules, against the strings real receipts print. + * The two PDFs are the user's own Uber Eats exports (plan §3): a Coles grocery + * run and a Burgertory meal whose burgers are named after people. + */ + +const fixtures = resolve(__dirname, "../fixtures/intake"); +const pdf = (name: string) => readFileSync(resolve(fixtures, name)); + +describe("classifyInput — input type picks the extractor", () => { + it("PDF by MIME or by name", () => { + expect(classifyInput({ type: "application/pdf", name: "x" })).toBe("pdf"); + expect(classifyInput({ type: "application/octet-stream", name: "Receipt_18Aug2026.pdf" })).toBe("pdf"); + }); + it("images and text", () => { + expect(classifyInput({ type: "image/png", name: "IMG_1.png" })).toBe("image"); + expect(classifyInput({ type: "", name: "photo.HEIC" })).toBe("image"); + expect(classifyInput({ type: "text/plain", name: "" })).toBe("text"); + }); + it("declines what it cannot read", () => { + expect(classifyInput({ type: "video/mp4", name: "clip.mp4" })).toBe("unsupported"); + expect(classifyInput({ type: "application/zip", name: "a.zip" })).toBe("unsupported"); + }); +}); + +describe("platformForSource", () => { + it("maps the tap to expense_metadata.platform vocabulary", () => { + expect(platformForSource("uber_eats")).toBe("ubereats"); + expect(platformForSource("doordash")).toBe("doordash"); + expect(platformForSource("coles")).toBeNull(); + expect(platformForSource("other")).toBeNull(); + }); +}); + +describe("merchant pattern — grocery | food | ask", () => { + it("the pattern list is a substring match on the normalised name", () => { + expect(resolveMerchantKind("uber_eats", "Coles (Wyndham Vale)")).toBe("grocery"); + expect(resolveMerchantKind("other", "Woolworths Group Limited")).toBe("grocery"); + expect(resolveMerchantKind("uber_eats", "WOOLIES METRO")).toBe("grocery"); + expect(resolveMerchantKind("doordash", "ALDI Werribee")).toBe("grocery"); + expect(resolveMerchantKind("uber_eats", "IGA Xpress")).toBe("grocery"); + expect(resolveMerchantKind("uber_eats", "FoodWorks Manor Lakes")).toBe("grocery"); + expect(resolveMerchantKind("uber_eats", "Costco Wholesale")).toBe("grocery"); + }); + it("a delivery order that is not a grocer is a meal", () => { + expect(resolveMerchantKind("uber_eats", "Burgertory (Werribee)")).toBe("food"); + expect(resolveMerchantKind("doordash", "Mad Mex")).toBe("food"); + }); + it("an empty or unknown merchant asks", () => { + expect(resolveMerchantKind("uber_eats", null)).toBe("ask"); + expect(resolveMerchantKind("uber_eats", " ")).toBe("ask"); + expect(resolveMerchantKind("doordash", "Unknown")).toBe("ask"); + }); + it("the Coles/Woolworths taps are ground truth", () => { + expect(resolveMerchantKind("coles", "Romeo's Food Hall")).toBe("grocery"); + expect(resolveMerchantKind("woolworths", null)).toBe("grocery"); + }); + it("`other` with an unrecognised merchant asks; a remembered answer sticks", () => { + expect(resolveMerchantKind("other", "Romeo's Food Hall")).toBe("ask"); + expect(resolveMerchantKind("other", "Romeo's Food Hall", "grocery")).toBe("grocery"); + expect(resolveMerchantKind("uber_eats", "Romeo's Food Hall", "grocery")).toBe("grocery"); + }); + it("merchantKey folds case and punctuation; merchantMatches allows containment only", () => { + expect(merchantKey("Coles (Wyndham Vale)")).toBe("coles wyndham vale"); + expect(merchantMatches("Coles (Wyndham Vale)", "coles wyndham vale")).toBe(true); + expect(merchantMatches("Coles (Wyndham Vale)", "Coles")).toBe(true); + expect(merchantMatches("Order - Coles (Wyndham Vale)", "Coles (Wyndham Vale)")).toBe(true); + expect(merchantMatches("Burgertory (Werribee)", "Coles")).toBe(false); + expect(merchantMatches(null, "Coles")).toBe(false); + }); +}); + +describe("validateHeader — nothing is written from a half-read upload", () => { + const base: ExtractedOrder = { merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, items: [] }; + it("accepts a complete header and rounds the total", () => { + const r = validateHeader({ ...base, total: 22.404 }); + expect(r.ok && r.header.total).toBe(22.4); + }); + it("missing total", () => { + const r = validateHeader({ ...base, total: null }); + expect(r.ok).toBe(false); + expect(!r.ok && r.missing).toEqual(["total"]); + }); + it("missing or malformed date", () => { + expect(validateHeader({ ...base, order_date: null }).ok).toBe(false); + expect(validateHeader({ ...base, order_date: "19/8/26" }).ok).toBe(false); + }); + it("a missing merchant is NOT a missing header — it becomes an ask", () => { + const r = validateHeader({ ...base, merchant: null }); + expect(r.ok && r.header.merchant).toBeNull(); + }); +}); + +describe("reconcileItems — the missing-screenshot check", () => { + it("reconciles to the printed item subtotal, not the total: fees and promotions are not items", () => { + // The real Coles export: 15 lines summing $118.87 against a printed $118.94 subtotal and an $85.74 total. + const r = reconcileItems({ + merchant: "Coles", order_date: "2026-08-18", total: 85.74, subtotal: 118.94, + items: [{ quantity: 1, description: "a", line_total: 118.87 }], + }); + expect(r.ok).toBe(true); + expect(r.basis).toBe("subtotal"); + }); + it("falls back to the total when no subtotal is printed", () => { + const r = reconcileItems({ merchant: "x", order_date: "2026-08-19", total: 28, items: [{ quantity: 1, description: "Michael", line_total: 14 }, { quantity: 1, description: "Joel", line_total: 14 }] }); + expect(r.ok).toBe(true); + expect(r.basis).toBe("total"); + }); + it("holds when the lines fall short — a shot is missing", () => { + const r = reconcileItems({ merchant: "x", order_date: "2026-08-19", total: 28, subtotal: 28, items: [{ quantity: 1, description: "Michael", line_total: 14 }] }); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/14\.00.*28\.00/); + }); + it("tolerance is $1 or 2%, whichever is larger", () => { + expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, subtotal: 10, items: [{ quantity: 1, description: "a", line_total: 9.05 }] }).ok).toBe(true); + expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, subtotal: 10, items: [{ quantity: 1, description: "a", line_total: 8.9 }] }).ok).toBe(false); + expect(reconcileItems({ merchant: "x", order_date: "d", total: 200, subtotal: 200, items: [{ quantity: 1, description: "a", line_total: 196.5 }] }).ok).toBe(true); + expect(reconcileItems({ merchant: "x", order_date: "d", total: 200, subtotal: 200, items: [{ quantity: 1, description: "a", line_total: 195 }] }).ok).toBe(false); + }); + it("derives a line from quantity × unit price when no line total is printed", () => { + const r = reconcileItems({ merchant: "x", order_date: "d", total: 13.2, subtotal: 13.2, items: [{ quantity: 3, description: "Coke", unit_price: 4.4 }] }); + expect(r.ok).toBe(true); + expect(r.items_sum).toBe(13.2); + }); + it("no items at all is held; priceless lines (pasted text) are not", () => { + expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, items: [] }).ok).toBe(false); + expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, items: [{ quantity: 1, description: "a" }] }).ok).toBe(true); + }); +}); + +describe("toLineItems — names verbatim, options kept", () => { + it("keeps a burger called Michael called Michael", () => { + const out = toLineItems([{ quantity: 1, description: "Michael", unit_price: 14, line_total: 14, options: ["Milk Bun"] }]); + expect(out).toEqual([{ qty: 1, description: "Michael", amount: 14, unit_price: 14, options: ["Milk Bun"] }]); + }); + it("drops nameless lines and defaults quantity to 1", () => { + const out = toLineItems([{ quantity: null, description: " ", line_total: 1 }, { quantity: null, description: "Cucumbers Continental", unit_price: 2.9 }]); + expect(out).toEqual([{ qty: 1, description: "Cucumbers Continental", amount: 2.9, unit_price: 2.9 }]); + }); +}); + +describe("extraction request and reply", () => { + it("one call: the prompt first, then every part in the order shared", () => { + const content = buildMessageContent([ + { kind: "image", mime: "image/png", bytes: Buffer.from("a"), name: "1.png" }, + { kind: "text", text: "Total $22.40", name: "r.pdf" }, + { kind: "image", mime: "image/jpeg", bytes: Buffer.from("b"), name: "2.jpg" }, + ]) as { type: string; text?: string; image_url?: { url: string } }[]; + expect(content[0].text).toMatch(/VERBATIM/); + const images = content.filter((c) => c.type === "image_url").map((c) => c.image_url!.url); + expect(images).toEqual(["data:image/png;base64,YQ==", "data:image/jpeg;base64,Yg=="]); + expect(content.findIndex((c) => c.text?.includes("Total $22.40"))).toBeLessThan(content.findIndex((c) => c.image_url?.url.endsWith("Yg=="))); + expect(EXTRACT_RESPONSE_FORMAT.json_schema.strict).toBe(true); + }); + it("parses a fenced reply and coerces numbers", () => { + const r = parseExtraction('```json\n{"merchant":"Burgertory (Werribee)","order_date":"2026-08-19","total":"22.40","subtotal":28,"currency":null,"items":[{"quantity":1,"description":"Michael","unit_price":14,"line_total":14,"options":["Milk Bun"]}],"adjustments":[{"label":"Promotion","amount":-8}]}\n```'); + expect(r.total).toBe(22.4); + expect(r.currency).toBe("AUD"); + expect(r.items[0]).toEqual({ quantity: 1, description: "Michael", unit_price: 14, line_total: 14, options: ["Milk Bun"] }); + expect(r.adjustments).toEqual([{ label: "Promotion", amount: -8 }]); + }); +}); + +describe("PDF text — the Uber Eats export is born-digital text", () => { + it("Coles (Wyndham Vale) · 2026-08-18 · $85.74, 15 lines, weighed and substituted", async () => { + const text = await extractPdfText(pdf("ubereats-coles-2026-08-18.pdf")); + expect(text).toContain("Here's your receipt for Coles (Wyndham Vale)."); + expect(text).toContain("18 Aug, 2026"); + expect(text).toContain("Total $85.74"); + expect(text).toContain("Item Subtotal $118.94"); + expect(text).toContain("Final 1.16 kg $11.00/kg"); + expect(text).toContain("Replaced Coles Beef Slow Cook Chuck Roast"); + // 15 item lines: 13 priced per piece, 2 weighed per kg. + expect(text.match(/\$\d+\.\d\d\/pc$/gm)?.length).toBe(13); + expect(text.match(/\$\d+\.\d\d\/kg$/gm)?.length).toBe(2); + }); + it("Burgertory (Werribee) · 2026-08-19 · $22.40, burgers named Michael and Joel", async () => { + const text = await extractPdfText(pdf("ubereats-burgertory-2026-08-19.pdf")); + expect(text).toContain("Here's your receipt for Burgertory (Werribee)."); + expect(text).toContain("Total $22.40"); + expect(text).toContain("1 Michael $14.00"); + expect(text).toContain("1 Joel $14.00"); + expect(text).toContain("Item Subtotal $28.00"); + }); +}); + +describe("cards carry the C5 action ids and JSON values", () => { + const c = { batchId: "11111111-2222-4333-8444-555555555555", merchant: "Burgertory (Werribee)", total: 22.4, orderDate: "2026-08-19", platform: "ubereats", itemCount: 2 }; + it("candidates: one Use-this per candidate plus an explicit Create", () => { + const { blocks } = candidatesCard(c, [{ transaction_id: 42, description: "Order - Burgertory (Werribee)", transaction_date: "2026-08-17", amount: "19.90" }]); + const buttons = JSON.stringify(blocks); + expect(buttons).toContain('"action_id":"intake_use_candidate"'); + expect(buttons).toContain('"action_id":"intake_create"'); + const use = (blocks[1] as { accessory: { value: string } }).accessory.value; + expect(JSON.parse(use)).toEqual({ batch_id: c.batchId, transaction_id: 42 }); + }); + it("enriched: the confirmation text and a View link", () => { + const { text, blocks } = enrichedCard({ ...c, transactionId: 7 }); + expect(text).toBe("Added 2 items to your $22.40 Uber Eats (Burgertory (Werribee)) order ✓"); + expect(JSON.stringify(blocks)).toContain("/transactions?q=Burgertory"); + }); + it("ask: Groceries / A meal", () => { + const ids = JSON.stringify(askCard({ ...c, platform: null }).blocks); + expect(ids).toContain('"action_id":"intake_grocery"'); + expect(ids).toContain('"action_id":"intake_meal"'); + }); +});