ci / lint-test (push) Successful in 45s
Six of the nine fixes have a test that fails against the previous tree: the metadata row's currency, the Create-path hold, two concurrent batches booking once, mrkdwn escaping including plain_text, the escaped currency, and candidate-row currency. Three do not, and the reason is the same each time — the window needs a crash or a lost race that a test cannot force. Asserted instead: a stale metadataId falls back to the live row rather than reporting a phantom write; two writes racing to create a metadata row both resolve to one; the create path records a baseline at all. All three passed before as well.
794 lines
50 KiB
TypeScript
794 lines
50 KiB
TypeScript
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<Response>;
|
|
let lineItemsPUT: (req: NextRequest, ctx: { params: Promise<{ id: string }> }) => Promise<Response>;
|
|
let slackPOST: (req: NextRequest) => Promise<Response>;
|
|
let orderGET: (req: NextRequest, ctx: { params: Promise<{ id: string }> }) => Promise<Response>;
|
|
|
|
/** 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<string, string>, 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<Record<string, unknown>>()),
|
|
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<string, unknown>; files: string[]; headers: Record<string, string> };
|
|
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<string, unknown> };
|
|
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<string, unknown> };
|
|
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<string, unknown>; 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." });
|
|
});
|
|
});
|