/**
* 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
Label
$X
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, " ");
/**
* Entity decoding, ordered so `&` resolves last.
*
* `•` was missing, and it is not a cosmetic omission: DoorDash separates
* an item's name from its options with a bullet, and parseDoorDashLineItems
* splits on the literal "•". Left encoded, the split never happens and the
* whole line collapses into the description — "Fire Extinguisher (Chicken
* Burgers) • Regular" instead of a name plus one option. So the entity
* showed up on screen AND the structure behind it was lost.
*
* Numeric entities are decoded generically rather than one at a time, which is
* how $ came to be listed individually while its neighbours were not.
*
* `&` goes last because decoding it first turns a literal "•" —
* text that should stay as written — into a bullet.
*/
const decodeEntities = (s: string) =>
s
.replace(/ /gi, " ")
.replace(/'|'/gi, "'")
.replace(/"/gi, '"')
.replace(/…/gi, "…")
.replace(/•/gi, "•")
.replace(/·/gi, "·")
.replace(/–/gi, "–")
.replace(/—/gi, "—")
.replace(/([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
.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:
*
Subtotal
$22.10
* 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[] {
//
1x
Name (Cat) • Opt…
$22.10
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 "