fix(orders): the restaurant is the merchant, not the courier
ci / lint-test (push) Successful in 43s

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".
This commit is contained in:
2026-07-28 17:51:36 +10:00
parent 50c5b7c430
commit 0595d49d5c
5 changed files with 53 additions and 20 deletions
@@ -177,7 +177,7 @@ describe("Order ingestion — invariants", () => {
expect(b.skipped).toBe("already_ingested"); expect(b.skipped).toBe("already_ingested");
expect(b.metadataId).toBe(a.metadataId); expect(b.metadataId).toBe(a.metadataId);
const n = await queryRow<{ c: string }>( 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); expect(Number(n!.c)).toBe(1);
}); });
@@ -389,15 +389,17 @@ describe("owner scoping", () => {
}); });
describe("how an ingested order presents in the app", () => { describe("how an ingested order presents in the app", () => {
it("names the platform in the description", async () => { it("names the restaurant, not the courier", async () => {
// "Order - Burger Corner" gives no way to know where to look for the // Reversed 2026-07-28. The platform was in the headline on request, but it
// detail, and the same restaurant can be on both platforms. // 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 p = parseOrderHTML(html("dd-01"), meta({ messageId: `desc-${Date.now()}` }));
const res = await processOrderIngestion(p); const res = await processOrderIngestion(p);
const row = await queryRow<{ description: string }>( const row = await queryRow<{ description: string }>(
`SELECT description FROM transactions WHERE id = $1`, [res.transactionId] `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 () => { it("reads as 'Gift Card', not 'Manual', and stays out of the reconcile queue", async () => {
@@ -154,6 +154,19 @@ describe("merchant history", () => {
expect(body.merchant.history.map((h: any) => h.transaction_id)).toEqual([older]); 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 () => { it("does not carry a verdict across different merchants", async () => {
const other = await seedOrder("Pizza Place", "2026-06-01"); const other = await seedOrder("Pizza Place", "2026-06-01");
await PUT(req({ participant_id: ownerId, rating: "never" }), params(other)); await PUT(req({ participant_id: ownerId, rating: "never" }), params(other));
+17 -7
View File
@@ -306,17 +306,27 @@ describe("Uber trips", () => {
}); });
describe("orderDescription", () => { describe("orderDescription", () => {
it("names the platform", () => { it("names the restaurant, not the courier", () => {
expect(orderDescription("doordash", "Mad Mex")).toBe("Order - Mad Mex (DoorDash)"); // 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( 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", () => { it("leaves a merchant that already names the platform alone", () => {
// A trip's merchant is literally "Uber Trip"; "(Uber)" after it says // A trip's merchant is literally "Uber Trip". What identifies a trip is its
// nothing. What identifies a trip is its addresses, and those live in the // addresses, and those live in the Order details panel.
// Order details panel.
expect(orderDescription("uber", "Uber Trip")).toBe("Order - Uber Trip"); 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")
);
});
}); });
+10 -7
View File
@@ -26,13 +26,16 @@ export const PLATFORM_LABEL: Record<ParsedOrder["platform"], string> = {
* look for the detail, and there are restaurants on both platforms. The * 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. * platform is the one thing the parser always knows and used to discard.
*/ */
export function orderDescription(platform: ParsedOrder["platform"], merchant: string): string { export function orderDescription(_platform: ParsedOrder["platform"], merchant: string): string {
const label = PLATFORM_LABEL[platform]; // The platform is deliberately NOT in the headline. What identifies the
// A trip's merchant is literally "Uber Trip", so the suffix would restate it // transaction is the restaurant; which courier delivered it is provenance,
// — "Order - Uber Trip (Uber)". The addresses that actually identify a trip // and it already has a home — the expandable Order details panel renders
// are in the Order details panel, not squeezed into the description. // `expense_metadata.platform` next to its heading (user, 2026-07-28).
if (merchant.toLowerCase().includes(label.toLowerCase())) return `Order - ${merchant}`; //
return `Order - ${merchant} (${label})`; // 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 { export interface IngestResult {
+6 -1
View File
@@ -151,7 +151,12 @@ export async function merchantVerdict(
JOIN expense_metadata em JOIN expense_metadata em
ON em.transaction_id = r.transaction_id ON em.transaction_id = r.transaction_id
OR em.matched_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) AND ($2::int IS NULL OR r.transaction_id <> $2)
ORDER BY t.transaction_date DESC, r.participant_id ORDER BY t.transaction_date DESC, r.participant_id
LIMIT 50`, LIMIT 50`,