From c656f5d26b34de743b1974b19c3fb2996352f81e Mon Sep 17 00:00:00 2001 From: siddharthd Date: Mon, 27 Jul 2026 11:21:13 +1000 Subject: [PATCH] feat(orders): read Uber trips, and reject the charge summary that duplicates them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:` 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. --- src/__tests__/fixtures/orders/real/ut-00.html | 241 ++++++++++++++++++ src/__tests__/fixtures/orders/real/ut-01.html | 241 ++++++++++++++++++ .../fixtures/orders/real/ut-meta.json | 20 ++ .../fixtures/orders/real/ut-summary.html | 241 ++++++++++++++++++ .../integration/order-ingestion.test.ts | 2 +- src/__tests__/unit/order-ingestion.test.ts | 56 ++++ src/lib/order-parse.ts | 42 ++- 7 files changed, 839 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/fixtures/orders/real/ut-00.html create mode 100644 src/__tests__/fixtures/orders/real/ut-01.html create mode 100644 src/__tests__/fixtures/orders/real/ut-meta.json create mode 100644 src/__tests__/fixtures/orders/real/ut-summary.html diff --git a/src/__tests__/fixtures/orders/real/ut-00.html b/src/__tests__/fixtures/orders/real/ut-00.html new file mode 100644 index 0000000..d31d100 --- /dev/null +++ b/src/__tests__/fixtures/orders/real/ut-00.html @@ -0,0 +1,241 @@ + +
\ No newline at end of file diff --git a/src/__tests__/fixtures/orders/real/ut-01.html b/src/__tests__/fixtures/orders/real/ut-01.html new file mode 100644 index 0000000..3334f6d --- /dev/null +++ b/src/__tests__/fixtures/orders/real/ut-01.html @@ -0,0 +1,241 @@ + +
\ No newline at end of file diff --git a/src/__tests__/fixtures/orders/real/ut-meta.json b/src/__tests__/fixtures/orders/real/ut-meta.json new file mode 100644 index 0000000..28bf978 --- /dev/null +++ b/src/__tests__/fixtures/orders/real/ut-meta.json @@ -0,0 +1,20 @@ +{ + "ut-00": { + "messageId": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zVAAAAA", + "subject": "Your Sunday evening trip with Uber", + "receivedAt": "2026-06-21T10:11:16Z", + "sender": "noreply@uber.com" + }, + "ut-01": { + "messageId": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zU-AAAA", + "subject": "Your Sunday morning trip with Uber", + "receivedAt": "2026-06-21T09:02:09Z", + "sender": "noreply@uber.com" + }, + "ut-summary": { + "messageId": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zU3AAAA", + "subject": "Your Sunday morning trip with Uber", + "receivedAt": "2026-06-20T22:27:54Z", + "sender": "noreply@uber.com" + } +} \ No newline at end of file diff --git a/src/__tests__/fixtures/orders/real/ut-summary.html b/src/__tests__/fixtures/orders/real/ut-summary.html new file mode 100644 index 0000000..5fcbfe1 --- /dev/null +++ b/src/__tests__/fixtures/orders/real/ut-summary.html @@ -0,0 +1,241 @@ + +
\ No newline at end of file diff --git a/src/__tests__/integration/order-ingestion.test.ts b/src/__tests__/integration/order-ingestion.test.ts index 0ab8b82..55c028b 100644 --- a/src/__tests__/integration/order-ingestion.test.ts +++ b/src/__tests__/integration/order-ingestion.test.ts @@ -450,7 +450,7 @@ describe("receipt lookup for the transaction detail panel", () => { // either side, and a card-settled order only has the matched_transaction_id // side, which is exactly where the detail would otherwise go missing. const receiptFor = (txnId: number) => - queryRow<{ platform: string; route: { label: string }[] }>( + queryRow<{ platform: string; route: { label: string; address: string }[] }>( `SELECT platform, route FROM expense_metadata WHERE transaction_id = $1 OR matched_transaction_id = $1 LIMIT 1`, [txnId] diff --git a/src/__tests__/unit/order-ingestion.test.ts b/src/__tests__/unit/order-ingestion.test.ts index 553e23e..059c2ba 100644 --- a/src/__tests__/unit/order-ingestion.test.ts +++ b/src/__tests__/unit/order-ingestion.test.ts @@ -247,3 +247,59 @@ describe("Uber line items", () => { expect(p.flags).not.toContain("no_line_items_parsed"); }); }); + +/** + * Uber trips. Captured 2026-06 via a dry-run against the real mailbox after the + * user pointed out that only *overseas* rides go on a card — local rides are + * paid with credits, which puts them in the same class as delivery orders. + */ +describe("Uber trips", () => { + const utMeta: Record = JSON.parse( + readFileSync(resolve(dir, "ut-meta.json"), "utf-8") + ); + const trip = (f: string) => parseOrderHTML(html(f), utMeta[f]); + + it("a local trip is credits-funded", () => { + const p = trip("ut-00"); + expect(p.platform).toBe("uber"); + expect(p.currency).toBe("AUD"); + expect(p.totals.total_charged).toBeCloseTo(84.78, 2); + expect(p.payment.credits_amount).toBeCloseTo(84.78, 2); + expect(p.payment.card_last4).toBeNull(); + expect(validateOrderTotals(p).ok).toBe(true); + }); + + it("an overseas trip is card-settled", () => { + const p = trip("ut-01"); + expect(p.currency).toBe("NZD"); + expect(p.totals.total_charged).toBeCloseTo(55.51, 2); + expect(p.payment.card_amount).toBeCloseTo(55.51, 2); + expect(p.payment.card_last4).toBe("3893"); + }); + + it("labels the two ends of a trip, which the receipt does not", () => { + // Delivery receipts write "1:20 pm - Pick-up"; trip receipts print the time + // alone. The naive split put the time in `label` and left `time` null. + const p = trip("ut-00"); + expect(p.route).toEqual([ + { + label: "Pick-up", + time: "7:32 pm", + address: "Terminal 2, Melbourne Airport (MEL), Tullamarine VIC 3045, Australia", + }, + { + label: "Drop-off", + time: "8:10 pm", + address: "19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia", + }, + ]); + }); + + it("rejects the charge summary Uber sends before the receipt", () => { + // Uber sends two mails per trip with the same subject and the same total. + // The first says "This is not a payment receipt" and carries no + // tripReference, so order_reference would fall back to msg: and I7 + // could not dedupe it — every trip would be recorded twice. + expect(() => trip("ut-summary")).toThrow(NotAReceiptError); + }); +}); diff --git a/src/lib/order-parse.ts b/src/lib/order-parse.ts index 32fc07a..873228c 100644 --- a/src/lib/order-parse.ts +++ b/src/lib/order-parse.ts @@ -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:` 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);