feat(orders): make an ingested order legible in the transactions view
ci / lint-test (push) Failing after 43s
ci / lint-test (push) Failing after 43s
Four things the view could not tell you, all from reading the rows (user,
2026-07-27).
**Which platform.** The parser has always known — it has to, to read the
template — and then discarded it. "Order - Burger Corner" gives no way to know
whether to open DoorDash or Uber Eats for the detail, and restaurants exist on
both. Now stored on expense_metadata and named in the description:
"Order - Burger Corner (Uber Eats)". Migration 0021 recovers it for the 101
backfilled rows from the order_reference shape — DoorDash receipts carry no id
of their own so ingestion synthesises `msg:<message-id>`, Uber carries a real
trip UUID, which makes the discriminator exact.
**Bank said "Manual".** That label is derived, not stored, and "Manual" reads
as "hand-entered, still awaiting a card line to match". A gift-card order has
no card line coming, ever. It now reads "Gift Card", and — the part that
actually mattered — credits joins cash in needsCardMatch(), so these stop
sitting in the pending-reconciliation queue. All 81 were queued against a match
that could not exist.
**Uber line items were never parsed.** 67 of 101 orders had none. Uber itemises
groceries but not restaurant orders, so some of that is genuine; the rest was
simply unread. Its markup is better than DoorDash's — every cell carries a
data-testid with the item's uuid, so qty/title/amount bind by id rather than by
column position. Sold-out items (0.00) are kept: they are why a total is lower
than what was ordered.
**Uber prints pick-up and delivery addresses on every receipt** and they were
thrown away. Captured as `route` [{label, time, address}], de-duplicated
because the template renders the whole block twice for narrow screens. Wording
is kept as printed ("Pick-up" on some receipts, "Pickup" on others) rather than
normalised, so a template change stays visible. This is the same block a *trip*
receipt uses for start and destination — rides are not ingested today, but the
reader will not need changing when they are.
Also stores source_email_subject/from, which order ingestion had left null on
columns that already existed.
Verified against the captured corpus: route on all 6 Uber fixtures, 5/5 items
on the GLOMARK grocery receipt including the sold-out one. Production data
updated by smarthome:docker/scripts/order-presentation-2026-07-27.sql
(81 descriptions, `backfill` tag, re-run clean). `route` and Uber line items
are parsed from here on only — recovering them for already-ingested orders
means re-reading the mail, which I7 idempotency refuses by design.
This commit is contained in:
+95
-1
@@ -20,6 +20,18 @@ export interface LineItem {
|
||||
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;
|
||||
@@ -47,6 +59,8 @@ export interface ParsedOrder {
|
||||
payment: PaymentBreakdown;
|
||||
totals: OrderTotals;
|
||||
line_items: LineItem[];
|
||||
/** Uber only. Empty for DoorDash, whose receipts carry no addresses. */
|
||||
route: RoutePoint[];
|
||||
is_family: boolean;
|
||||
flags: string[];
|
||||
}
|
||||
@@ -116,6 +130,81 @@ function tdPairValue(html: string, label: string): number | null {
|
||||
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<string>();
|
||||
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])));
|
||||
// "1:20 pm - Pick-up" — time and label share one cell.
|
||||
const combined = collapse(decodeEntities(stripTags(rawLabel)));
|
||||
const split = combined.match(/^(.*?)\s+-\s+(.*)$/);
|
||||
const time = split ? split[1] : null;
|
||||
const label = split ? split[2] : combined;
|
||||
|
||||
const key = `${label}|${time}|${address}`;
|
||||
if (!address || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
points.push({ label, time, address });
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function parseDoorDashLineItems(html: string): LineItem[] {
|
||||
// <td width="10%">1x</td><td width="75%"><b>Name</b> (Cat)<br><font>• Opt</font>…</td><td width="15%">$22.10</td>
|
||||
const re =
|
||||
@@ -397,11 +486,15 @@ export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
|
||||
// ---- line items ----------------------------------------------------------
|
||||
// Uber Eats receipts carry no itemisation (verified across 29 real mails).
|
||||
const line_items =
|
||||
platform === "doordash" ? parseDoorDashLineItems(clean) : [];
|
||||
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)
|
||||
@@ -417,6 +510,7 @@ export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
|
||||
payment,
|
||||
totals,
|
||||
line_items,
|
||||
route,
|
||||
is_family,
|
||||
flags,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user