import { describe, it, expect } from "vitest"; import { readFileSync } from "fs"; import { resolve } from "path"; import { parseOrderHTML, validateOrderTotals, resolveCategory, NotAReceiptError, orderDescription, type MessageMeta, type ParsedOrder, } from "../../lib/order-ingestion"; /** * Rewritten 2026-07-26. The previous unit suite exercised synthetic fixtures * built to satisfy the parser, so it passed while the parser could not read a * real email. These run against unmodified captured receipts. */ const dir = resolve(__dirname, "../fixtures/orders/real"); const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8"); const meta = (over: Partial = {}): MessageMeta => ({ messageId: "unit-1", subject: "Order Confirmation for Siddharth from Mad Mex", receivedAt: "2026-07-16T03:34:00Z", sender: "DoorDash Order ", ...over, }); describe("payment detection", () => { it("credits-only", () => { const p = parseOrderHTML(html("dd-01"), meta()); expect(p.payment.credits_amount).toBe(14.64); expect(p.payment.card_last4).toBeNull(); expect(p.payment.ambiguous).toBe(false); }); it("card-only produces no credits figure", () => { const p = parseOrderHTML( html("dd-27"), meta({ subject: "Order Confirmation for Siddharth from Subway" }) ); expect(p.payment.card_last4).toBe("8032"); expect(p.payment.credits_amount).toBeNull(); }); it("'and/or credits' is ambiguous, not silently credits", () => { // Regression: an earlier regex delimited the payment line on a double // space, which whitespace collapsing removes. Every card and mixed receipt // fell through to the credits branch — this one booked the whole $60.93 as // credits spend that never happened. const p = parseOrderHTML( html("dd-10"), meta({ subject: "Order Confirmation for Siddharth from Woolworths" }) ); expect(p.payment.ambiguous).toBe(true); expect(p.payment.card_last4).toBe("8032"); expect(p.payment.credits_amount).toBeNull(); }); }); describe("validateOrderTotals", () => { const base = (over: Partial = {}): ParsedOrder => ({ order_reference: "x", platform: "doordash", merchant_name: "M", order_datetime: "2026-03-01T00:00:00Z", currency: "AUD", payment: { credits_amount: 10, card_amount: null, card_last4: null, ambiguous: false }, totals: { subtotal: null, taxes: null, delivery_fee: null, service_fee: null, tip: null, discounts: null, total_charged: 10, }, line_items: [], route: [], is_family: false, flags: [], ...over, }); it("rejects a non-positive total", () => { const o = base(); o.totals.total_charged = 0; expect(validateOrderTotals(o).ok).toBe(false); }); it("rejects payments that do not account for the total", () => { const r = validateOrderTotals( base({ payment: { credits_amount: 5, card_amount: null, card_last4: null, ambiguous: false } }) ); expect(r.ok).toBe(false); expect(r.reason).toMatch(/payments sum/); }); it("accepts a matching total", () => { expect(validateOrderTotals(base()).ok).toBe(true); }); it("does not gate on DoorDash's non-reconciling fee breakdown", () => { const p = parseOrderHTML(html("dd-01"), meta()); expect(p.totals.subtotal).toBe(22.10); expect(p.totals.discounts).toBe(24.09); // 22.10 + 1.99 — genuinely printed expect(validateOrderTotals(p, html("dd-01")).ok).toBe(true); }); }); describe("resolveCategory", () => { const o = (merchant: string, platform: ParsedOrder["platform"] = "doordash") => ({ merchant_name: merchant, platform }) as ParsedOrder; it("maps grocers to groceries", () => { expect(resolveCategory(o("Woolworths"))).toBe("groceries"); expect(resolveCategory(o("ALDI"))).toBe("groceries"); expect(resolveCategory(o("GLOMARK Kandana", "ubereats"))).toBe("groceries"); }); it("maps restaurants to dining rather than 'other'", () => { // The earlier six-merchant allowlist sent every one of these to `other`. for (const m of ["Carl's Jr.", "Taco Bell", "Chilli India", "Oporto", "Schnitz", "Souvlaki GR"]) { expect(resolveCategory(o(m))).toBe("dining"); } }); it("maps rides to transport", () => { expect(resolveCategory(o("Uber Trip", "uber"))).toBe("transport"); }); }); describe("parse guards", () => { it("throws on a body too short to be a receipt", () => { expect(() => parseOrderHTML("", meta())).toThrow(NotAReceiptError); }); it("throws rather than inventing a platform", () => { expect(() => parseOrderHTML(html("dd-01"), meta({ subject: "Newsletter", sender: "someone@example.com" })) ).toThrow(/platform/i); }); }); describe("order_reference anchoring", () => { it("takes Uber's tripReference, not the first UUID in the document", () => { const p = parseOrderHTML( html("ue-00"), meta({ subject: "Your Wednesday afternoon order with Uber Eats", sender: "uber.com" }) ); // The UUID the PDF redirect actually resolves to for this receipt. expect(p.order_reference).toBe("34d6b4ee-da8f-5029-8d14-bd359617c8e9"); expect(p.flags).not.toContain("order_uuid_ambiguous"); }); it("is stable across repeated parses of the same message", () => { const m = meta({ subject: "Your Wednesday afternoon order with Uber Eats", sender: "uber.com" }); const a = parseOrderHTML(html("ue-00"), m).order_reference; const b = parseOrderHTML(html("ue-00"), m).order_reference; expect(a).toBe(b); }); it("flags ambiguity only when several UUIDs and no anchor", () => { // Strip the anchor from a receipt that carries multiple UUIDs (ue-09 has 6). const stripped = html("ue-09").replace(/tripReference/gi, "notTheAnchor"); const p = parseOrderHTML( stripped, meta({ subject: "[Family] Your Sunday evening order with Uber Eats", sender: "uber.com" }) ); expect(p.flags).toContain("order_uuid_ambiguous"); }); }); describe("mixed Uber payment (issuer-named card leg)", () => { it("captures both legs when the card is labelled by issuer, not brand", () => { // Real receipt: Uber Cash $1.17 + Westpac ••••8032 $15.33 = $16.50. // A brand allowlist (Visa|MasterCard|Amex) misses "Westpac" and drops the // card half, leaving payments that do not account for the total. const p = parseOrderHTML( readFileSync(resolve(dir, "ue-mixed.html"), "utf-8"), meta({ subject: "Your Friday morning order with Uber Eats", sender: "uber.com", receivedAt: "2026-01-09T09:26:44Z" }) ); expect(p.totals.total_charged).toBeCloseTo(16.50, 2); expect(p.payment.credits_amount).toBeCloseTo(1.17, 2); expect(p.payment.card_amount).toBeCloseTo(15.33, 2); expect(p.payment.card_last4).toBe("8032"); expect(validateOrderTotals(p).ok).toBe(true); }); }); describe("Uber route (pick-up / delivery)", () => { const uber = (f: string, subject = "Your Wednesday order with Uber Eats") => parseOrderHTML(html(f), meta({ subject, sender: "uber.com" })); it("reads both stops with their times, as printed", () => { const p = uber("ue-00"); expect(p.route).toEqual([ { label: "Pick-up", time: "1:20 pm", address: "197 Watton St, Werribee VIC 3030, Australia" }, { label: "Delivery", time: "1:40 pm", address: "19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia" }, ]); }); it("de-duplicates the block Uber renders twice", () => { // The receipt emits the whole address section a second time for narrow // screens. Without de-duplication every trip has four stops. expect(uber("ue-00").route).toHaveLength(2); expect(uber("ue-26").route).toHaveLength(2); }); it("keeps the receipt's own wording rather than normalising it", () => { // Uber is not internally consistent: "Pick-up" on some receipts, // "Pickup" on others. Inventing a canonical spelling would hide that a // template changed. expect(uber("ue-mixed", "Your Friday morning order with Uber Eats").route[0].label).toBe("Pickup"); }); it("works on an international receipt", () => { const p = uber("ue-26"); expect(p.route[1].address).toContain("Luzern, Switzerland"); }); it("DoorDash has no route — its receipts carry no addresses", () => { expect(parseOrderHTML(html("dd-01"), meta()).route).toEqual([]); }); }); describe("Uber line items", () => { it("itemises a grocery order, binding qty/title/amount by item id", () => { const p = parseOrderHTML( html("ue-09"), meta({ subject: "Your Sunday evening order with Uber Eats", sender: "uber.com" }) ); expect(p.line_items).toHaveLength(5); expect(p.line_items[0]).toMatchObject({ qty: 1, description: "Highland Brewing MILK FULL CREAM U H T 900ML", amount: 440, }); // A sold-out item prints 0.00 and is kept: it is why the total is lower // than what was ordered, and dropping it makes the receipt unexplainable. expect(p.line_items.map((i) => i.amount)).toContain(0); }); it("a restaurant order legitimately has none", () => { // Uber itemises groceries only; a restaurant receipt states a total and // nothing else. Empty here is the receipt, not a parse failure — so it // must not raise no_line_items_parsed either. const p = parseOrderHTML( html("ue-00"), meta({ subject: "Your Wednesday order with Uber Eats", sender: "uber.com" }) ); expect(p.line_items).toEqual([]); expect(p.flags).not.toContain("no_line_items_parsed"); }); }); /** * Uber trips. Captured 2026-06 via a dry-run against the real mailbox after the * user pointed out that only *overseas* rides go on a card — local rides are * paid with credits, which puts them in the same class as delivery orders. */ describe("Uber trips", () => { const utMeta: Record = JSON.parse( readFileSync(resolve(dir, "ut-meta.json"), "utf-8") ); const trip = (f: string) => parseOrderHTML(html(f), utMeta[f]); it("a local trip is credits-funded", () => { const p = trip("ut-00"); expect(p.platform).toBe("uber"); expect(p.currency).toBe("AUD"); expect(p.totals.total_charged).toBeCloseTo(84.78, 2); expect(p.payment.credits_amount).toBeCloseTo(84.78, 2); expect(p.payment.card_last4).toBeNull(); expect(validateOrderTotals(p).ok).toBe(true); }); it("an overseas trip is card-settled", () => { const p = trip("ut-01"); expect(p.currency).toBe("NZD"); expect(p.totals.total_charged).toBeCloseTo(55.51, 2); expect(p.payment.card_amount).toBeCloseTo(55.51, 2); expect(p.payment.card_last4).toBe("3893"); }); it("labels the two ends of a trip, which the receipt does not", () => { // Delivery receipts write "1:20 pm - Pick-up"; trip receipts print the time // alone. The naive split put the time in `label` and left `time` null. const p = trip("ut-00"); expect(p.route).toEqual([ { label: "Pick-up", time: "7:32 pm", address: "Terminal 2, Melbourne Airport (MEL), Tullamarine VIC 3045, Australia", }, { label: "Drop-off", time: "8:10 pm", address: "19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia", }, ]); }); it("rejects the charge summary Uber sends before the receipt", () => { // Uber sends two mails per trip with the same subject and the same total. // The first says "This is not a payment receipt" and carries no // tripReference, so order_reference would fall back to msg: and I7 // could not dedupe it — every trip would be recorded twice. expect(() => trip("ut-summary")).toThrow(NotAReceiptError); }); }); describe("orderDescription", () => { it("names the platform", () => { expect(orderDescription("doordash", "Mad Mex")).toBe("Order - Mad Mex (DoorDash)"); expect(orderDescription("ubereats", "Coles (Wyndham Vale)")).toBe( "Order - Coles (Wyndham Vale) (Uber Eats)" ); }); it("does not restate a platform the merchant already names", () => { // A trip's merchant is literally "Uber Trip"; "(Uber)" after it says // nothing. What identifies a trip is its addresses, and those live in the // Order details panel. expect(orderDescription("uber", "Uber Trip")).toBe("Order - Uber Trip"); }); });