intake: match merchants on whole words, not substrings

merchantKey("Bigals Pizza") contains "iga", so GROCERY_PATTERN filed a pizza
shop as a grocer and merchantMatches matched it against every IGA on the same
date and total. Short names in a substring list collide with ordinary words,
and the collision reads as a correct classification rather than a bug.

GROCERY_PATTERN is now a word-boundary alternation; merchantMatches asks
whether one name's words appear as a contiguous run inside the other's, which
keeps Coles <-> Coles (Wyndham Vale) and Order - Coles (Wyndham Vale).

Also collapses the third PLATFORM_LABEL copy: order-ingestion's was exported
and read nowhere, order-details.tsx had its own, and the intake one — the only
live copy — was missing `uber`. One map, in intake-rules.
This commit is contained in:
2026-08-21 17:52:25 +10:00
parent a4e5f540cc
commit be3d10d4f1
3 changed files with 36 additions and 22 deletions
+4 -7
View File
@@ -13,12 +13,9 @@ import {
type OrderReceipt, type OrderReceipt,
type OrderRating, type OrderRating,
} from "@/lib/hooks"; } from "@/lib/hooks";
// One platform vocabulary, shared with the intake cards — three copies had
const PLATFORM_LABEL: Record<string, string> = { // already drifted apart (the intake one was missing `uber`).
doordash: "DoorDash", import { platformLabel } from "@/lib/intake-rules";
ubereats: "Uber Eats",
uber: "Uber",
};
/** /**
* Who records verdicts. A two-person household with one primary user: the * Who records verdicts. A two-person household with one primary user: the
@@ -99,7 +96,7 @@ export function OrderDetails({
<p className="text-xs text-zinc-500"> <p className="text-xs text-zinc-500">
Order details Order details
{receipt.platform && ( {receipt.platform && (
<span className="ml-1.5 text-zinc-400">{PLATFORM_LABEL[receipt.platform] ?? receipt.platform}</span> <span className="ml-1.5 text-zinc-400">{platformLabel(receipt.platform)}</span>
)} )}
</p> </p>
{receipt.card_last4 && ( {receipt.card_last4 && (
+32 -8
View File
@@ -43,6 +43,7 @@ export function platformForSource(source: IntakeSource): string | null {
export const PLATFORM_LABEL: Record<string, string> = { export const PLATFORM_LABEL: Record<string, string> = {
ubereats: "Uber Eats", ubereats: "Uber Eats",
doordash: "DoorDash", doordash: "DoorDash",
uber: "Uber",
}; };
export function platformLabel(platform: string | null): string { export function platformLabel(platform: string | null): string {
@@ -51,10 +52,16 @@ export function platformLabel(platform: string | null): string {
/** /**
* A small pattern list, not an exact lookup (plan §2, Codex #6). Uber Eats prints * A small pattern list, not an exact lookup (plan §2, Codex #6). Uber Eats prints
* `Coles (Wyndham Vale)`, a PDF may print `Woolworths Group Limited`; substring on * `Coles (Wyndham Vale)`, a PDF may print `Woolworths Group Limited`; a
* the normalised name covers both without a merchant master-data subsystem. * word-boundary alternation on the normalised name covers both without a
* merchant master-data subsystem.
*
* The boundaries are load-bearing, not tidiness: a bare substring made
* `Bigals Pizza` a grocer, because "b-IGA-ls" contains `iga`. Short names in a
* substring list collide with ordinary words, and the collision reads as a
* correct classification.
*/ */
export const GROCERY_PATTERN = /coles|woolworths|woolies|aldi|iga|foodworks|costco/i; export const GROCERY_PATTERN = /\b(coles|woolworths|woolies|aldi|iga|foodworks|costco)\b/i;
/** /**
* The normalised merchant used for matching and for the remembered-kind table. * The normalised merchant used for matching and for the remembered-kind table.
@@ -219,10 +226,27 @@ export function toLineItems(items: ExtractedItem[]): StoredLineItem[] {
}); });
} }
/** Does the stored merchant name denote the extracted one? Exact key, or one contains the other. */ /** Is `needle` a contiguous run of whole words inside `hay`? */
function containsWordRun(hay: string[], needle: string[]): boolean {
if (!needle.length || needle.length > hay.length) return false;
for (let i = 0; i + needle.length <= hay.length; i++) {
if (needle.every((w, j) => hay[i + j] === w)) return true;
}
return false;
}
/**
* Does the stored merchant name denote the extracted one? One name's words must
* appear as a contiguous run inside the other's — so `Coles` matches
* `Coles (Wyndham Vale)` and `Order - Coles (Wyndham Vale)`, which is spelling,
* not widening.
*
* Whole words, never a raw substring: `merchantKey("Bigals Pizza")` contains
* `iga`, so a pizza shop matched every IGA on the same date and total.
*/
export function merchantMatches(stored: string | null | undefined, extracted: string | null | undefined): boolean { export function merchantMatches(stored: string | null | undefined, extracted: string | null | undefined): boolean {
const a = merchantKey(stored); const a = merchantKey(stored).split(" ").filter(Boolean);
const b = merchantKey(extracted); const b = merchantKey(extracted).split(" ").filter(Boolean);
if (!a || !b) return false; if (!a.length || !b.length) return false;
return a === b || a.includes(b) || b.includes(a); return containsWordRun(a, b) || containsWordRun(b, a);
} }
-7
View File
@@ -12,13 +12,6 @@ export const CUTOVER_DATE = "2026-01-09";
*/ */
export const DEFAULT_OWNER_ID = 1; export const DEFAULT_OWNER_ID = 1;
/** Human labels for the platform a receipt came from. */
export const PLATFORM_LABEL: Record<ParsedOrder["platform"], string> = {
doordash: "DoorDash",
ubereats: "Uber Eats",
uber: "Uber",
};
/** /**
* Transaction description. * Transaction description.
* *