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.
159 lines
6.6 KiB
TypeScript
159 lines
6.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
||
import { suggestCategory, extractMerchant, looksLikeCardNumber } 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"),
|
||
cat("Electronics"),
|
||
];
|
||
|
||
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("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 electronics retailers", () => {
|
||
expect(suggestCategory("MAGIC TECH STORE", CATEGORIES)).toBe("Electronics");
|
||
expect(suggestCategory("ITOPIA MALL", CATEGORIES)).toBe("Electronics");
|
||
});
|
||
|
||
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 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 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");
|
||
});
|
||
});
|
||
|
||
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("TLJ CENTR", CATEGORIES)).toBeNull();
|
||
expect(suggestCategory("TSENGELDE", CATEGORIES)).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);
|
||
});
|
||
});
|