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 restaurant, not the courier", () => { // The platform is provenance and lives in the Order details panel, which // already renders expense_metadata.platform. Putting it here fragmented the // merchant — the same restaurant read differently depending on who carried // the bag, which nobody rating the food cares about. expect(orderDescription("doordash", "Mad Mex")).toBe("Order - Mad Mex"); expect(orderDescription("ubereats", "Coles (Wyndham Vale)")).toBe( "Order - Coles (Wyndham Vale)" ); }); it("leaves a merchant that already names the platform alone", () => { // A trip's merchant is literally "Uber Trip". What identifies a trip is its // addresses, and those live in the Order details panel. expect(orderDescription("uber", "Uber Trip")).toBe("Order - Uber Trip"); }); it("gives the same description whichever platform delivered it", () => { // The regression this whole change exists to prevent. expect(orderDescription("doordash", "TEG Kebabs & Biryani")).toBe( orderDescription("ubereats", "TEG Kebabs & Biryani") ); }); }); /** * Two parse failures that between them accounted for 218 of the 287 unreadable * messages in the 776-message capture set. Neither was an "old template": the * Uber one fails on current 2024-2025 mail, and the DoorDash one fails on every * order paid from credits, in every year. */ describe("currency notations Uber actually sends", () => { const uber = (f: string, subject: string) => parseOrderHTML(html(f), meta({ subject, sender: "Uber Receipts " })); it("reads a symbol-prefixed Australian total", () => { // "Total A$54.87". The old pattern allowed a 3-letter ISO code or a bare // "$", so A$ — which is what Uber sends for ordinary domestic orders — // matched neither and 98 of 275 Uber Eats mails were unreadable. const o = uber("ue-aud-prefix", "Your Friday evening order with Uber Eats"); expect(o.totals.total_charged).toBe(54.87); expect(o.currency).toBe("AUD"); }); it("reads NZ$ as New Zealand dollars, not Australian", () => { // The prefix is the only thing distinguishing them, and getting it wrong // books a Queenstown dinner at the wrong rate rather than failing loudly. const o = uber("ue-nzd-prefix", "Your Saturday evening order with Uber Eats"); expect(o.totals.total_charged).toBe(22.83); expect(o.currency).toBe("NZD"); }); it("reads a bare rupee symbol on a trip", () => { const o = uber("ut-inr-symbol", "Your Friday evening trip with Uber"); expect(o.totals.total_charged).toBe(622.74); expect(o.currency).toBe("INR"); }); it("still reads the space-separated ISO form", () => { // The [Family] LKR receipts depend on this and must not regress. const o = uber("ut-nzd-prefix", "Your Sunday afternoon trip with Uber"); expect(o.totals.total_charged).toBe(10.83); expect(o.currency).toBe("NZD"); }); }); describe("credits-funded orders are orders", () => { const credits = () => parseOrderHTML( html("dd-credits-zero"), meta({ subject: "Order Confirmation for Siddharth from Chilli India" }) ); it("records the subtotal when the card was charged nothing", () => { // The receipt says "Subtotal $71.86 ... Total Charged $0.00" — truthfully, // because credits covered it. Reading that as a $0 order threw away the // credit-funded spend this pipeline exists to surface. const o = credits(); expect(o.totals.total_charged).toBe(71.86); expect(o.totals.subtotal).toBe(71.86); expect(o.flags).toContain("credits_funded_zero_charge"); }); it("books the amount as credits, not as a card charge", () => { const o = credits(); expect(o.payment.credits_amount).toBe(71.86); expect(o.payment.card_amount).toBeNull(); expect(o.payment.card_last4).toBeNull(); }); it("passes validation instead of being rejected as non-positive", () => { // Both stated totals are $0.00 and agree, so the header cross-check has to // stand down here or it rejects the very figure the parser overrode. const o = credits(); expect(validateOrderTotals(o, html("dd-credits-zero"))).toEqual({ ok: true }); }); it("does not invent a total when the receipt never says credits", () => { // The guard that keeps this from becoming "any zero total borrows the // subtotal" — a genuinely empty receipt must still fail. const notCredits = html("dd-credits-zero").replace(/Paid with/gi, "Charged to"); const o = parseOrderHTML(notCredits, meta({ subject: "Order Confirmation for Siddharth from Chilli India" })); expect(o.totals.total_charged).toBe(0); expect(validateOrderTotals(o, notCredits).ok).toBe(false); }); }); describe("Uber Cash is credits, not a card", () => { it("reads a payment line carrying a timestamp and a prefixed currency", () => { // "Payments Uber Cash 10/17/25 8:50 PM A$54.87". The old pattern allowed // neither the timestamp nor the A$ prefix, so credits_amount stayed null // and the order was filed as card-settled — sent looking for a card leg // that does not exist, and left as an orphan with nothing to match on. const o = parseOrderHTML( html("ue-aud-prefix"), meta({ subject: "Your Friday evening order with Uber Eats", sender: "Uber Receipts ", }) ); expect(o.payment.credits_amount).toBe(54.87); expect(o.payment.card_last4).toBeNull(); expect(o.payment.ambiguous).toBe(false); }); it("still reads the plain form, where the timestamp follows the amount", () => { // "Uber Cash $25.33 22/7/26 1:41 pm" — the older layout the widened // pattern must not break. const o = parseOrderHTML( html("ue-00"), meta({ subject: "Your order with Uber Eats", sender: "Uber Receipts ", }) ); expect(o.payment.credits_amount).toBe(25.33); }); it("still reads the card leg of a mixed payment", () => { // "Uber Cash $1.17 ... Westpac ••••8032 $15.33" — the credits half must // not swallow the card half. const o = parseOrderHTML( html("ue-mixed"), meta({ subject: "Your order with Uber Eats", sender: "Uber Receipts " }) ); expect(o.payment.credits_amount).toBe(1.17); expect(o.payment.card_last4).toBe("8032"); }); }); describe("payment legs", () => { const trip = (f: string) => parseOrderHTML( html(f), meta({ subject: "Your Wednesday afternoon trip with Uber", sender: "Uber Receipts ", }) ); it("does not add a superseded authorisation to the settled charge", () => { // "Citi Prestige ••••0253 AED 17.67" then the same card "AED 577.83", // against a stated total of 577.83. The first is a hold, not a part // payment; adding it overstates the trip by the held amount. const o = trip("ut-reauth"); expect(o.totals.total_charged).toBe(577.83); expect(o.payment.card_amount).toBe(577.83); expect(o.payment.card_last4).toBe("0253"); expect(validateOrderTotals(o, html("ut-reauth")).ok).toBe(true); }); it("adds the legs of a genuinely split payment", () => { // "PayPal - A$78.41" + "Uber Cash A$6.85" = 85.26. Neither leg // equals the total, so both are real and both must be counted — and the // PayPal leg carries no card mask to anchor on. const o = trip("ut-paypal"); expect(o.totals.total_charged).toBe(85.26); expect(o.payment.credits_amount).toBe(6.85); expect(o.payment.card_amount).toBe(78.41); expect(validateOrderTotals(o, html("ut-paypal")).ok).toBe(true); }); });