From 0595d49d5c48e7dfb754ab5dc7fc83a7fb0bbf17 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Tue, 28 Jul 2026 17:51:36 +1000 Subject: [PATCH] fix(orders): the restaurant is the merchant, not the courier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses a change made on request. The platform in the headline fragmented the merchant: the same restaurant read as two, depending on who carried the bag, and that is not a distinction anyone rating the food cares about. It also already has a home — the expandable Order details panel renders expense_metadata.platform next to its heading, which is where the user asked for it. The fragmentation was worse than cosmetic. merchantVerdict joined on an exact merchant_normalized, and the platforms capitalise differently ("TEG Kebabs & Biryani" on Uber Eats, "TEG KEBABS & BIRYANI" on DoorDash), so one restaurant kept two separate histories and a "never again" recorded through one app never warned in the other — silently defeating the point of the memory. Now case-folded; verified on real data, where the DoorDash order sees 1 prior verdict against 0 before. 81 existing descriptions backfilled in one transaction, dry-run first and dumped beforehand. The regex is anchored to the end so suburb parens survive: "Order - Coles (Wyndham Vale) (Uber Eats)" becomes "Order - Coles (Wyndham Vale)", not "Order - Coles". --- .../integration/order-ingestion.test.ts | 12 ++++++---- .../integration/order-reviews.test.ts | 13 ++++++++++ src/__tests__/unit/order-ingestion.test.ts | 24 +++++++++++++------ src/lib/order-ingestion.ts | 17 +++++++------ src/lib/order-reviews.ts | 7 +++++- 5 files changed, 53 insertions(+), 20 deletions(-) diff --git a/src/__tests__/integration/order-ingestion.test.ts b/src/__tests__/integration/order-ingestion.test.ts index 55c028b..321b149 100644 --- a/src/__tests__/integration/order-ingestion.test.ts +++ b/src/__tests__/integration/order-ingestion.test.ts @@ -177,7 +177,7 @@ describe("Order ingestion — invariants", () => { expect(b.skipped).toBe("already_ingested"); expect(b.metadataId).toBe(a.metadataId); const n = await queryRow<{ c: string }>( - `SELECT count(*)::text c FROM transactions WHERE description = 'Order - Mad Mex (DoorDash)'` + `SELECT count(*)::text c FROM transactions WHERE description = 'Order - Mad Mex'` ); expect(Number(n!.c)).toBe(1); }); @@ -389,15 +389,17 @@ describe("owner scoping", () => { }); describe("how an ingested order presents in the app", () => { - it("names the platform in the description", async () => { - // "Order - Burger Corner" gives no way to know where to look for the - // detail, and the same restaurant can be on both platforms. + it("names the restaurant, not the courier", async () => { + // Reversed 2026-07-28. The platform was in the headline on request, but it + // fragmented the merchant — the same restaurant read differently depending + // on who delivered — and it already has a home: the Order details panel + // renders expense_metadata.platform next to its heading. const p = parseOrderHTML(html("dd-01"), meta({ messageId: `desc-${Date.now()}` })); const res = await processOrderIngestion(p); const row = await queryRow<{ description: string }>( `SELECT description FROM transactions WHERE id = $1`, [res.transactionId] ); - expect(row!.description).toMatch(/\(DoorDash\)$/); + expect(row!.description).toBe('Order - Mad Mex'); }); it("reads as 'Gift Card', not 'Manual', and stays out of the reconcile queue", async () => { diff --git a/src/__tests__/integration/order-reviews.test.ts b/src/__tests__/integration/order-reviews.test.ts index a332875..5aada55 100644 --- a/src/__tests__/integration/order-reviews.test.ts +++ b/src/__tests__/integration/order-reviews.test.ts @@ -154,6 +154,19 @@ describe("merchant history", () => { expect(body.merchant.history.map((h: any) => h.transaction_id)).toEqual([older]); }); + it("treats the same restaurant as one merchant across platforms", async () => { + // DoorDash and Uber Eats capitalise differently — "TEG Kebabs & Biryani" + // vs "TEG KEBABS & BIRYANI". An exact match split one restaurant's history + // in two, so a "never again" recorded through one app never warned in the + // other, silently defeating the point of the memory. + const shouty = await seedOrder("THAI PALACE", "2026-06-01"); + await PUT(req({ participant_id: ownerId, rating: "never" }), params(shouty)); + + const body = await (await GET(req(), params(txnId))).json(); + expect(body.merchant.warn).toBe(true); + expect(body.merchant.history.map((h: any) => h.transaction_id)).toContain(shouty); + }); + it("does not carry a verdict across different merchants", async () => { const other = await seedOrder("Pizza Place", "2026-06-01"); await PUT(req({ participant_id: ownerId, rating: "never" }), params(other)); diff --git a/src/__tests__/unit/order-ingestion.test.ts b/src/__tests__/unit/order-ingestion.test.ts index 484945c..1f21789 100644 --- a/src/__tests__/unit/order-ingestion.test.ts +++ b/src/__tests__/unit/order-ingestion.test.ts @@ -306,17 +306,27 @@ describe("Uber trips", () => { }); describe("orderDescription", () => { - it("names the platform", () => { - expect(orderDescription("doordash", "Mad Mex")).toBe("Order - Mad Mex (DoorDash)"); + it("names the restaurant, not the courier", () => { + // The platform is provenance and lives in the Order details panel, which + // already renders expense_metadata.platform. Putting it here fragmented the + // merchant — the same restaurant read differently depending on who carried + // the bag, which nobody rating the food cares about. + expect(orderDescription("doordash", "Mad Mex")).toBe("Order - Mad Mex"); expect(orderDescription("ubereats", "Coles (Wyndham Vale)")).toBe( - "Order - Coles (Wyndham Vale) (Uber Eats)" + "Order - Coles (Wyndham Vale)" ); }); - it("does not restate a platform the merchant already names", () => { - // A trip's merchant is literally "Uber Trip"; "(Uber)" after it says - // nothing. What identifies a trip is its addresses, and those live in the - // Order details panel. + it("leaves a merchant that already names the platform alone", () => { + // A trip's merchant is literally "Uber Trip". What identifies a trip is its + // addresses, and those live in the Order details panel. expect(orderDescription("uber", "Uber Trip")).toBe("Order - Uber Trip"); }); + + it("gives the same description whichever platform delivered it", () => { + // The regression this whole change exists to prevent. + expect(orderDescription("doordash", "TEG Kebabs & Biryani")).toBe( + orderDescription("ubereats", "TEG Kebabs & Biryani") + ); + }); }); diff --git a/src/lib/order-ingestion.ts b/src/lib/order-ingestion.ts index 87f5a68..2fd59c1 100644 --- a/src/lib/order-ingestion.ts +++ b/src/lib/order-ingestion.ts @@ -26,13 +26,16 @@ export const PLATFORM_LABEL: Record = { * look for the detail, and there are restaurants on both platforms. The * platform is the one thing the parser always knows and used to discard. */ -export function orderDescription(platform: ParsedOrder["platform"], merchant: string): string { - const label = PLATFORM_LABEL[platform]; - // A trip's merchant is literally "Uber Trip", so the suffix would restate it - // — "Order - Uber Trip (Uber)". The addresses that actually identify a trip - // are in the Order details panel, not squeezed into the description. - if (merchant.toLowerCase().includes(label.toLowerCase())) return `Order - ${merchant}`; - return `Order - ${merchant} (${label})`; +export function orderDescription(_platform: ParsedOrder["platform"], merchant: string): string { + // The platform is deliberately NOT in the headline. What identifies the + // transaction is the restaurant; which courier delivered it is provenance, + // and it already has a home — the expandable Order details panel renders + // `expense_metadata.platform` next to its heading (user, 2026-07-28). + // + // Keeping it here also fragmented the merchant: the same restaurant reads + // "Mad Mex (DoorDash)" and "Mad Mex (Uber Eats)" depending on who carried + // the bag, which is not a distinction anyone rating the food cares about. + return `Order - ${merchant}`; } export interface IngestResult { diff --git a/src/lib/order-reviews.ts b/src/lib/order-reviews.ts index d02a3a1..960c76d 100644 --- a/src/lib/order-reviews.ts +++ b/src/lib/order-reviews.ts @@ -151,7 +151,12 @@ export async function merchantVerdict( JOIN expense_metadata em ON em.transaction_id = r.transaction_id OR em.matched_transaction_id = r.transaction_id - WHERE em.merchant_normalized = $1 + -- Case-folded: the platforms capitalise the same restaurant differently + -- ("TEG Kebabs & Biryani" on Uber Eats, "TEG KEBABS & BIRYANI" on + -- DoorDash). An exact match split one restaurant's history in two, so a + -- "never again" recorded through one app never warned you in the other — + -- silently defeating the whole point of the memory. + WHERE lower(em.merchant_normalized) = lower($1) AND ($2::int IS NULL OR r.transaction_id <> $2) ORDER BY t.transaction_date DESC, r.participant_id LIMIT 50`,