Files
finance-app/src/__tests__/unit/order-ingestion.test.ts
T
siddharthd fe104a9618
ci / lint-test (push) Successful in 53s
fix(orders): read both legs of an Uber payment
Uber Cash IS credits, and the payment line had become unreadable in the
newer layout: "Payments Uber Cash 10/17/25 8:50 PM A$54.87" carries a
timestamp between the label and the amount, and writes the currency as a
prefix. Both defeated the pattern, so credits_amount stayed null and the
order was filed as card-settled — sent looking for a card leg that does
not exist, found nothing, and left as an orphan with no transaction and
no card tail to match on. 118 captured messages sit in that state, and
every one of them is pre-cutover, so reading them correctly means I1
skips them rather than storing enrichment that points at nothing.

The card leg had the same blind spot, hidden behind the first: the gap
between the mask and the amount was [^\d]{0,40}, which a timestamp
breaks. While BOTH legs were unreadable a mixed payment still looked
consistent — the order read as card-settled for the full total. Fixing
only credits reads half an order, which validateOrderTotals correctly
refuses. Found exactly that way: four messages that validated before
began failing "payments sum to 34.92 but receipt states 43.60".

Legs are summed rather than taken first, because one order can be charged
in instalments and an instrument can carry no mask at all (PayPal). But a
leg that already equals the stated total IS the payment, not an
instalment: a Dubai trip prints an AED 17.67 authorisation and then the
AED 577.83 settled charge, and adding the hold overstates the trip. A
mixed credits+card order is unaffected — neither leg equals the total
there, which is why it needs summing.

A/B over all 776 captures: 468 parsed by both, zero change to any amount,
currency or existing card tail, 0 lost, 118 credits figures newly read.
Mutation-tested: dropping the exact-leg rule fails 2 tests, dropping the
Uber Cash read fails 7.
2026-07-28 21:59:35 +10:00

491 lines
19 KiB
TypeScript

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> = {}): MessageMeta => ({
messageId: "unit-1",
subject: "Order Confirmation for Siddharth from Mad Mex",
receivedAt: "2026-07-16T03:34:00Z",
sender: "DoorDash Order <no-reply@doordash.com>",
...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> = {}): 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("<html></html>", 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<string, MessageMeta> = 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:<id> 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 <noreply@uber.com>" }));
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 <noreply@uber.com>",
})
);
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 <noreply@uber.com>",
})
);
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 <noreply@uber.com>" })
);
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 <noreply@uber.com>",
})
);
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 - <email> 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);
});
});