fix(web): parse real merchant out of card-string :MCI: segment
Card charges were showing as raw masked-card strings with no suggestion. prettyMerchant/suggestCategory now extract the merchant after the last :MCI: segment (e.g. "...:MCI:ANTHROPIC 1" -> "ANTHROPIC"), and the keyword map is grounded in real uncategorized merchants (card-processed software/cloud, cross-border marketplaces, local lenders, grocers, carriers, electronics, gaming). Also fixes a Cyrillic \b word-boundary bug (JS's \b never matches non-ASCII text) and moves CAFE/КАФЕ to the Coffee rule instead of Food & Drink.
This commit is contained in:
parent
b9e58cb319
commit
79dd6ed591
3 changed files with 159 additions and 57 deletions
|
|
@ -10,7 +10,7 @@ import { categoryStyle } from "@/ds/categoryStyle";
|
||||||
import { Icon } from "@/ds/icons";
|
import { Icon } from "@/ds/icons";
|
||||||
import { MASKED, tugrikRaw } from "@/ds/money";
|
import { MASKED, tugrikRaw } from "@/ds/money";
|
||||||
import { CategorizeSheet } from "./CategorizeSheet";
|
import { CategorizeSheet } from "./CategorizeSheet";
|
||||||
import { suggestCategory } from "./suggestCategory";
|
import { suggestCategory, extractMerchant, looksLikeCardNumber } from "./suggestCategory";
|
||||||
import { accountingStrings as s } from "./strings";
|
import { accountingStrings as s } from "./strings";
|
||||||
import { useHiddenAmounts } from "./useHiddenAmounts";
|
import { useHiddenAmounts } from "./useHiddenAmounts";
|
||||||
|
|
||||||
|
|
@ -74,10 +74,11 @@ function buildQueue(txns: Txn[]): ReviewItem[] {
|
||||||
const CARD_LIKE = /^(\d{3,})\*{2,}(\d{2,})/;
|
const CARD_LIKE = /^(\d{3,})\*{2,}(\d{2,})/;
|
||||||
|
|
||||||
function prettyMerchant(raw: string): string {
|
function prettyMerchant(raw: string): string {
|
||||||
|
const name = extractMerchant(raw); // recovers "ANTHROPIC"/"TAOBAO" from card strings
|
||||||
|
if (name && !looksLikeCardNumber(name)) return name;
|
||||||
const head = (raw.split(":")[0] ?? raw).trim();
|
const head = (raw.split(":")[0] ?? raw).trim();
|
||||||
const m = head.match(CARD_LIKE);
|
const m = head.match(CARD_LIKE);
|
||||||
if (m) return `Карт •••• ${m[2]}`;
|
return m ? `Карт •••• ${m[2]}` : head || raw;
|
||||||
return head || raw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Row extends ReviewItem {
|
interface Row extends ReviewItem {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { suggestCategory } from "./suggestCategory";
|
import { suggestCategory, extractMerchant, looksLikeCardNumber } from "./suggestCategory";
|
||||||
import type { Category } from "@/api/schemas";
|
import type { Category } from "@/api/schemas";
|
||||||
|
|
||||||
function cat(name: string, kind: "income" | "expense" = "expense", depth = 1): Category {
|
function cat(name: string, kind: "income" | "expense" = "expense", depth = 1): Category {
|
||||||
|
|
@ -15,67 +15,145 @@ const CATEGORIES: Category[] = [
|
||||||
cat("Entertainment"),
|
cat("Entertainment"),
|
||||||
cat("Groceries"),
|
cat("Groceries"),
|
||||||
cat("Insurance"),
|
cat("Insurance"),
|
||||||
|
cat("Electronics"),
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("suggestCategory", () => {
|
describe("extractMerchant", () => {
|
||||||
it("matches loan/leasing keywords (Cyrillic)", () => {
|
it("pulls the real merchant out of a card string's :MCI: segment", () => {
|
||||||
expect(suggestCategory("ЛИЗИНГ ХХК", CATEGORIES)).toBe("Bills & Services"); // no "Loan" category present
|
expect(extractMerchant("554835******6886:30-07-2026 11:02:10:MCI:ANTHROPIC 1")).toBe("ANTHROPIC");
|
||||||
|
expect(extractMerchant("554835******6886:13-08-2026 12:13:52:MCI:WWW HOSTI 5")).toBe("WWW HOSTI");
|
||||||
|
expect(extractMerchant("554835******6886:11-08-2026 10:07:35:MCI:TAOBAO CO 4")).toBe("TAOBAO CO");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers Loan when it exists", () => {
|
it("falls back to the head before the first colon when there's no :MCI: segment", () => {
|
||||||
|
expect(extractMerchant("ЛИЗИНГ ХХК")).toBe("ЛИЗИНГ ХХК");
|
||||||
|
expect(extractMerchant("1234******5678")).toBe("1234******5678");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suggestCategory — real card charges (:MCI: merchant extraction)", () => {
|
||||||
|
it("suggests Bills & Services for ANTHROPIC (no Software/Subscriptions category present)", () => {
|
||||||
|
expect(suggestCategory("554835******6886:30-07-2026 11:02:10:MCI:ANTHROPIC 1", CATEGORIES)).toBe(
|
||||||
|
"Bills & Services",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers Software over Bills & Services when the category exists", () => {
|
||||||
|
const withSoftware = [...CATEGORIES, cat("Software")];
|
||||||
|
expect(suggestCategory("554835******6886:30-07-2026 11:02:10:MCI:ANTHROPIC 1", withSoftware)).toBe("Software");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suggests Bills & Services for a hosting merchant (WWW HOSTI)", () => {
|
||||||
|
expect(suggestCategory("554835******6886:13-08-2026 12:13:52:MCI:WWW HOSTI 5", CATEGORIES)).toBe(
|
||||||
|
"Bills & Services",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suggests Shopping for TAOBAO", () => {
|
||||||
|
expect(suggestCategory("554835******6886:11-08-2026 10:07:35:MCI:TAOBAO CO 4", CATEGORIES)).toBe("Shopping");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suggestCategory — local merchants", () => {
|
||||||
|
it("matches ТОКИ (non-bank lender) to Loan", () => {
|
||||||
|
const withLoan = [...CATEGORIES, cat("Loan")];
|
||||||
|
expect(suggestCategory("ТОКИ ББСБ ХХК", withLoan)).toBe("Loan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to Bills & Services when Loan doesn't exist", () => {
|
||||||
|
expect(suggestCategory("ЛИЗИНГ ХХК", CATEGORIES)).toBe("Bills & Services");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the Cyrillic ЗЭЭЛ keyword (word-boundary-free, Cyrillic-safe)", () => {
|
||||||
const withLoan = [...CATEGORIES, cat("Loan")];
|
const withLoan = [...CATEGORIES, cat("Loan")];
|
||||||
expect(suggestCategory("ХААН ЗЭЭЛ ТӨЛБӨР", withLoan)).toBe("Loan");
|
expect(suggestCategory("ХААН ЗЭЭЛ ТӨЛБӨР", withLoan)).toBe("Loan");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches TUSHIG to Groceries", () => {
|
||||||
|
expect(suggestCategory("TUSHIG SUPERMARKET", CATEGORIES)).toBe("Groceries");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches other grocery keywords", () => {
|
||||||
|
expect(suggestCategory("NOMIN SUPERMARKET", CATEGORIES)).toBe("Groceries");
|
||||||
|
expect(suggestCategory("CU-24 CONVENIENCE", CATEGORIES)).toBe("Groceries");
|
||||||
|
expect(suggestCategory("ХҮНСНИЙ ДЭЛГҮҮР", CATEGORIES)).toBe("Groceries");
|
||||||
|
});
|
||||||
|
|
||||||
it("matches insurance keywords", () => {
|
it("matches insurance keywords", () => {
|
||||||
expect(suggestCategory("MONGOL ДААТГАЛ LLC", CATEGORIES)).toBe("Insurance");
|
expect(suggestCategory("MONGOL ДААТГАЛ LLC", CATEGORIES)).toBe("Insurance");
|
||||||
expect(suggestCategory("SOME INSURANCE CO", CATEGORIES)).toBe("Insurance");
|
expect(suggestCategory("SOME INSURANCE CO", CATEGORIES)).toBe("Insurance");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches grocery keywords", () => {
|
it("matches electronics retailers", () => {
|
||||||
expect(suggestCategory("NOMIN SUPERMARKET", CATEGORIES)).toBe("Groceries");
|
expect(suggestCategory("MAGIC TECH STORE", CATEGORIES)).toBe("Electronics");
|
||||||
expect(suggestCategory("CU-24 CONVENIENCE", CATEGORIES)).toBe("Groceries");
|
expect(suggestCategory("ITOPIA MALL", CATEGORIES)).toBe("Electronics");
|
||||||
expect(suggestCategory("ХҮНСНИЙ ДЭЛГҮҮР", CATEGORIES)).toBe("Groceries");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches food & drink keywords", () => {
|
it("matches mobile carriers to Bills & Services", () => {
|
||||||
expect(suggestCategory("KFC ULAANBAATAR", CATEGORIES)).toBe("Food & Drink");
|
expect(suggestCategory("MOBICOM PAYMENT", CATEGORIES)).toBe("Bills & Services");
|
||||||
expect(suggestCategory("КАФЕ МОДЕРН", CATEGORIES)).toBe("Food & Drink");
|
expect(suggestCategory("UNITEL TOP UP", CATEGORIES)).toBe("Bills & Services");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches a coffee-only merchant (no food keyword present)", () => {
|
it("matches apparel/shopping brands", () => {
|
||||||
expect(suggestCategory("TOM N TOMS COFFEE", CATEGORIES)).toBe("Coffee");
|
expect(suggestCategory("CONVERSE STORE", CATEGORIES)).toBe("Shopping");
|
||||||
expect(suggestCategory("STARBUCKS COFFEE", CATEGORIES)).toBe("Coffee");
|
expect(suggestCategory("SANT ASAR TRADE", CATEGORIES)).toBe("Shopping");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches gaming merchants to Entertainment", () => {
|
||||||
|
expect(suggestCategory("STEAM GAMES", CATEGORIES)).toBe("Entertainment");
|
||||||
|
expect(suggestCategory("PGAMING WALLET", CATEGORIES)).toBe("Entertainment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches CAFE/КАФЕ to Coffee, not Food & Drink", () => {
|
||||||
|
expect(suggestCategory("КАФЕ МОДЕРН", CATEGORIES)).toBe("Coffee");
|
||||||
|
expect(suggestCategory("TOM CAFE LLC", CATEGORIES)).toBe("Coffee");
|
||||||
expect(suggestCategory("КОФЕ ЦЭГ", CATEGORIES)).toBe("Coffee");
|
expect(suggestCategory("КОФЕ ЦЭГ", CATEGORIES)).toBe("Coffee");
|
||||||
|
expect(suggestCategory("STARBUCKS COFFEE", CATEGORIES)).toBe("Coffee");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches transport keywords", () => {
|
it("matches restaurant/food keywords (no CAFE overlap) to Food & Drink", () => {
|
||||||
|
expect(suggestCategory("KFC ULAANBAATAR", CATEGORIES)).toBe("Food & Drink");
|
||||||
|
expect(suggestCategory("ХООЛНЫ ГАЗАР", CATEGORIES)).toBe("Food & Drink");
|
||||||
|
expect(suggestCategory("BURGER KING", CATEGORIES)).toBe("Food & Drink");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches transport/fuel keywords", () => {
|
||||||
expect(suggestCategory("UBCAB TRIP", CATEGORIES)).toBe("Transport");
|
expect(suggestCategory("UBCAB TRIP", CATEGORIES)).toBe("Transport");
|
||||||
expect(suggestCategory("ШАТАХУУНЫ СТАНЦ", CATEGORIES)).toBe("Transport");
|
expect(suggestCategory("ШАТАХУУНЫ СТАНЦ", CATEGORIES)).toBe("Transport");
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("matches shopping keywords", () => {
|
describe("suggestCategory — no signal", () => {
|
||||||
expect(suggestCategory("CONVERSE STORE", CATEGORIES)).toBe("Shopping");
|
it("returns null for a masked card number with no :MCI: merchant segment", () => {
|
||||||
// Cyrillic "НОМИН" doesn't match the Latin "NOMIN" keyword, so this only
|
|
||||||
// hits the Shopping rule's "ДЭЛГҮҮР" ("store") keyword.
|
|
||||||
expect(suggestCategory("НОМИН ДЭЛГҮҮР ХХК", CATEGORIES)).toBe("Shopping");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for a masked card number", () => {
|
|
||||||
expect(suggestCategory("554835******6886:13-08-2026 09:41:22", CATEGORIES)).toBeNull();
|
expect(suggestCategory("554835******6886:13-08-2026 09:41:22", CATEGORIES)).toBeNull();
|
||||||
expect(suggestCategory("1234******5678", CATEGORIES)).toBeNull();
|
expect(suggestCategory("1234******5678", CATEGORIES)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when nothing matches", () => {
|
it("returns null when nothing matches", () => {
|
||||||
expect(suggestCategory("SOME RANDOM MERCHANT XYZ", CATEGORIES)).toBeNull();
|
expect(suggestCategory("TLJ CENTR", CATEGORIES)).toBeNull();
|
||||||
|
expect(suggestCategory("TSENGELDE", CATEGORIES)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when the only matching candidate category doesn't exist", () => {
|
it("returns null when every matching rule's candidates are all absent", () => {
|
||||||
const noInsurance = CATEGORIES.filter((c) => c.name !== "Insurance");
|
// The phone-carrier rule has a single candidate ("Bills & Services") with
|
||||||
expect(suggestCategory("ДААТГАЛ", noInsurance)).toBeNull();
|
// no fallback, unlike Loan/Insurance/Groceries/Electronics which fall
|
||||||
|
// back to a broader category — removing it leaves nothing to suggest.
|
||||||
|
const noBills = CATEGORIES.filter((c) => c.name !== "Bills & Services");
|
||||||
|
expect(suggestCategory("MOBICOM PAYMENT", noBills)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for empty input", () => {
|
it("returns null for empty input", () => {
|
||||||
expect(suggestCategory("", CATEGORIES)).toBeNull();
|
expect(suggestCategory("", CATEGORIES)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("looksLikeCardNumber", () => {
|
||||||
|
it("recognizes a masked card head as a card number", () => {
|
||||||
|
expect(looksLikeCardNumber("554835******6886")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag a normal merchant name", () => {
|
||||||
|
expect(looksLikeCardNumber("ANTHROPIC")).toBe(false);
|
||||||
|
expect(looksLikeCardNumber("ТОКИ ББСБ ХХК")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,72 @@
|
||||||
import type { Category } from "@/api/schemas";
|
import type { Category } from "@/api/schemas";
|
||||||
|
|
||||||
/** A merchant/matchKey string that is mostly a masked card number (e.g.
|
/** Card/online charges arrive as a masked-card string with the real merchant
|
||||||
* `"554835******6886:13-08-2026 09:41:22"`) carries no merchant-name signal —
|
* buried after the last `:MCI:` segment, e.g.
|
||||||
* never suggest a category for these, let the user pick. */
|
* `"554835******6886:30-07-2026 11:02:10:MCI:ANTHROPIC 1"` → `"ANTHROPIC"`.
|
||||||
function looksLikeCardNumber(s: string): boolean {
|
* Returns the cleaned merchant name (trailing sequence number stripped), or the
|
||||||
const head = s.split(":")[0] ?? s;
|
* head before the first colon for normal names. */
|
||||||
|
export function extractMerchant(raw: string): string {
|
||||||
|
const s = (raw ?? "").trim();
|
||||||
|
const mci = s.split(/:MCI:/i);
|
||||||
|
if (mci.length > 1) {
|
||||||
|
const m = mci[mci.length - 1].replace(/\s+\d+$/, "").trim();
|
||||||
|
if (m) return m;
|
||||||
|
}
|
||||||
|
return (s.split(":")[0] ?? s).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the string is (still) mostly a masked card number with no merchant
|
||||||
|
* signal — e.g. a card charge with no `:MCI:` merchant segment. */
|
||||||
|
export function looksLikeCardNumber(s: string): boolean {
|
||||||
|
const head = (s.split(":")[0] ?? s).trim();
|
||||||
if (/^\d{4,}\*{2,}/.test(head)) return true;
|
if (/^\d{4,}\*{2,}/.test(head)) return true;
|
||||||
const digits = (s.match(/\d/g) ?? []).length;
|
const digits = (s.match(/\d/g) ?? []).length;
|
||||||
const stars = (s.match(/\*/g) ?? []).length;
|
const stars = (s.match(/\*/g) ?? []).length;
|
||||||
return stars >= 2 && digits / Math.max(s.length, 1) > 0.4;
|
return stars >= 2 && digits / Math.max(s.length, 1) > 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keyword rule: if the merchant text matches `pattern`, suggest the first
|
|
||||||
* name in `candidates` that actually exists in the user's category list —
|
|
||||||
* later candidates are fallbacks for accounts that don't have the specific
|
|
||||||
* category. Case-insensitive, Cyrillic + Latin. */
|
|
||||||
interface Rule {
|
interface Rule {
|
||||||
pattern: RegExp;
|
pattern: RegExp;
|
||||||
candidates: string[];
|
candidates: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kept small and ordered: earlier rules win when a merchant string matches
|
// Ordered — earlier rules win when a merchant matches more than one. Keyed to
|
||||||
// more than one (e.g. a name containing both "ХООЛ" and "CAFE"). Add new
|
// the REAL uncategorized merchants in the data (incl. the merchant extracted
|
||||||
|
// from card strings). Each rule's candidates are tried in order; the first that
|
||||||
|
// exists in the user's category list is used, so a specific child (Groceries,
|
||||||
|
// Loan, Electronics) falls back to its depth-1 parent when absent. Add new
|
||||||
// merchants here rather than growing suggestCategory()'s logic.
|
// merchants here rather than growing suggestCategory()'s logic.
|
||||||
const RULES: Rule[] = [
|
const RULES: Rule[] = [
|
||||||
{ pattern: /ЛИЗИНГ|ЗЭЭЛ|ББСБ|LOAN/i, candidates: ["Loan", "Bills & Services"] },
|
{ pattern: /ANTHROPIC|OPENAI|CLAUDE|GITHUB|VERCEL|\bAWS\b|HOSTI|\bWWW\b|GOOGLE|NETLIFY/i, candidates: ["Software", "Subscriptions", "Bills & Services"] },
|
||||||
{ pattern: /ДААТГАЛ|INSURANCE/i, candidates: ["Insurance"] },
|
{ pattern: /TAOBAO|ALIEXPRESS|ALIPAY|AMAZON|WISH/i, candidates: ["Shopping"] },
|
||||||
{ pattern: /MART|МАРКЕТ|CU-|NOMIN|ХҮНС|GROCER/i, candidates: ["Groceries"] },
|
// Note: no `\b` word-boundary around the Cyrillic keywords — JS's `\b` is
|
||||||
{ pattern: /ХООЛ|CAFE|КАФЕ|RESTAURANT|KFC|PIZZA/i, candidates: ["Food & Drink"] },
|
// defined in terms of `\w` ([A-Za-z0-9_]), which doesn't include Cyrillic
|
||||||
{ pattern: /КОФЕ|COFFEE|TOM N TOMS/i, candidates: ["Coffee"] },
|
// letters, so `\bЗЭЭЛ\b` would never match any Cyrillic text at all.
|
||||||
{ pattern: /TAXI|UBCAB|ТЭЭВЭР|PETROL|ШАТАХУУН/i, candidates: ["Transport"] },
|
{ pattern: /ЛИЗИНГ|ТОКИ|АВДАР|ББСБ|ЗЭЭЛ|LOAN|LEASING/i, candidates: ["Loan", "Bills & Services"] },
|
||||||
{ pattern: /STORE|SHOP|ДЭЛГҮҮР|CONVERSE/i, candidates: ["Shopping"] },
|
{ pattern: /ДААТГАЛ|INSURANCE/i, candidates: ["Insurance", "Bills & Services"] },
|
||||||
|
{ pattern: /MOBICOM|UNITEL|SKYTEL|GMOBILE|ONDO/i, candidates: ["Bills & Services"] },
|
||||||
|
{ pattern: /TUSHIG|CARREFOUR|NOMIN|MART|МАРКЕТ|CU-|GS25|MINII|ХҮНС|GROCER/i, candidates: ["Groceries", "Food & Drink"] },
|
||||||
|
{ pattern: /MAGIC TEC|ELECTRONI|ITOPIA|TOPAZ|ELECTRO/i, candidates: ["Electronics", "Shopping"] },
|
||||||
|
{ pattern: /PGAMING|GAMING|STEAM|PLAYSTATION|XBOX/i, candidates: ["Entertainment"] },
|
||||||
|
// CAFE/КАФЕ live here (not in the Food & Drink rule below) per the latest
|
||||||
|
// keyword grounding — a bare "cafe" name reads as a coffee spot first.
|
||||||
|
{ pattern: /КОФЕ|COFFEE|TOM N TOMS|CAFE|КАФЕ/i, candidates: ["Coffee", "Food & Drink"] },
|
||||||
|
{ pattern: /ХООЛ|RESTAURANT|KFC|PIZZA|BURGER/i, candidates: ["Food & Drink"] },
|
||||||
|
{ pattern: /TAXI|UBCAB|ТЭЭВЭР|PETROL|ШАТАХУУН|BENZIN/i, candidates: ["Transport"] },
|
||||||
|
{ pattern: /CONVERSE|WARRIOR|PASTEL|OLYMPIC|НЭКСУС|SANT ASAR|STORE|SHOP|ДЭЛГҮҮР/i, candidates: ["Shopping"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suggests one of the user's existing categories for an uncategorized
|
* Suggests one of the user's existing categories for an uncategorized merchant,
|
||||||
* merchant, from a small keyword heuristic. Returns `null` (no suggestion —
|
* from a keyword heuristic keyed to the real merchants seen in the data. The
|
||||||
* the user picks manually) when nothing matches, or when every matching
|
* merchant name is first extracted from any card string (`:MCI:ANTHROPIC`).
|
||||||
* rule's candidates are all absent from `categories`, or when `merchant`
|
* Returns `null` (user picks) when nothing matches, when the matching rule's
|
||||||
* looks like a masked card number rather than a real merchant name.
|
* candidates are all absent, or when the string is a card number with no
|
||||||
|
* recoverable merchant.
|
||||||
*/
|
*/
|
||||||
export function suggestCategory(merchant: string, categories: Category[]): string | null {
|
export function suggestCategory(merchant: string, categories: Category[]): string | null {
|
||||||
const text = merchant?.trim();
|
const text = extractMerchant(merchant);
|
||||||
if (!text) return null;
|
if (!text || looksLikeCardNumber(text)) return null;
|
||||||
if (looksLikeCardNumber(text)) return null;
|
|
||||||
|
|
||||||
const names = new Set(categories.map((c) => c.name));
|
const names = new Set(categories.map((c) => c.name));
|
||||||
for (const rule of RULES) {
|
for (const rule of RULES) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue