Files
finance-app/src/__tests__/unit/intake-rules.test.ts
T
siddharthd 32a47bf8ee test(intake): rules, PDF text, C2 idempotency, enrich-vs-candidates, pantry hand-off, Slack taps
Unit: merchant pattern, header validation, reconciliation tolerance, verbatim
line items, one-call message shape, card action ids; unpdf over the two real
Uber Eats exports (Coles $85.74 / 15 lines, Burgertory $22.40 / Michael +
Joel). Integration: fetch stubbed at the LiteLLM and pantry boundaries so the
PDF path, persistence, match and writes run for real against personal_test.
intake-live.test.ts is opt-in (INTAKE_LIVE_TEST=1) and was run green against
cloud-budget on both PDFs.
2026-08-21 12:10:06 +10:00

225 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"');
});
});