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;
+}