From 2c666236b2fdf857859acc95115f351444d9d613 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Wed, 12 Aug 2026 21:07:50 +1000 Subject: [PATCH] orders: share the merchant tidy, and fix two bugs the corpus run found (board 212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tidy existed but lived inside the list page, so the DETAIL page never had it: following a row through showed "Apple" becoming "Apple Pty Ltd." Moved to src/lib/merchant-label.ts and applied on both surfaces. Running it over all 1,451 distinct merchant names — rather than over examples — turned up two real defects: Coburger & Co -> "Coburger &" eBay Commerce Australia Pty Ltd. -> "EBay Commerce Australia" The first strips a suffix that is part of the brand and leaves a dangling connector. The second is the capitalisation rule firing on "starts with a lower-case letter" when what it means is "is a bare domain" — mangling a brand that is deliberately lower-cased. Both are now guarded, and re-running over the full corpus reports 170 names tidied and 0 defects. PRESENTATION ONLY. It merges nothing and must never decide two rows are the same merchant. Amazon.in stays Amazon.in — a different marketplace with different currency and geography, not a name variant — and there is a test asserting it can never equal amazon.com.au. Kogan.com, GOG.com, AliExpress.com and Catch.com.au are real brand names containing a TLD and are left alone. Real unification is the merchant-alias bridge, ticket 176, which has already merged what can be merged safely. The five names that read alike after tidying (ebay/eBay/eBay Inc., menulog/Menulog Pty Ltd, deliveroo/Deliveroo, grab/Grab, paypal/PayPal) are genuinely one merchant each, so reading alike is correct. Lower-case platform SLUGS are deliberately not capitalised: "ebay" -> "Ebay" is wrong, and getting it right is the registry's job, not the stylesheet's. --- src/__tests__/unit/merchant-label.test.ts | 72 +++++++++++++++++++++++ src/app/orders/[entityKey]/page.tsx | 12 +++- src/app/orders/page.tsx | 26 +------- src/lib/merchant-label.ts | 51 ++++++++++++++++ 4 files changed, 134 insertions(+), 27 deletions(-) create mode 100644 src/__tests__/unit/merchant-label.test.ts create mode 100644 src/lib/merchant-label.ts diff --git a/src/__tests__/unit/merchant-label.test.ts b/src/__tests__/unit/merchant-label.test.ts new file mode 100644 index 0000000..7da1eb1 --- /dev/null +++ b/src/__tests__/unit/merchant-label.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { tidyMerchant } from "@/lib/merchant-label"; + +/** + * Board 212. Every fixture below is a real `display_name` from order_feed. + * The cases that matter most are the ones that must NOT change: this is a + * presentational tidy and merging anything here would be a data error wearing + * a stylesheet. + */ +describe("tidyMerchant", () => { + it("capitalises a bare domain used as a name", () => { + expect(tidyMerchant("amazon.com.au")).toBe("Amazon.com.au"); + expect(tidyMerchant("cdkeys.com")).toBe("Cdkeys.com"); + }); + + it("NEVER merges the country marketplaces", () => { + // The single most important assertion in this file. Amazon.in is a + // different marketplace with different currency and geography, and 289 + // orders sit behind it. + expect(tidyMerchant("Amazon.in")).toBe("Amazon.in"); + expect(tidyMerchant("amazon.com.au")).not.toBe(tidyMerchant("Amazon.in")); + }); + + it("leaves real brand names that contain a TLD alone", () => { + for (const brand of ["Kogan.com", "GOG.com", "AliExpress.com", "Catch.com.au", "GeekBuying.com"]) { + expect(tidyMerchant(brand)).toBe(brand); + } + }); + + it("does not uppercase a deliberately lower-cased brand", () => { + // The bug the corpus run found: gating on "starts lower-case" rather than + // on "is a bare domain" turned this into "EBay Commerce Australia". + expect(tidyMerchant("eBay Commerce Australia Pty Ltd.")).toBe("eBay Commerce Australia"); + expect(tidyMerchant("iRobot Australia Pty Ltd")).toBe("iRobot Australia"); + }); + + it("drops trailing corporate suffixes", () => { + expect(tidyMerchant("Apple Pty Ltd.")).toBe("Apple"); + expect(tidyMerchant("Microsoft Pty. Limited")).toBe("Microsoft"); + expect(tidyMerchant("Meta Platforms, Inc.")).toBe("Meta Platforms"); + expect(tidyMerchant("Domino's Pizza Enterprises Limited")).toBe("Domino's Pizza Enterprises"); + expect(tidyMerchant("Blinks Labs GmbH")).toBe("Blinks Labs"); + expect(tidyMerchant("Rasier New Zealand Limited")).toBe("Rasier New Zealand"); + }); + + it("strips two suffixes when a name carries two", () => { + expect(tidyMerchant("Samsung Electronics Co. Ltd.")).toBe("Samsung Electronics"); + expect(tidyMerchant("DiDi Mobility Information Technology Pte. Ltd.")) + .toBe("DiDi Mobility Information Technology"); + }); + + it("does not leave a dangling connector", () => { + // 'Co' is part of this brand. Stripping it produced "Coburger &". + expect(tidyMerchant("Coburger & Co")).toBe("Coburger & Co"); + }); + + it("keeps a name that would be reduced to almost nothing", () => { + // "UT" is too short to stand on its own; better scruffy than cryptic. + expect(tidyMerchant("UT LLC")).toBe("UT LLC"); + }); + + it("normalises whitespace and tolerates junk", () => { + expect(tidyMerchant(" Yaffa Media Pty Ltd ")).toBe("Yaffa Media"); + expect(tidyMerchant("")).toBe(""); + }); + + it("is idempotent", () => { + for (const n of ["amazon.com.au", "Apple Pty Ltd.", "Coburger & Co", "Amazon.in"]) { + expect(tidyMerchant(tidyMerchant(n))).toBe(tidyMerchant(n)); + } + }); +}); diff --git a/src/app/orders/[entityKey]/page.tsx b/src/app/orders/[entityKey]/page.tsx index ccec24f..7433e31 100644 --- a/src/app/orders/[entityKey]/page.tsx +++ b/src/app/orders/[entityKey]/page.tsx @@ -3,6 +3,7 @@ import { use } from "react"; import Link from "next/link"; import { useOrderDetail } from "@/lib/hooks"; +import { tidyMerchant } from "@/lib/merchant-label"; /** * One order: what it was, what happened to it, and what paid for it. @@ -129,7 +130,10 @@ export default function OrderDetailPage({ params }: { params: Promise<{ entityKe {o.ordered_at && <> · {dateFmt.format(new Date(o.ordered_at))}}

- {o.canonical_name || o.display_name || "Order"} + {/* canonical_name is the ORDER title ("DJI Air 3 Fly More Combo") and + is left alone; only the display_name fallback is a merchant name + and wants tidying. */} + {o.canonical_name || tidyMerchant(o.display_name ?? "") || "Order"}

{o.content_class && KIND_LABEL[o.content_class] && ( @@ -164,7 +168,11 @@ export default function OrderDetailPage({ params }: { params: Promise<{ entityKe {o.merchant_name && (
Merchant - {o.merchant_name} + {/* Board 212: the list has tidied this since it shipped and the + detail page did not, so following a row through showed + "Apple" becoming "Apple Pty Ltd." Presentational only — + merges nothing, and Amazon.in stays Amazon.in. */} + {tidyMerchant(o.merchant_name)}
)}
diff --git a/src/app/orders/page.tsx b/src/app/orders/page.tsx index c462516..2aacde6 100644 --- a/src/app/orders/page.tsx +++ b/src/app/orders/page.tsx @@ -3,6 +3,7 @@ import { Suspense, useMemo, useState } from "react"; import Link from "next/link"; import { useOrders } from "@/lib/hooks"; +import { tidyMerchant } from "@/lib/merchant-label"; import type { OrderRow } from "@/lib/order-feed"; /** @@ -75,31 +76,6 @@ const KIND_LABEL: Record = { account_notice: "notice", }; -/** - * Presentational tidy only — this NEVER merges two merchants. The estate holds - * "amazon.com.au", "Amazon.in" and "Amazon Services Australia, Inc." as three - * distinct entities, and Amazon.in must stay separate: it is a different - * marketplace, not a name variant. Actually unifying them is the merchant-alias - * bridge (ticket 176). All this does is stop the same entity from looking - * scruffy: drop the corporate suffix, and capitalise a name that arrived - * lower-cased from a domain. - */ -const CORP_SUFFIX = - /,?\s+(pty\.?\s+ltd\.?|pty\.?\s+limited|p\/l|ltd\.?|limited|inc\.?|llc|pbc|gmbh|b\.?v\.?|s\.?a\.?r\.?l\.?|oü|co\.?)$/i; - -function tidyMerchant(name: string): string { - let n = name.replace(/\s+/g, " ").trim(); - // Strip at most two trailing corporate suffixes ("Pty Ltd." then ","). - for (let i = 0; i < 2; i++) { - const stripped = n.replace(CORP_SUFFIX, "").trim().replace(/,$/, ""); - if (stripped === n || stripped.length < 3) break; - n = stripped; - } - // "amazon.com.au" reads as a machine artefact; "Amazon.com.au" reads as a name. - if (/^[a-z]/.test(n)) n = n[0].toUpperCase() + n.slice(1); - return n; -} - const IN_FLIGHT = new Set(["ordered", "shipped", "out_for_delivery"]); const REVERSED = new Set(["refunded", "returned", "cancelled"]); diff --git a/src/lib/merchant-label.ts b/src/lib/merchant-label.ts new file mode 100644 index 0000000..aa42042 --- /dev/null +++ b/src/lib/merchant-label.ts @@ -0,0 +1,51 @@ +/** + * Presentational tidy for a merchant name (board 212). + * + * PRESENTATION ONLY. It merges nothing and must never be used to decide that + * two rows are the same merchant. Real unification is the merchant-alias bridge + * (ticket 176, `jobs/merchant_merge.py`), which has already merged what can be + * merged safely; what is left on screen is one entity looking scruffy, not two + * entities that should be one. + * + * `Amazon.in` MUST NOT become `amazon.com.au`. They are different marketplaces + * with different currency and geography — a genuine country distinction, not a + * name variant. `Kogan.com`, `GOG.com`, `AliExpress.com` and `Catch.com.au` are + * real brand names that happen to contain a TLD and are already correct. + * + * Two rules, both mechanical: + * 1. drop trailing corporate suffixes + * 2. capitalise a name that arrived lower-cased FROM A DOMAIN + */ + +const CORP_SUFFIX = + /,?\s+(pty\.?\s+ltd\.?|pty\.?\s+limited|pte\.?\s+ltd\.?|pte\.?|p\/l|ltd\.?|limited|inc\.?|llc|pbc|gmbh|b\.?v\.?|s\.?a\.?r\.?l\.?|oü|co\.?)$/i; + +/** A bare domain used as a name: all lower case, and a dotted TLD. */ +const BARE_DOMAIN = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(\.[a-z]{2,})?$/; + +/** + * Stripping must not leave a dangling connector. `Coburger & Co` is a brand + * whose last word happens to match the suffix list, and removing it produced + * `Coburger &` — visibly broken, and the kind of thing that only shows up when + * you run the rule over the real corpus rather than over examples you chose. + */ +const DANGLING = /(\s[&+-]|\sand)$/i; + +export function tidyMerchant(name: string): string { + let n = name.replace(/\s+/g, " ").trim(); + + // At most two passes: "Samsung Electronics Co. Ltd." needs Ltd. then Co. + for (let i = 0; i < 2; i++) { + const stripped = n.replace(CORP_SUFFIX, "").trim().replace(/,$/, ""); + if (stripped === n || stripped.length < 3 || DANGLING.test(stripped)) break; + n = stripped; + } + + // "amazon.com.au" reads as a machine artefact; "Amazon.com.au" reads as a + // name. Gated on BARE_DOMAIN, not on "starts with a lower-case letter": the + // looser test turned `eBay Commerce Australia Pty Ltd.` into `EBay Commerce + // Australia`, mangling a brand that is deliberately lower-cased. + if (BARE_DOMAIN.test(n)) n = n[0].toUpperCase() + n.slice(1); + + return n; +}