feat(orders): read Uber trips, and reject the charge summary that duplicates them
ci / lint-test (push) Failing after 45s

Local rides are paid with credits (only overseas ones go on a card), so trips
belong to this slice and were simply never fetched — the Graph query searched
"order with Uber", the Eats subject. Captured 15 real messages from the mailbox
via a dry-run before touching anything, which found two defects that no amount
of reasoning about the template would have:

**Uber sends two mails per trip.** A "charge summary" when the ride ends, then
the real receipt when payment settles — same subject, same total. The summary
carries no tripReference, so order_reference fell back to `msg:<message-id>`
and I7 could not dedupe it against the receipt that follows. Every trip would
have been recorded twice. It says so itself ("This is not a payment receipt ...
You will receive a trip receipt when the payment is processed"), so it is now a
NotAReceiptError — 200 and silent, like every other expected non-receipt.

**Trip receipts label neither end of the journey.** Delivery receipts write
"1:20 pm - Pick-up"; trips print the time alone. The split regex put the time
into `label` and left `time` null. Time is now read properly, and a two-stop
trip is labelled Pick-up/Drop-off positionally — only where the receipt was
silent, so a template that does label its stops keeps its own wording.

Verified against all 15 captured messages: 7 trips recorded, 5 charge summaries
and 3 promotions skipped, 0 failures, no duplicate references. Two of the seven
are AUD credits-funded ($84.78 + $47.97) and would become transactions; the
five NZD ones are card-settled and correctly create provenance only (I5).

Fixtures ut-00 (local credits trip), ut-01 (overseas card trip) and ut-summary
(the charge summary) are captured mail, not written by hand.
This commit is contained in:
2026-07-27 11:21:13 +10:00
parent 4febf38292
commit c656f5d26b
7 changed files with 839 additions and 4 deletions
+39 -3
View File
@@ -191,17 +191,39 @@ function parseUberRoute(html: string): RoutePoint[] {
if (!addrM) continue;
const address = collapse(decodeEntities(stripTags(addrM[1])));
// "1:20 pm - Pick-up" — time and label share one cell.
// 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+(.*)$/);
const time = split ? split[1] : null;
const label = split ? split[2] : combined;
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;
}
@@ -357,6 +379,20 @@ export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
);
}
// Uber sends TWO mails per trip with the same subject and the same total: a
// "charge summary" when the trip ends, then the real receipt once payment
// settles. The summary says so itself — "This is not a payment receipt ...
// You will receive a trip receipt when the payment is processed with payment
// information" — and it carries no tripReference, so order_reference would
// fall back to `msg:<message-id>` and I7 could not dedupe it against the
// receipt that follows. Every trip would be recorded twice.
if (/This is not a payment receipt|This is your charge summary/i.test(text)) {
throw new NotAReceiptError(
"charge summary, not a payment receipt — the real receipt follows",
meta.messageId
);
}
const platform = detectPlatform(meta, text);
const merchant_name = parseMerchant(platform, meta, text);