/** * Order receipt parsing, written against REAL captured emails. * * History: the first version of this parser was written against synthetic * fixtures that were shaped to match the code rather than the mail. It invented * a layout that DoorDash does not * send, derived order_reference from Math.random(), and read the order date * from a `Date: YYYY-MM-DD` string that appears in no real message. All of it * passed its tests. This version is built from 36 real DoorDash and 29 real * Uber Eats receipts; see docs in memory-bank/order-ingestion-implementation.md. * * Governing rule: parse or throw. Never fabricate a value that the mail did not * state (I9). A caller that gets a ParsedOrder back can trust every field in it. */ export interface LineItem { qty: number; description: string; amount: number; options?: string[]; } /** * A stop on the receipt's map: pick-up, delivery, or (for a trip) the ride's * start and end. Uber prints these for every order under `Order details`. */ export interface RoutePoint { /** "Pick-up" / "Delivery" — whatever the receipt itself calls it. */ label: string; /** Local time as printed, e.g. "1:20 pm". No date; the receipt gives none. */ time: string | null; address: string; } export interface PaymentBreakdown { credits_amount: number | null; card_amount: number | null; card_last4: string | null; /** true when the mail states a payment method it does not fully disaggregate. */ ambiguous: boolean; } export interface OrderTotals { subtotal: number | null; taxes: number | null; delivery_fee: number | null; service_fee: number | null; tip: number | null; discounts: number | null; total_charged: number; } export interface ParsedOrder { order_reference: string; platform: "doordash" | "ubereats" | "uber"; merchant_name: string; order_datetime: string; currency: string; payment: PaymentBreakdown; totals: OrderTotals; line_items: LineItem[]; /** Uber only. Empty for DoorDash, whose receipts carry no addresses. */ route: RoutePoint[]; is_family: boolean; flags: string[]; } /** Everything the parser needs that lives on the message, not in the body. */ export interface MessageMeta { /** Provider message id. The only stable per-mail identity DoorDash offers. */ messageId: string; subject: string; /** ISO 8601. The authoritative order date — the body carries no reliable one. */ receivedAt: string; sender?: string; } /** * The message is not a receipt at all — a promotion, a delivery update, an * adjustment or refund notice. Expected traffic. Skipping it is correct and * must not raise an alert, or the channel becomes noise and gets ignored. */ export class NotAReceiptError extends Error { constructor(message: string, readonly messageId?: string) { super(message); this.name = "NotAReceiptError"; } } /** * The message IS a receipt and could not be parsed. This is the failure that * matters: a provider template change breaks every order at once, silently, and * the only symptom is spend quietly ceasing to appear. It must alert loudly. */ export class OrderParseError extends Error { constructor(message: string, readonly messageId?: string) { super(message); this.name = "OrderParseError"; } } const stripTags = (s: string) => s.replace(/<[^>]+>/g, " "); const decodeEntities = (s: string) => s .replace(/ /gi, " ") .replace(/&/gi, "&") .replace(/'|'/gi, "'") .replace(/"/gi, '"') .replace(/$/g, "$") .replace(/…/gi, "…"); const collapse = (s: string) => s.replace(/\s+/g, " ").trim(); const money = (raw: string): number => Math.abs(parseFloat(raw.replace(/[$,]/g, ""))); /** * DoorDash renders each total as its own nested table: * * Reading the pair structurally is what stops the label/value rebinding that * flattening causes (I9) — flattened, "Discounts -$24.09 Total Charged $14.64" * invites a regex to bind the wrong number to the wrong label. */ function tdPairValue(html: string, label: string): number | null { const re = new RegExp( `]*>\\s*${label}\\s*\\s*]*>\\s*(-?\\s*\\$?[\\d,]+\\.\\d{2})\\s*`, "i" ); const m = html.match(re); return m ? money(m[1]) : null; } /** * Uber itemises only *grocery* orders — a restaurant receipt states a total and * nothing else, which is why 67 of the 101 backfilled orders have no items. * When it does itemise, the markup is far better than DoorDash's: every cell * carries a `data-testid` naming its role and the item's own uuid, so quantity, * title and amount can be bound to each other by id rather than by position. */ function parseUberLineItems(html: string): LineItem[] { const items: LineItem[] = []; const titleRe = /data-testid="shoppingCart_item_title_([0-9a-f-]+)"[^>]*>([\s\S]*?)<\/td>/gi; for (const m of html.matchAll(titleRe)) { const [, id, rawTitle] = m; const description = collapse(decodeEntities(stripTags(rawTitle))); if (!description) continue; const qtyM = html.match( new RegExp(`data-testid="shoppingCart_item_quantity_${id}"[^>]*>\\s*(\\d+)\\s*<`, "i") ); const amtM = html.match( new RegExp( `data-testid="shoppingCart_item_amount_${id}"[^>]*>([\\s\\S]*?)<\\/td>`, "i" ) ); const amtText = amtM ? collapse(decodeEntities(stripTags(amtM[1]))) : ""; const amtNum = amtText.match(/(-?[\d,]+\.\d{2})/); items.push({ qty: qtyM ? parseInt(qtyM[1], 10) : 1, description, // A sold-out item prints 0.00 and is genuinely part of the order — it // explains a total that does not match what was asked for. Keep it. amount: amtNum ? money(amtNum[1]) : 0, }); } return items; } /** * Uber's `Order details` block, anchored on `data-testid="address_point_N_*"`. * * The template repeats the whole block twice (once hidden for narrow screens), * so the same stop appears more than once and has to be de-duplicated. This is * the same markup a *trip* receipt uses for its start and destination — rides * are not ingested today, but the reader will not need changing when they are. */ function parseUberRoute(html: string): RoutePoint[] { const seen = new Set(); const points: RoutePoint[] = []; const labelRe = /data-testid="address_point_(\d+)_time"[^>]*>([\s\S]*?)<\/td>/gi; for (const m of html.matchAll(labelRe)) { const [, idx, rawLabel] = m; const addrM = html.match( new RegExp(`data-testid="address_point_${idx}_address"[^>]*>([\\s\\S]*?)<\\/td>`, "i") ); if (!addrM) continue; const address = collapse(decodeEntities(stripTags(addrM[1]))); // Delivery receipts share one cell between time and label — "1:20 pm - // Pick-up". Trip receipts print the time alone, with no label at all, so // the naive split put the time in `label` and left `time` null. Position // carries the meaning there: first stop is where the ride began. const combined = collapse(decodeEntities(stripTags(rawLabel))); const split = combined.match(/^(.*?)\s+-\s+(.*)$/); let time: string | null; let label: string; if (split) { time = split[1]; label = split[2]; } else if (/^\d{1,2}:\d{2}\s*(am|pm)?$/i.test(combined)) { time = combined; label = ""; // filled in positionally below — the receipt gives none } else { time = null; label = combined; } const key = `${label}|${time}|${address}`; if (!address || seen.has(key)) continue; seen.add(key); points.push({ label, time, address }); } // A trip receipt labels neither end. Position is the only thing that says // which is which, and for a two-stop trip it says it unambiguously. Only // filled where the receipt itself was silent, so a future template that does // label its stops keeps its own wording. if (points.length === 2 && points.every((p) => !p.label)) { points[0].label = "Pick-up"; points[1].label = "Drop-off"; } return points; } function parseDoorDashLineItems(html: string): LineItem[] { // const re = /]*width="10%"[^>]*>\s*(\d+)x\s*<\/td>\s*]*width="75%"[^>]*>([\s\S]*?)<\/td>\s*]*width="15%"[^>]*>\s*\$?([\d,]+\.\d{2})\s*<\/td>/gi; const items: LineItem[] = []; for (const m of html.matchAll(re)) { const parts = decodeEntities(stripTags(m[2])) .split("•") .map((p) => collapse(p)) .filter(Boolean); if (!parts.length) continue; items.push({ qty: parseInt(m[1], 10), description: parts[0], amount: money(m[3]), options: parts.slice(1), }); } return items; } /** Reads " present in all 29 captured // receipts. For ue-00 it equals the UUID the PDF redirect resolves to // (ubereats.com/orders/34d6b4ee-...), so it is the order's real identity. // // The alternative, "first UUID in the document", is positional rather than // semantic: 4 of 29 receipts carry several UUIDs, and if a template reshuffle // ever put a per-send tracking id first, the symptom would be a reference // that changes every fetch and silently duplicates every order on every // backfill. Fall back to it only when the anchor is absent, and flag when // that fallback is genuinely ambiguous. let order_reference: string; const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; const anchored = clean.match( /tripReference[^>]*>\s*xid([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i ); const firstUuid = clean.match(UUID_RE); if (platform !== "doordash" && (anchored || firstUuid)) { order_reference = (anchored ? anchored[1] : firstUuid![0]).toLowerCase(); if (!anchored) { const distinct = new Set( (clean.match(new RegExp(UUID_RE.source, "gi")) || []).map((u) => u.toLowerCase()) ); // Only ambiguous when there is more than one candidate to choose between. if (distinct.size > 1) flags.push("order_uuid_ambiguous"); } } else { // DoorDash carries no order id anywhere in the receipt, so the provider // message id is the only stable identity available. That is correct for // ingestion idempotency: one receipt is one order. if (!meta.messageId) { throw new OrderParseError("no order id in body and no messageId supplied"); } order_reference = `msg:${meta.messageId}`; } // ---- date ---------------------------------------------------------------- // From the message, never the body. DoorDash confirmations are sent at order // time; the body's own date strings are inconsistent and locale-formatted. const received = new Date(meta.receivedAt); if (isNaN(received.getTime())) { throw new OrderParseError(`unparseable receivedAt: ${meta.receivedAt}`, meta.messageId); } const order_datetime = received.toISOString(); // ---- [Family] ------------------------------------------------------------ // A subject prefix on Uber Eats: "[Family] Your Sunday evening order with…". // Deliberately anchored — a bare substring search for "family" matches // footer copy and merchant names, and a false positive here silently drops // the order out of every budget. const is_family = /^\s*\[Family\]/i.test(meta.subject || ""); // ---- totals -------------------------------------------------------------- let totals: OrderTotals; if (platform === "doordash") { // Grocery "Final receipt" mails price each item and carry no Total Charged // row; the only stated total is the header. Fall back to it explicitly // rather than letting a partial total through. const headerTotal = text.match(/Total:\s*\$?([\d,]+\.\d{2})/i); const total = tdPairValue(clean, "Total Charged") ?? tdPairValue(clean, "Total") ?? (headerTotal ? money(headerTotal[1]) : null); if (total === null) { throw new OrderParseError("no total stated anywhere in receipt", meta.messageId); } const subtotal = tdPairValue(clean, "Subtotal"); totals = { subtotal, taxes: tdPairValue(clean, "Taxes"), delivery_fee: tdPairValue(clean, "Delivery Fee"), service_fee: tdPairValue(clean, "Service Fee"), tip: tdPairValue(clean, "Tip"), discounts: tdPairValue(clean, "Discounts"), total_charged: total, }; // An order paid entirely from DoorDash credits states "Total Charged // $0.00" — truthfully, because nothing was charged to a card — while the // items above it add up to a real amount. Read literally that is a $0 // order, and `validateOrderTotals` rejected 88 of them as "non-positive // total 0", which is the single largest cause of parse failures in the // captured mail and discards exactly the credit-funded spend this pipeline // exists to make visible. // // The order's value is its subtotal. Recording that keeps a credits meal // countable in budgets; recording zero would show the order and hide what // it cost. Guarded on the receipt actually saying credits, so a genuinely // zero-value mail still fails rather than inheriting a stray subtotal. if (total === 0 && subtotal !== null && subtotal > 0 && /Paid with[\s\S]{0,60}?credits/i.test(text)) { totals.total_charged = subtotal; flags.push("credits_funded_zero_charge"); } } else { // Uber Eats states a Total, optionally in a foreign currency. Uber writes // the currency in three different notations and all three occur in real // mail: // "Total $25.33" bare — the home currency // "Total LKR 3,783.20" ISO code, space-separated // "Total A$54.87" symbol-prefixed: A$, NZ$, US$, S$, HK$, C$ // "Total ₹1,240.00" a bare symbol // Only the first two were handled. The prefixed form is not exotic: it is // what Uber sends for ordinary Australian orders, so 176 of 550 captured // messages — most of them 2024-2025, i.e. current mail rather than legacy // templates — failed with "no Total found" while the amount sat in plain // sight in the body. `A$` misses `[A-Z]{3}` by a character. // // The [Family] orders are placed for family in Sri Lanka and are priced in // LKR — reading those as dollars would inflate them ~200x, which is a large // part of why they must not reach a budget untagged. const m = text.match( /(?:New Total|Total)\s*(?:([A-Z]{3})\s*)?(?:([A-Z]{1,2})?\$|([₹€£]))?\s*([\d,]+\.\d{2})/ ); if (!m) throw new OrderParseError("no Total found", meta.messageId); totals = { subtotal: extractLabelled(text, "Item subtotal"), taxes: extractLabelled(text, "Tax"), delivery_fee: extractLabelled(text, "Delivery Fee"), service_fee: extractLabelled(text, "Service Fee"), tip: null, discounts: null, total_charged: money(m[4]), }; // A symbol is only evidence of currency when it is qualified. A bare "$" // stays unset so the body-wide scan below still gets its say — the receipt // often names the currency elsewhere, and guessing AUD here would overrule // it. explicitCurrency = (m[1] && m[1].toUpperCase()) || (m[2] && SYMBOL_PREFIX_CURRENCY[m[2].toUpperCase()]) || (m[3] && SYMBOL_CURRENCY[m[3]]) || explicitCurrency; } // ---- payment ------------------------------------------------------------- const payment = parsePayment(platform, clean, text, totals.total_charged); if (payment.ambiguous && is_family) { // [Family] receipts name the payer, not an instrument ("Payments Siddharth // LKR 3,783.20"). An earlier version read that as credits-funded. It is // not: the card statement carries all four of them (CBA ...3893, exact // foreign_currency_amount matches), so creating a transaction duplicated // spend that was already recorded — precisely the double-count I5 exists to // prevent. // // Treated as card-settled: provenance only, no transaction. The statement // line IS the transaction, and it is what should carry the `family` tag. flags.push("family_card_settled_no_transaction"); } else if (payment.ambiguous) { // Split not stated and resolvable from the card statement — left for the // ingestion runner to reconcile, not guessed here. flags.push("payment_split_not_stated"); } else if (platform === "doordash") { // DoorDash names the method but not the amount; the total is the amount. if (payment.card_last4) payment.card_amount = totals.total_charged; else payment.credits_amount = totals.total_charged; } // ---- line items ---------------------------------------------------------- // Uber Eats receipts carry no itemisation (verified across 29 real mails). const line_items = platform === "doordash" ? parseDoorDashLineItems(clean) : parseUberLineItems(clean); if (platform === "doordash" && line_items.length === 0) { flags.push("no_line_items_parsed"); } // Uber prints addresses on every receipt; DoorDash prints none at all, so an // empty route there is expected rather than a parse failure. const route = platform === "doordash" ? [] : parseUberRoute(clean); const currency = explicitCurrency || (/\b(NZD|USD|LKR|CHF|EUR|GBP|SGD|INR)\b/.test(text) ? (text.match(/\b(NZD|USD|LKR|CHF|EUR|GBP|SGD|INR)\b/) as RegExpMatchArray)[1] : "AUD"); return { order_reference, platform, merchant_name, order_datetime, currency, payment, totals, line_items, route, is_family, flags, }; } /** * Integrity check on the amount we are about to record as spend. * * NOT an arithmetic reconciliation of the fee breakdown. Measured against 36 * real DoorDash receipts, the components do not sum to the total on 32 of them: * DoorDash's `Discounts` line frequently equals subtotal + service fee exactly * (Mad Mex: subtotal 22.10, service 1.99, Discounts -24.09, Total Charged * 14.64) and sometimes differs by an unrelated margin. Whatever that line means * to DoorDash, it is not a term in `total = components`. * * This corrects an earlier diagnosis that read the same numbers as an * HTML-flattening artefact with a "true discount of $9.45". Parsing the table * cells structurally yields the identical figures, and no $9.45 appears * anywhere in the message — the receipt genuinely says this. * * So the breakdown is stored as provenance and never gated on. What IS checked * is the number that becomes money in the ledger: DoorDash states the total * twice, independently (a `Total: $X` header and a `Total Charged` table row), * and those must agree. That catches a mis-parse, which is the failure that * actually matters. */ export function validateOrderTotals( order: ParsedOrder, html?: string ): { ok: boolean; reason?: string } { const t = order.totals; if (!(t.total_charged > 0)) { return { ok: false, reason: `non-positive total ${t.total_charged}` }; } // Cross-check the header total against the table total where both exist. // // Skipped for a credits-funded order: both stated totals are $0.00 there and // agree with each other, but the recorded amount is deliberately the // subtotal, so the check would reject every one of them for "disagreeing" // with a figure the parser overrode on purpose. if ( html && order.platform === "doordash" && !order.flags.includes("credits_funded_zero_charge") ) { const header = collapse(decodeEntities(stripTags(html))).match( /Total:\s*\$?([\d,]+\.\d{2})/i ); if (header) { const stated = money(header[1]); if (Math.abs(stated - t.total_charged) > 0.02) { return { ok: false, reason: `header total ${stated.toFixed(2)} disagrees with Total Charged ${t.total_charged.toFixed(2)}`, }; } } } // The payment line must account for the total, or we are recording an amount // no stated payment method covers. if (!order.payment.ambiguous) { const paid = (order.payment.credits_amount || 0) + (order.payment.card_amount || 0); if (paid > 0 && Math.abs(paid - t.total_charged) > 0.02) { return { ok: false, reason: `payments sum to ${paid.toFixed(2)} but receipt states ${t.total_charged.toFixed(2)}`, }; } } return { ok: true }; } export interface OrderAmendment { order_reference: string | null; previous_total: number | null; refund_amount: number | null; new_total: number; order_datetime: string; messageId: string; } /** * Refund / total-adjustment notices restate an order that was already ingested: * * "We adjusted the total for your recent order from Coles (Wyndham Vale)." * Previous total $49.94 · Refund -$4.21 · New Total $45.73 * * These are amendments, not receipts — inserting one as a new order would * double-count the meal and hide the refund. Uber embeds the same order UUID it * used on the original receipt, so the amendment can be matched back to it. */ export function parseOrderAmendment(html: string, meta: MessageMeta): OrderAmendment { const clean = html.replace(//g, ""); const text = collapse(decodeEntities(stripTags(clean))); if (!/We adjusted the total|Your refund has been applied|Previous total/i.test(text)) { throw new OrderParseError("not an amendment notice", meta.messageId); } const newTotal = text.match(/New Total\s*(?:[A-Z]{3})?\s*\$?\s*([\d,]+\.\d{2})/i); if (!newTotal) { throw new OrderParseError("amendment states no New Total", meta.messageId); } const prev = text.match(/Previous total\s*(?:[A-Z]{3})?\s*\$?\s*([\d,]+\.\d{2})/i); const refund = text.match(/Refund\s*-?\s*(?:[A-Z]{3})?\s*\$?\s*([\d,]+\.\d{2})/i); const uuid = clean.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i); const received = new Date(meta.receivedAt); if (isNaN(received.getTime())) { throw new OrderParseError(`unparseable receivedAt: ${meta.receivedAt}`, meta.messageId); } return { order_reference: uuid ? uuid[0].toLowerCase() : null, previous_total: prev ? money(prev[1]) : null, refund_amount: refund ? money(refund[1]) : null, new_total: money(newTotal[1]), order_datetime: received.toISOString(), messageId: meta.messageId, }; } /** True when a message is an amendment rather than a receipt. */ export function isAmendment(html: string): boolean { const text = collapse(decodeEntities(stripTags(html.replace(//g, "")))); return /We adjusted the total|Your refund has been applied|Previous total/i.test(text); }
Label$X
Subtotal $22.101xName (Cat)
• Opt
$22.10xid