Two parse bugs that between them made 218 of 776 captured messages
unreadable. Neither was the "old template" they were filed as.
Uber writes the currency three ways and only two were handled. "Total
A$54.87" is what it sends for ordinary Australian orders — A$ misses
[A-Z]{3} by one character — so 98 of 275 Uber Eats mails and 78 of 275
trip mails failed with "no Total found" while the amount sat in plain
sight. Most were 2024-2025, i.e. current mail. NZ$, US$, S$, HK$, C$ and
a bare rupee/euro/pound symbol are handled the same way. A bare "$" is
still left unresolved on purpose: a dozen currencies use it, and the
body-wide scan that reads the receipt's own stated code should win.
A DoorDash order paid from credits states "Total Charged $0.00"
truthfully, above a real subtotal. Read literally that is a $0 order, and
validateOrderTotals rejected 88 of them as non-positive — discarding
exactly the credit-funded spend this pipeline exists to surface. The
order's value is its subtotal; recording zero would show the order and
hide what it cost. Guarded on the receipt actually saying credits, so an
empty mail still fails rather than inheriting a stray subtotal, and the
header cross-check stands down for these or it rejects the figure the
parser deliberately overrode.
Measured A/B over all 776 captures: 254 parsed by both parsers with zero
change to any amount or currency, 0 lost, 214 newly readable. Both fixes
mutation-tested — reverting the regex fails 4 tests, removing the credits
branch fails 3.
Fixtures are real captured receipts, per the 2026-07-26 rewrite: the
earlier synthetic suite passed while the parser could not read a real
email.
No history replay — I7 idempotency refuses re-reads and that needs an
explicit update mode. This fixes ingest from here on.
This commit is contained in:
+78
-7
@@ -282,6 +282,29 @@ function parseMerchant(platform: string, meta: MessageMeta, text: string): strin
|
||||
throw new OrderParseError(`cannot determine merchant`, meta.messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Currency notations Uber actually uses in receipt totals.
|
||||
*
|
||||
* Deliberately narrow: only prefixes that are unambiguous. "R$" (BRL) and "$"
|
||||
* alone are excluded — a bare dollar sign is used by a dozen currencies and
|
||||
* resolving it here would overrule the body-wide scan that reads the receipt's
|
||||
* own stated code.
|
||||
*/
|
||||
const SYMBOL_PREFIX_CURRENCY: Record<string, string> = {
|
||||
A: "AUD",
|
||||
NZ: "NZD",
|
||||
US: "USD",
|
||||
S: "SGD",
|
||||
HK: "HKD",
|
||||
C: "CAD",
|
||||
};
|
||||
|
||||
const SYMBOL_CURRENCY: Record<string, string> = {
|
||||
"₹": "INR",
|
||||
"€": "EUR",
|
||||
"£": "GBP",
|
||||
};
|
||||
|
||||
function parsePayment(platform: string, html: string, text: string): PaymentBreakdown {
|
||||
const out: PaymentBreakdown = {
|
||||
credits_amount: null,
|
||||
@@ -467,8 +490,9 @@ export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
|
||||
if (total === null) {
|
||||
throw new OrderParseError("no total stated anywhere in receipt", meta.messageId);
|
||||
}
|
||||
const subtotal = tdPairValue(clean, "Subtotal");
|
||||
totals = {
|
||||
subtotal: tdPairValue(clean, "Subtotal"),
|
||||
subtotal,
|
||||
taxes: tdPairValue(clean, "Taxes"),
|
||||
delivery_fee: tdPairValue(clean, "Delivery Fee"),
|
||||
service_fee: tdPairValue(clean, "Service Fee"),
|
||||
@@ -476,13 +500,43 @@ export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
|
||||
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:
|
||||
// "Total $25.33" | "Total LKR 3,783.20"
|
||||
// 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*)?\$?\s*([\d,]+\.\d{2})/);
|
||||
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"),
|
||||
@@ -491,9 +545,17 @@ export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
|
||||
service_fee: extractLabelled(text, "Service Fee"),
|
||||
tip: null,
|
||||
discounts: null,
|
||||
total_charged: money(m[2]),
|
||||
total_charged: money(m[4]),
|
||||
};
|
||||
if (m[1]) explicitCurrency = m[1].toUpperCase().replace(/\$$/, "");
|
||||
// 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 -------------------------------------------------------------
|
||||
@@ -584,7 +646,16 @@ export function validateOrderTotals(
|
||||
}
|
||||
|
||||
// Cross-check the header total against the table total where both exist.
|
||||
if (html && order.platform === "doordash") {
|
||||
//
|
||||
// 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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user