From ed4f7ca97b63164eaee44f49df486738693efb46 Mon Sep 17 00:00:00 2001 From: Munkherdene Date: Sun, 23 Aug 2026 00:26:56 +0800 Subject: [PATCH 1/3] fix(web): categorize queue excludes salary/income/transfers (only uncategorized spending) --- src/features/accounting/CategorizeReview.tsx | 5 +++++ src/features/accounting/TransactionList.tsx | 10 +++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/features/accounting/CategorizeReview.tsx b/src/features/accounting/CategorizeReview.tsx index 80522ea..12a09d8 100644 --- a/src/features/accounting/CategorizeReview.tsx +++ b/src/features/accounting/CategorizeReview.tsx @@ -34,6 +34,11 @@ function buildQueue(txns: Txn[]): ReviewItem[] { const groups = new Map(); for (const t of txns) { if (t.category) continue; // defensive — the fetch already scopes to Uncategorized + // Only uncategorized SPENDING needs sorting. Salary (auto-detected income), + // any income, and transfers between accounts are not expenses to categorize. + if (t.salary === true) continue; + if (t.direction === "income") continue; + if (t.transfer === true) continue; const key = t.matchKey || t.title; if (!key) continue; const amount = parseFloat(t.amount) || 0; diff --git a/src/features/accounting/TransactionList.tsx b/src/features/accounting/TransactionList.tsx index 7439a46..9729d81 100644 --- a/src/features/accounting/TransactionList.tsx +++ b/src/features/accounting/TransactionList.tsx @@ -163,7 +163,15 @@ export function TransactionList() { const categoryOptions = useMemo(() => categoriesIn(all), [all]); const hasUncategorized = useMemo( - () => all.some((txn) => !txn.category && (txn.matchKey || txn.title)), + () => + all.some( + (txn) => + !txn.category && + txn.salary !== true && + txn.transfer !== true && + txn.direction !== "income" && + (txn.matchKey || txn.title), + ), [all], ); From b9e58cb319dd1e422ab4a45b67feab1260195cd7 Mon Sep 17 00:00:00 2001 From: Munkherdene Date: Sun, 23 Aug 2026 00:35:21 +0800 Subject: [PATCH 2/3] feat(web): batch categorizer with auto-suggested categories + coverage --- src/features/accounting/CategorizeReview.tsx | 335 ++++++++++++++---- src/features/accounting/strings.ts | 14 +- .../accounting/suggestCategory.test.ts | 81 +++++ src/features/accounting/suggestCategory.ts | 56 +++ 4 files changed, 415 insertions(+), 71 deletions(-) create mode 100644 src/features/accounting/suggestCategory.test.ts create mode 100644 src/features/accounting/suggestCategory.ts diff --git a/src/features/accounting/CategorizeReview.tsx b/src/features/accounting/CategorizeReview.tsx index 12a09d8..9ca4276 100644 --- a/src/features/accounting/CategorizeReview.tsx +++ b/src/features/accounting/CategorizeReview.tsx @@ -4,11 +4,13 @@ import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { useCategories, useTransactions, todayLocalDate } from "@/api/hooks/reads"; import { useCategorize } from "@/api/hooks/mutations"; -import type { Txn } from "@/api/schemas"; +import type { Category, Txn } from "@/api/schemas"; import { Card, EmptyState, IconChip, MercuryButton } from "@/ds"; import { categoryStyle } from "@/ds/categoryStyle"; import { Icon } from "@/ds/icons"; import { MASKED, tugrikRaw } from "@/ds/money"; +import { CategorizeSheet } from "./CategorizeSheet"; +import { suggestCategory } from "./suggestCategory"; import { accountingStrings as s } from "./strings"; import { useHiddenAmounts } from "./useHiddenAmounts"; @@ -26,6 +28,17 @@ function threeMonthsAgo(base: Date = new Date()): Date { return new Date(base.getFullYear(), base.getMonth() - 3, base.getDate()); } +/** Spending transactions worth reviewing: excludes auto-detected salary + * deposits, any income, and inter-account transfers — those aren't expenses + * to categorize. Shared by the uncategorized queue and the coverage + * denominator so both count the same population. */ +function isReviewableSpend(t: Txn): boolean { + if (t.salary === true) return false; + if (t.direction === "income") return false; + if (t.transfer === true) return false; + return Boolean(t.matchKey || t.title); +} + /** Groups uncategorized transactions by matchKey, biggest total spend first — * a handful of taps then covers most of the uncategorized money instead of * burning through many trivial merchants. Ports the grouping in @@ -34,11 +47,7 @@ function buildQueue(txns: Txn[]): ReviewItem[] { const groups = new Map(); for (const t of txns) { if (t.category) continue; // defensive — the fetch already scopes to Uncategorized - // Only uncategorized SPENDING needs sorting. Salary (auto-detected income), - // any income, and transfers between accounts are not expenses to categorize. - if (t.salary === true) continue; - if (t.direction === "income") continue; - if (t.transfer === true) continue; + if (!isReviewableSpend(t)) continue; const key = t.matchKey || t.title; if (!key) continue; const amount = parseFloat(t.amount) || 0; @@ -59,11 +68,165 @@ function buildQueue(txns: Txn[]): ReviewItem[] { return Array.from(groups.values()).sort((a, b) => b.total - a.total); } +// Masked card strings like "554835******6886:13-08-2026 09:41:22" carry a +// trailing timestamp and no real merchant name — collapse them to a short +// "card ending in ####" label instead of dumping the raw string on the row. +const CARD_LIKE = /^(\d{3,})\*{2,}(\d{2,})/; + +function prettyMerchant(raw: string): string { + const head = (raw.split(":")[0] ?? raw).trim(); + const m = head.match(CARD_LIKE); + if (m) return `Карт •••• ${m[2]}`; + return head || raw; +} + +interface Row extends ReviewItem { + displayName: string; + suggestion: string | null; +} + +function toRows(queue: ReviewItem[], categories: Category[]): Row[] { + return queue.map((item) => ({ + ...item, + displayName: prettyMerchant(item.merchant), + suggestion: suggestCategory(item.merchant, categories), + })); +} + +const checkSvg = ( + + + +); + +function ProgressBar({ percent }: { percent: number }) { + return ( +
+
+
+ ); +} + +function ReviewRow({ + row, + hidden, + busy, + removing, + onConfirm, + onPick, +}: { + row: Row; + hidden: boolean; + busy: boolean; + removing: boolean; + onConfirm: (row: Row) => void; + onPick: (row: Row) => void; +}) { + const chip = row.suggestion ? categoryStyle(row.suggestion, false) : null; + + return ( +
+
+ +
+ + {row.displayName} + + + ×{row.count} · {hidden ? MASKED : tugrikRaw(row.total)} + +
+
+ +
+ + {row.suggestion ? ( + + ) : null} +
+
+ ); +} + /** - * Full-screen categorize-review flow at `/accounting/review` (ports - * `CategorizeReviewView.swift`): groups the last three months' uncategorized - * transactions by merchant, biggest spend first, and asks the user to assign - * or skip a category one merchant at a time. + * Batch categorize-review screen at `/accounting/review`: every uncategorized + * merchant from the last three months, biggest spend first, in one scannable + * list with an auto-suggested category per row. Confirming a suggestion (or + * picking a different category) posts a retroactive rule via `useCategorize` + * that categorizes all matching past + future transactions server-side — the + * row then animates out. "Бүгдийг санал болгосноор ангилах" applies every + * confident suggestion in one pass. Replaces the old one-merchant-at-a-time + * `CategorizeReviewView.swift` port. */ export function CategorizeReview() { const router = useRouter(); @@ -72,40 +235,84 @@ export function CategorizeReview() { return { from: todayLocalDate(threeMonthsAgo(now)), to: todayLocalDate(now) }; }, []); const { data, isLoading } = useTransactions({ from, to, category: "Uncategorized", limit: 500 }); - const { data: categories = [] } = useCategories(); + // Same 3-month window, unfiltered — only used as the coverage bar's fixed + // denominator (total reviewable spend transactions), not re-rendered as a list. + const { data: allData } = useTransactions({ from, to, limit: 500 }); + const { data: categoriesData } = useCategories(); + const categories = categoriesData ?? []; const categorize = useCategorize(); const hidden = useHiddenAmounts(); - // The queue is seeded once from the fetch, then mutated locally (skip - // removes, assign removes on success) — re-deriving it from `data` on every - // background refetch (categorize invalidates the transactions cache) would - // otherwise re-insert items the user already handled in this session. + // The queue is seeded once from the fetch, then mutated locally (assign + // removes on success) — re-deriving it from `data` on every background + // refetch (categorize invalidates the transactions cache) would otherwise + // re-insert items the user already handled in this session. const [queue, setQueue] = useState(null); useEffect(() => { if (data && queue === null) setQueue(buildQueue(data)); }, [data, queue]); + const [removingKeys, setRemovingKeys] = useState>(new Set()); + const [pickerFor, setPickerFor] = useState(null); + const [bulkRunning, setBulkRunning] = useState(false); + const [bulkProgress, setBulkProgress] = useState({ done: 0, total: 0 }); + + const rows = useMemo(() => (queue ? toRows(queue, categories) : []), [queue, categories]); const mainCategories = useMemo(() => categories.filter((c) => c.depth === 1), [categories]); + const totalSpend = useMemo( + () => (allData ? allData.filter(isReviewableSpend).length : null), + [allData], + ); + const remainingCount = useMemo(() => rows.reduce((sum, r) => sum + r.count, 0), [rows]); + const percent = + totalSpend && totalSpend > 0 ? Math.round(((totalSpend - remainingCount) / totalSpend) * 100) : null; + function close() { router.push("/accounting"); } - function skip(item: ReviewItem) { - setQueue((q) => (q ? q.filter((i) => i.matchKey !== item.matchKey) : q)); + function removeItem(matchKey: string) { + setRemovingKeys((r) => new Set(r).add(matchKey)); + setTimeout(() => { + setQueue((q) => (q ? q.filter((i) => i.matchKey !== matchKey) : q)); + setRemovingKeys((r) => { + const next = new Set(r); + next.delete(matchKey); + return next; + }); + }, 220); } function assign(item: ReviewItem, category: string) { categorize.mutate( { matchKey: item.matchKey, category, kind: item.direction }, - { onSuccess: () => setQueue((q) => (q ? q.filter((i) => i.matchKey !== item.matchKey) : q)) }, + { onSuccess: () => removeItem(item.matchKey) }, ); } - const current = queue?.[0]; + async function runBulk() { + const targets = rows.filter((r) => r.suggestion); + if (targets.length === 0) return; + setBulkRunning(true); + setBulkProgress({ done: 0, total: targets.length }); + for (const row of targets) { + try { + await categorize.mutateAsync({ matchKey: row.matchKey, category: row.suggestion!, kind: row.direction }); + removeItem(row.matchKey); + } catch { + // Leave it in the queue on failure so the user can retry it manually. + } + setBulkProgress((p) => ({ ...p, done: p.done + 1 })); + } + setBulkRunning(false); + } + + const confidentCount = rows.filter((r) => r.suggestion).length; + const busy = categorize.isPending || bulkRunning; return ( -
+

{s.review.title}

- ); - })} -
- - skip(current)}> - {s.review.skip} + + {bulkRunning ? s.review.bulkProgress(bulkProgress.done, bulkProgress.total) : s.review.bulkApply} + + + {rows.map((row) => ( + ) : ( <> - + {s.review.close} )} + + { + if (!open) setPickerFor(null); + }} + categories={mainCategories} + selected={pickerFor ? (suggestCategory(pickerFor.merchant, categories) ?? undefined) : undefined} + onSelect={(cat) => { + if (pickerFor) assign(pickerFor, cat.name); + setPickerFor(null); + }} + />
); } diff --git a/src/features/accounting/strings.ts b/src/features/accounting/strings.ts index 014d699..5dff5ea 100644 --- a/src/features/accounting/strings.ts +++ b/src/features/accounting/strings.ts @@ -20,12 +20,16 @@ export const accountingStrings = { review: { title: "Ангилалжуулах", later: "Дараа нь", - remaining: (n: number) => `${n} ангилалгүй худалдагч үлдлээ`, - question: "Аль ангилалд хамаарах вэ?", - skip: "Алгасах", - done: "Бүгд ангилагдлаа", - close: "Хаах", + remaining: (n: number) => `${n} ангилаагүй үлдлээ`, + coverage: (pct: number) => `${pct}% ангилагдсан`, transactionCount: (n: number) => `${n} гүйлгээ`, + bulkApply: "Бүгдийг санал болгосноор ангилах", + bulkProgress: (done: number, total: number) => `${done}/${total} ангилж байна…`, + confirm: "Баталгаажуулах", + pickCategory: "Ангилал сонгох", + done: "Бүгд ангилагдлаа", + doneHint: "Шинэ гүйлгээ ирвэл дүрмийн дагуу автоматаар ангилагдана.", + close: "Хаах", }, detail: { total: "Нийт", diff --git a/src/features/accounting/suggestCategory.test.ts b/src/features/accounting/suggestCategory.test.ts new file mode 100644 index 0000000..f75a436 --- /dev/null +++ b/src/features/accounting/suggestCategory.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { suggestCategory } from "./suggestCategory"; +import type { Category } from "@/api/schemas"; + +function cat(name: string, kind: "income" | "expense" = "expense", depth = 1): Category { + return { name, kind, depth }; +} + +const CATEGORIES: Category[] = [ + cat("Food & Drink"), + cat("Coffee"), + cat("Transport"), + cat("Shopping"), + cat("Bills & Services"), + cat("Entertainment"), + cat("Groceries"), + cat("Insurance"), +]; + +describe("suggestCategory", () => { + it("matches loan/leasing keywords (Cyrillic)", () => { + expect(suggestCategory("ЛИЗИНГ ХХК", CATEGORIES)).toBe("Bills & Services"); // no "Loan" category present + }); + + it("prefers Loan when it exists", () => { + const withLoan = [...CATEGORIES, cat("Loan")]; + expect(suggestCategory("ХААН ЗЭЭЛ ТӨЛБӨР", withLoan)).toBe("Loan"); + }); + + it("matches insurance keywords", () => { + expect(suggestCategory("MONGOL ДААТГАЛ LLC", CATEGORIES)).toBe("Insurance"); + expect(suggestCategory("SOME INSURANCE CO", CATEGORIES)).toBe("Insurance"); + }); + + it("matches grocery keywords", () => { + expect(suggestCategory("NOMIN SUPERMARKET", CATEGORIES)).toBe("Groceries"); + expect(suggestCategory("CU-24 CONVENIENCE", CATEGORIES)).toBe("Groceries"); + expect(suggestCategory("ХҮНСНИЙ ДЭЛГҮҮР", CATEGORIES)).toBe("Groceries"); + }); + + it("matches food & drink keywords", () => { + expect(suggestCategory("KFC ULAANBAATAR", CATEGORIES)).toBe("Food & Drink"); + expect(suggestCategory("КАФЕ МОДЕРН", CATEGORIES)).toBe("Food & Drink"); + }); + + it("matches a coffee-only merchant (no food keyword present)", () => { + expect(suggestCategory("TOM N TOMS COFFEE", CATEGORIES)).toBe("Coffee"); + expect(suggestCategory("STARBUCKS COFFEE", CATEGORIES)).toBe("Coffee"); + expect(suggestCategory("КОФЕ ЦЭГ", CATEGORIES)).toBe("Coffee"); + }); + + it("matches transport keywords", () => { + expect(suggestCategory("UBCAB TRIP", CATEGORIES)).toBe("Transport"); + expect(suggestCategory("ШАТАХУУНЫ СТАНЦ", CATEGORIES)).toBe("Transport"); + }); + + it("matches shopping keywords", () => { + expect(suggestCategory("CONVERSE STORE", CATEGORIES)).toBe("Shopping"); + // 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("1234******5678", CATEGORIES)).toBeNull(); + }); + + it("returns null when nothing matches", () => { + expect(suggestCategory("SOME RANDOM MERCHANT XYZ", CATEGORIES)).toBeNull(); + }); + + it("returns null when the only matching candidate category doesn't exist", () => { + const noInsurance = CATEGORIES.filter((c) => c.name !== "Insurance"); + expect(suggestCategory("ДААТГАЛ", noInsurance)).toBeNull(); + }); + + it("returns null for empty input", () => { + expect(suggestCategory("", CATEGORIES)).toBeNull(); + }); +}); diff --git a/src/features/accounting/suggestCategory.ts b/src/features/accounting/suggestCategory.ts new file mode 100644 index 0000000..58a6a10 --- /dev/null +++ b/src/features/accounting/suggestCategory.ts @@ -0,0 +1,56 @@ +import type { Category } from "@/api/schemas"; + +/** A merchant/matchKey string that is mostly a masked card number (e.g. + * `"554835******6886:13-08-2026 09:41:22"`) carries no merchant-name signal — + * never suggest a category for these, let the user pick. */ +function looksLikeCardNumber(s: string): boolean { + const head = s.split(":")[0] ?? s; + if (/^\d{4,}\*{2,}/.test(head)) return true; + const digits = (s.match(/\d/g) ?? []).length; + const stars = (s.match(/\*/g) ?? []).length; + 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 { + pattern: RegExp; + candidates: string[]; +} + +// Kept small and ordered: earlier rules win when a merchant string matches +// more than one (e.g. a name containing both "ХООЛ" and "CAFE"). Add new +// merchants here rather than growing suggestCategory()'s logic. +const RULES: Rule[] = [ + { pattern: /ЛИЗИНГ|ЗЭЭЛ|ББСБ|LOAN/i, candidates: ["Loan", "Bills & Services"] }, + { pattern: /ДААТГАЛ|INSURANCE/i, candidates: ["Insurance"] }, + { pattern: /MART|МАРКЕТ|CU-|NOMIN|ХҮНС|GROCER/i, candidates: ["Groceries"] }, + { pattern: /ХООЛ|CAFE|КАФЕ|RESTAURANT|KFC|PIZZA/i, candidates: ["Food & Drink"] }, + { pattern: /КОФЕ|COFFEE|TOM N TOMS/i, candidates: ["Coffee"] }, + { pattern: /TAXI|UBCAB|ТЭЭВЭР|PETROL|ШАТАХУУН/i, candidates: ["Transport"] }, + { pattern: /STORE|SHOP|ДЭЛГҮҮР|CONVERSE/i, candidates: ["Shopping"] }, +]; + +/** + * Suggests one of the user's existing categories for an uncategorized + * merchant, from a small keyword heuristic. Returns `null` (no suggestion — + * the user picks manually) when nothing matches, or when every matching + * rule's candidates are all absent from `categories`, or when `merchant` + * looks like a masked card number rather than a real merchant name. + */ +export function suggestCategory(merchant: string, categories: Category[]): string | null { + const text = merchant?.trim(); + if (!text) return null; + if (looksLikeCardNumber(text)) return null; + + const names = new Set(categories.map((c) => c.name)); + for (const rule of RULES) { + if (!rule.pattern.test(text)) continue; + for (const candidate of rule.candidates) { + if (names.has(candidate)) return candidate; + } + } + return null; +} From 79dd6ed591bc0805bc8789de907448c54b56b826 Mon Sep 17 00:00:00 2001 From: Munkherdene Date: Sun, 23 Aug 2026 00:41:58 +0800 Subject: [PATCH 3/3] fix(web): parse real merchant out of card-string :MCI: segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/features/accounting/CategorizeReview.tsx | 7 +- .../accounting/suggestCategory.test.ts | 134 ++++++++++++++---- src/features/accounting/suggestCategory.ts | 75 ++++++---- 3 files changed, 159 insertions(+), 57 deletions(-) diff --git a/src/features/accounting/CategorizeReview.tsx b/src/features/accounting/CategorizeReview.tsx index 9ca4276..695685c 100644 --- a/src/features/accounting/CategorizeReview.tsx +++ b/src/features/accounting/CategorizeReview.tsx @@ -10,7 +10,7 @@ import { categoryStyle } from "@/ds/categoryStyle"; import { Icon } from "@/ds/icons"; import { MASKED, tugrikRaw } from "@/ds/money"; import { CategorizeSheet } from "./CategorizeSheet"; -import { suggestCategory } from "./suggestCategory"; +import { suggestCategory, extractMerchant, looksLikeCardNumber } from "./suggestCategory"; import { accountingStrings as s } from "./strings"; import { useHiddenAmounts } from "./useHiddenAmounts"; @@ -74,10 +74,11 @@ function buildQueue(txns: Txn[]): ReviewItem[] { const CARD_LIKE = /^(\d{3,})\*{2,}(\d{2,})/; 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 m = head.match(CARD_LIKE); - if (m) return `Карт •••• ${m[2]}`; - return head || raw; + return m ? `Карт •••• ${m[2]}` : head || raw; } interface Row extends ReviewItem { diff --git a/src/features/accounting/suggestCategory.test.ts b/src/features/accounting/suggestCategory.test.ts index f75a436..a5d3b3e 100644 --- a/src/features/accounting/suggestCategory.test.ts +++ b/src/features/accounting/suggestCategory.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { suggestCategory } from "./suggestCategory"; +import { suggestCategory, extractMerchant, looksLikeCardNumber } from "./suggestCategory"; import type { Category } from "@/api/schemas"; function cat(name: string, kind: "income" | "expense" = "expense", depth = 1): Category { @@ -15,67 +15,145 @@ const CATEGORIES: Category[] = [ cat("Entertainment"), cat("Groceries"), cat("Insurance"), + cat("Electronics"), ]; -describe("suggestCategory", () => { - it("matches loan/leasing keywords (Cyrillic)", () => { - expect(suggestCategory("ЛИЗИНГ ХХК", CATEGORIES)).toBe("Bills & Services"); // no "Loan" category present +describe("extractMerchant", () => { + it("pulls the real merchant out of a card string's :MCI: segment", () => { + 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")]; 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", () => { expect(suggestCategory("MONGOL ДААТГАЛ LLC", CATEGORIES)).toBe("Insurance"); expect(suggestCategory("SOME INSURANCE CO", CATEGORIES)).toBe("Insurance"); }); - it("matches grocery keywords", () => { - expect(suggestCategory("NOMIN SUPERMARKET", CATEGORIES)).toBe("Groceries"); - expect(suggestCategory("CU-24 CONVENIENCE", CATEGORIES)).toBe("Groceries"); - expect(suggestCategory("ХҮНСНИЙ ДЭЛГҮҮР", CATEGORIES)).toBe("Groceries"); + it("matches electronics retailers", () => { + expect(suggestCategory("MAGIC TECH STORE", CATEGORIES)).toBe("Electronics"); + expect(suggestCategory("ITOPIA MALL", CATEGORIES)).toBe("Electronics"); }); - it("matches food & drink keywords", () => { - expect(suggestCategory("KFC ULAANBAATAR", CATEGORIES)).toBe("Food & Drink"); - expect(suggestCategory("КАФЕ МОДЕРН", CATEGORIES)).toBe("Food & Drink"); + it("matches mobile carriers to Bills & Services", () => { + expect(suggestCategory("MOBICOM PAYMENT", CATEGORIES)).toBe("Bills & Services"); + expect(suggestCategory("UNITEL TOP UP", CATEGORIES)).toBe("Bills & Services"); }); - it("matches a coffee-only merchant (no food keyword present)", () => { - expect(suggestCategory("TOM N TOMS COFFEE", CATEGORIES)).toBe("Coffee"); - expect(suggestCategory("STARBUCKS COFFEE", CATEGORIES)).toBe("Coffee"); + it("matches apparel/shopping brands", () => { + expect(suggestCategory("CONVERSE STORE", CATEGORIES)).toBe("Shopping"); + 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("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("ШАТАХУУНЫ СТАНЦ", CATEGORIES)).toBe("Transport"); }); +}); - it("matches shopping keywords", () => { - expect(suggestCategory("CONVERSE STORE", CATEGORIES)).toBe("Shopping"); - // 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", () => { +describe("suggestCategory — no signal", () => { + it("returns null for a masked card number with no :MCI: merchant segment", () => { expect(suggestCategory("554835******6886:13-08-2026 09:41:22", CATEGORIES)).toBeNull(); expect(suggestCategory("1234******5678", CATEGORIES)).toBeNull(); }); 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", () => { - const noInsurance = CATEGORIES.filter((c) => c.name !== "Insurance"); - expect(suggestCategory("ДААТГАЛ", noInsurance)).toBeNull(); + it("returns null when every matching rule's candidates are all absent", () => { + // The phone-carrier rule has a single candidate ("Bills & Services") with + // 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", () => { 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); + }); +}); diff --git a/src/features/accounting/suggestCategory.ts b/src/features/accounting/suggestCategory.ts index 58a6a10..9ad5af3 100644 --- a/src/features/accounting/suggestCategory.ts +++ b/src/features/accounting/suggestCategory.ts @@ -1,49 +1,72 @@ import type { Category } from "@/api/schemas"; -/** A merchant/matchKey string that is mostly a masked card number (e.g. - * `"554835******6886:13-08-2026 09:41:22"`) carries no merchant-name signal — - * never suggest a category for these, let the user pick. */ -function looksLikeCardNumber(s: string): boolean { - const head = s.split(":")[0] ?? s; +/** Card/online charges arrive as a masked-card string with the real merchant + * buried after the last `:MCI:` segment, e.g. + * `"554835******6886:30-07-2026 11:02:10:MCI:ANTHROPIC 1"` → `"ANTHROPIC"`. + * Returns the cleaned merchant name (trailing sequence number stripped), or the + * 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; const digits = (s.match(/\d/g) ?? []).length; const stars = (s.match(/\*/g) ?? []).length; 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 { pattern: RegExp; candidates: string[]; } -// Kept small and ordered: earlier rules win when a merchant string matches -// more than one (e.g. a name containing both "ХООЛ" and "CAFE"). Add new +// Ordered — earlier rules win when a merchant matches more than one. Keyed to +// 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. const RULES: Rule[] = [ - { pattern: /ЛИЗИНГ|ЗЭЭЛ|ББСБ|LOAN/i, candidates: ["Loan", "Bills & Services"] }, - { pattern: /ДААТГАЛ|INSURANCE/i, candidates: ["Insurance"] }, - { pattern: /MART|МАРКЕТ|CU-|NOMIN|ХҮНС|GROCER/i, candidates: ["Groceries"] }, - { pattern: /ХООЛ|CAFE|КАФЕ|RESTAURANT|KFC|PIZZA/i, candidates: ["Food & Drink"] }, - { pattern: /КОФЕ|COFFEE|TOM N TOMS/i, candidates: ["Coffee"] }, - { pattern: /TAXI|UBCAB|ТЭЭВЭР|PETROL|ШАТАХУУН/i, candidates: ["Transport"] }, - { pattern: /STORE|SHOP|ДЭЛГҮҮР|CONVERSE/i, candidates: ["Shopping"] }, + { pattern: /ANTHROPIC|OPENAI|CLAUDE|GITHUB|VERCEL|\bAWS\b|HOSTI|\bWWW\b|GOOGLE|NETLIFY/i, candidates: ["Software", "Subscriptions", "Bills & Services"] }, + { pattern: /TAOBAO|ALIEXPRESS|ALIPAY|AMAZON|WISH/i, candidates: ["Shopping"] }, + // Note: no `\b` word-boundary around the Cyrillic keywords — JS's `\b` is + // defined in terms of `\w` ([A-Za-z0-9_]), which doesn't include Cyrillic + // letters, so `\bЗЭЭЛ\b` would never match any Cyrillic text at all. + { pattern: /ЛИЗИНГ|ТОКИ|АВДАР|ББСБ|ЗЭЭЛ|LOAN|LEASING/i, candidates: ["Loan", "Bills & Services"] }, + { 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 - * merchant, from a small keyword heuristic. Returns `null` (no suggestion — - * the user picks manually) when nothing matches, or when every matching - * rule's candidates are all absent from `categories`, or when `merchant` - * looks like a masked card number rather than a real merchant name. + * Suggests one of the user's existing categories for an uncategorized merchant, + * from a keyword heuristic keyed to the real merchants seen in the data. The + * merchant name is first extracted from any card string (`:MCI:ANTHROPIC`). + * Returns `null` (user picks) when nothing matches, when the matching rule's + * 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 { - const text = merchant?.trim(); - if (!text) return null; - if (looksLikeCardNumber(text)) return null; + const text = extractMerchant(merchant); + if (!text || looksLikeCardNumber(text)) return null; const names = new Set(categories.map((c) => c.name)); for (const rule of RULES) {