Compare commits

..

No commits in common. "fdebc97c9a9d46685c1acbec514473ce7caf19b4" and "3f42f215040781f21a4cd817645e1f5835654c76" have entirely different histories.

12 changed files with 119 additions and 697 deletions

View file

@ -1,7 +1,5 @@
import { z } from "zod";
export const TxnSchema = z.object({ date: z.string(), amount: z.string(), direction: z.string(), category: z.string(),
title: z.string(), balanceAfter: z.string().nullish(), accountId: z.number(), transfer: z.boolean().nullish(),
txnId: z.number().nullish(), note: z.string().nullish(), matchKey: z.string().nullish(), salary: z.boolean().nullish(),
fee: z.boolean().nullish(),
passThrough: z.boolean().nullish() });
txnId: z.number().nullish(), note: z.string().nullish(), matchKey: z.string().nullish(), salary: z.boolean().nullish() });
export type Txn = z.infer<typeof TxnSchema>;

View file

@ -4,13 +4,11 @@ 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 { Category, Txn } from "@/api/schemas";
import type { 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, extractMerchant, looksLikeCardNumber } from "./suggestCategory";
import { accountingStrings as s } from "./strings";
import { useHiddenAmounts } from "./useHiddenAmounts";
@ -28,19 +26,6 @@ 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;
if (t.fee === true) return false; // bank's own fees are excluded, not categorized
if (t.passThrough === true) return false; // buying-on-behalf leg — excluded
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
@ -49,7 +34,6 @@ function buildQueue(txns: Txn[]): ReviewItem[] {
const groups = new Map<string, ReviewItem>();
for (const t of txns) {
if (t.category) continue; // defensive — the fetch already scopes to Uncategorized
if (!isReviewableSpend(t)) continue;
const key = t.matchKey || t.title;
if (!key) continue;
const amount = parseFloat(t.amount) || 0;
@ -70,166 +54,11 @@ 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 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);
return m ? `Карт •••• ${m[2]}` : 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 = (
<svg viewBox="0 0 24 24" width={18} height={18} fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.5l4.5 4.5L19 7" />
</svg>
);
function ProgressBar({ percent }: { percent: number }) {
return (
<div
style={{
height: 6,
borderRadius: 999,
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${Math.max(0, Math.min(100, percent))}%`,
borderRadius: 999,
background: "var(--mercury-success, #1e9e6b)",
transition: "width 300ms ease",
}}
/>
</div>
);
}
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 (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "10px 0",
opacity: removing ? 0 : 1,
transform: removing ? "translateX(6px)" : "translateX(0)",
transition: "opacity 220ms ease, transform 220ms ease",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
<IconChip
icon={chip?.icon ?? "receipt"}
tint={chip?.tint ?? "var(--seed-color-bg-neutral-subtle, #eef0f2)"}
fg={chip?.fg ?? "var(--seed-color-fg-neutral-muted, #8b8b8b)"}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{row.displayName}
</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
×{row.count} · {hidden ? MASKED : tugrikRaw(row.total)}
</span>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexShrink: 0 }}>
<button
type="button"
onClick={() => onPick(row)}
disabled={busy}
style={{
all: "unset",
cursor: busy ? "default" : "pointer",
display: "flex",
alignItems: "center",
gap: 6,
padding: "8px 12px",
borderRadius: 999,
fontSize: 13,
fontWeight: 600,
opacity: busy ? 0.6 : 1,
background: chip?.tint ?? "var(--seed-color-bg-neutral-subtle, #eef0f2)",
color: chip?.fg ?? "var(--seed-color-fg-neutral-muted, #8b8b8b)",
}}
>
<Icon name={chip?.icon ?? "list"} size={14} />
{chip ? chip.name : s.review.pickCategory}
</button>
{row.suggestion ? (
<button
type="button"
aria-label={s.review.confirm}
onClick={() => onConfirm(row)}
disabled={busy}
style={{
all: "unset",
cursor: busy ? "default" : "pointer",
display: "grid",
placeItems: "center",
width: 32,
height: 32,
borderRadius: "50%",
opacity: busy ? 0.6 : 1,
background: "var(--mercury-success, #1e9e6b)",
color: "#fff",
}}
>
{checkSvg}
</button>
) : null}
</div>
</div>
);
}
/**
* 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.
* 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.
*/
export function CategorizeReview() {
const router = useRouter();
@ -238,84 +67,40 @@ export function CategorizeReview() {
return { from: todayLocalDate(threeMonthsAgo(now)), to: todayLocalDate(now) };
}, []);
const { data, isLoading } = useTransactions({ from, to, category: "Uncategorized", limit: 500 });
// 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 { data: categories = [] } = useCategories();
const categorize = useCategorize();
const hidden = useHiddenAmounts();
// 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.
// 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.
const [queue, setQueue] = useState<ReviewItem[] | null>(null);
useEffect(() => {
if (data && queue === null) setQueue(buildQueue(data));
}, [data, queue]);
const [removingKeys, setRemovingKeys] = useState<Set<string>>(new Set());
const [pickerFor, setPickerFor] = useState<ReviewItem | null>(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 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 skip(item: ReviewItem) {
setQueue((q) => (q ? q.filter((i) => i.matchKey !== item.matchKey) : q));
}
function assign(item: ReviewItem, category: string) {
categorize.mutate(
{ matchKey: item.matchKey, category, kind: item.direction },
{ onSuccess: () => removeItem(item.matchKey) },
{ onSuccess: () => setQueue((q) => (q ? q.filter((i) => i.matchKey !== item.matchKey) : q)) },
);
}
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;
const current = queue?.[0];
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<h1 style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>{s.review.title}</h1>
<button
@ -328,60 +113,64 @@ export function CategorizeReview() {
</div>
{isLoading && queue === null ? (
<Card style={{ minHeight: 200 }} />
) : rows.length > 0 ? (
<Card style={{ minHeight: 320 }} />
) : current ? (
<>
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
<span style={{ fontSize: 14, fontWeight: 700 }}>{s.review.remaining(remainingCount)}</span>
{percent !== null ? (
<span style={{ fontSize: 13, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.review.coverage(percent)}
</span>
) : null}
</div>
{percent !== null ? <ProgressBar percent={percent} /> : null}
<p style={{ margin: 0, fontSize: 13, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.review.remaining(queue!.length)}
</p>
<Card style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8, padding: "24px 16px" }}>
<IconChip icon="cart" tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" size={56} />
<span style={{ fontSize: 16, fontWeight: 700, textAlign: "center" }}>{current.merchant}</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.review.transactionCount(current.count)} · {hidden ? MASKED : tugrikRaw(current.total)}
</span>
</Card>
<MercuryButton variant="primary" onClick={runBulk} disabled={busy || confidentCount === 0}>
{bulkRunning ? s.review.bulkProgress(bulkProgress.done, bulkProgress.total) : s.review.bulkApply}
<p style={{ margin: 0, fontSize: 14, fontWeight: 700 }}>{s.review.question}</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
{mainCategories.map((cat) => {
const style = categoryStyle(cat.name, cat.kind === "income");
return (
<button
key={cat.name}
type="button"
onClick={() => assign(current, cat.name)}
disabled={categorize.isPending}
style={{
all: "unset",
cursor: categorize.isPending ? "default" : "pointer",
display: "flex",
alignItems: "center",
gap: 6,
padding: "10px 14px",
borderRadius: 999,
fontSize: 14,
opacity: categorize.isPending ? 0.6 : 1,
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
}}
>
<Icon name={style.icon} size={16} />
{style.name}
</button>
);
})}
</div>
<MercuryButton variant="secondary" onClick={() => skip(current)}>
{s.review.skip}
</MercuryButton>
<Card style={{ display: "flex", flexDirection: "column" }}>
{rows.map((row) => (
<ReviewRow
key={row.matchKey}
row={row}
hidden={hidden}
busy={busy}
removing={removingKeys.has(row.matchKey)}
onConfirm={(r) => assign(r, r.suggestion!)}
onPick={(r) => setPickerFor(r)}
/>
))}
</Card>
</>
) : (
<>
<EmptyState icon="calendar-check" title={s.review.done} hint={s.review.doneHint} />
<EmptyState icon="calendar-check" title={s.review.done} />
<MercuryButton variant="primary" onClick={close}>
{s.review.close}
</MercuryButton>
</>
)}
<CategorizeSheet
open={pickerFor !== null}
onOpenChange={(open) => {
if (!open) setPickerFor(null);
}}
categories={mainCategories}
selected={pickerFor ? (suggestCategory(pickerFor.merchant, categories) ?? undefined) : undefined}
onSelect={(cat) => {
if (pickerFor) assign(pickerFor, cat.name);
setPickerFor(null);
}}
/>
</div>
);
}

View file

@ -154,8 +154,6 @@ export function TransactionList() {
let expense = 0;
for (const txn of all) {
if (txn.transfer === true) continue;
if (txn.fee === true) continue; // bank's own fees are excluded from totals
if (txn.passThrough === true) continue; // buying-on-behalf nets to zero
if (txn.direction === "income") income += amountOf(txn);
else expense += amountOf(txn);
}
@ -165,24 +163,14 @@ export function TransactionList() {
const categoryOptions = useMemo(() => categoriesIn(all), [all]);
const hasUncategorized = useMemo(
() =>
all.some(
(txn) =>
!txn.category &&
txn.salary !== true &&
txn.transfer !== true &&
txn.fee !== true &&
txn.passThrough !== true &&
txn.direction !== "income" &&
(txn.matchKey || txn.title),
),
() => all.some((txn) => !txn.category && (txn.matchKey || txn.title)),
[all],
);
const visible = useMemo(
() =>
all
.filter((txn) => txn.salary !== true && txn.fee !== true && txn.passThrough !== true)
.filter((txn) => txn.salary !== true)
.filter((txn) => selectedCategory === null || txn.category === selectedCategory)
.slice()
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),

View file

@ -20,16 +20,12 @@ export const accountingStrings = {
review: {
title: "Ангилалжуулах",
later: "Дараа нь",
remaining: (n: number) => `${n} ангилаагүй үлдлээ`,
coverage: (pct: number) => `${pct}% ангилагдсан`,
transactionCount: (n: number) => `${n} гүйлгээ`,
bulkApply: "Бүгдийг санал болгосноор ангилах",
bulkProgress: (done: number, total: number) => `${done}/${total} ангилж байна…`,
confirm: "Баталгаажуулах",
pickCategory: "Ангилал сонгох",
remaining: (n: number) => `${n} ангилалгүй худалдагч үлдлээ`,
question: "Аль ангилалд хамаарах вэ?",
skip: "Алгасах",
done: "Бүгд ангилагдлаа",
doneHint: "Шинэ гүйлгээ ирвэл дүрмийн дагуу автоматаар ангилагдана.",
close: "Хаах",
transactionCount: (n: number) => `${n} гүйлгээ`,
},
detail: {
total: "Нийт",

View file

@ -1,159 +0,0 @@
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);
});
});

View file

@ -1,79 +0,0 @@
import type { Category } from "@/api/schemas";
/** 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;
}
interface Rule {
pattern: RegExp;
candidates: string[];
}
// 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: /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 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 = extractMerchant(merchant);
if (!text || 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;
}

View file

@ -2,7 +2,6 @@
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Skeleton } from "@seed-design/react";
import {
Card,
@ -12,11 +11,11 @@ import {
Icon,
HideAmountsToggle,
SyncButton,
MercuryButton,
categoryStyle,
} from "../../ds";
import type { IconName } from "../../ds/icons";
import { tugrik, tugrikRaw, tugrikShort, tugrikShortRaw, MASKED } from "../../ds/money";
import { AmountToggle } from "../../ds/AmountToggle";
import { tugrik, tugrikShort, tugrikShortRaw } from "../../ds/money";
import {
useNetWorth,
useAnalyzeMonth,
@ -33,54 +32,18 @@ import { topExpenseCategories, topExpensePayees, shareOf } from "./whereItWent";
import { recurringMonthlyTotal, detectRecurringMerchants } from "./recurring";
import { buildNetWorthComposition } from "./netWorthComposition";
import { countUncategorized } from "./categorizeNudge";
import { prettyMerchant } from "./prettyMerchant";
import { sample } from "./sample";
import { homeStrings as s } from "./strings";
import { monthRange } from "../accounting/monthRange";
import { useHiddenAmounts } from "../accounting/useHiddenAmounts";
/** A number that's redacted with a Seed skeleton block while loading. Once
* real data has arrived it renders the REAL figure by default masked only
* when the global hide-amounts flag (`hidden`) is on never the
* default-hidden tap-to-reveal `AmountToggle`, which is for the ledger rows,
* not the hero. Mirrors iOS's `.skeleton(loading)` view modifier, which
* redacts the finished layout in place rather than swapping in a spinner. */
function Amount({
value,
loading,
hidden,
width = "72px",
}: {
value: number;
loading: boolean;
hidden: boolean;
width?: string;
}) {
/** A number that's redacted with a Seed skeleton block while loading, and a
* tap-to-reveal `AmountToggle` once real data has arrived. Mirrors iOS's
* `.skeleton(loading)` view modifier, which redacts the finished layout in
* place rather than swapping in a separate spinner. */
function Amount({ value, loading, width = "72px" }: { value: number; loading: boolean; width?: string }) {
if (loading) return <Skeleton height="1em" width={width} style={{ display: "inline-block" }} />;
return <>{hidden ? MASKED : tugrikRaw(value)}</>;
}
/** The small "see the rest" link capping every home list categories,
* merchants, recurring. Always routes to the full-detail page for that
* data, never a dead end. */
function ViewAllLink({ href, label }: { href: string; label: string }) {
return (
<Link
href={href}
style={{
alignSelf: "flex-start",
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: 13,
fontWeight: 700,
color: "var(--seed-color-fg-neutral-muted, #8b8b8b)",
textDecoration: "none",
}}
>
{label} <Icon name="chevron-right" size={14} />
</Link>
);
return <AmountToggle value={value} />;
}
/** The circular "₮" badge used on every card (togrogCircle in DashboardView.swift). */
@ -113,15 +76,15 @@ function TugrikCircle({ bg, fg }: { bg: string; fg: string }) {
function CashFlowMiniChart({ months }: { months: TrendMonth[] }) {
const max = Math.max(1, ...months.flatMap((m) => [m.income, m.expense]));
const W = 300;
const H = 92;
const base = H - 16;
const top = 8;
const H = 78;
const base = H - 14;
const top = 6;
const groupW = W / months.length;
const barW = Math.min(16, groupW / 3.2);
return (
<div style={{ paddingTop: 2 }}>
<div style={{ display: "flex", gap: 14, fontSize: 10, opacity: 0.75, marginBottom: 6 }}>
<div>
<div style={{ display: "flex", gap: 14, fontSize: 10, opacity: 0.75, marginBottom: 4 }}>
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<i aria-hidden style={{ width: 7, height: 7, borderRadius: 2, background: "#1B8F60", display: "inline-block" }} />
{s.income}
@ -203,7 +166,7 @@ function MerchantShareRow({ item, items }: { item: Named; items: Named[] }) {
color: "var(--seed-color-fg-neutral)",
}}
>
{prettyMerchant(item.name)}
{item.name}
</span>
<span style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{tugrik(item.total)}</span>
</div>
@ -219,7 +182,7 @@ function RecurringRow({ sub, icon }: { sub: Subscription; icon: IconName }) {
<IconChip icon={icon} tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" size={36} />
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 14, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{prettyMerchant(sub.label)}
{sub.label}
</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{sub.cadence}</span>
</div>
@ -240,7 +203,6 @@ function Dot({ color }: { color: string }) {
* unchanged) plus the small aggregation helpers in this directory; `sample`
* is the pre-connection / empty-account fallback for the hero only. */
export function DashboardView() {
const router = useRouter();
const netWorthQ = useNetWorth();
const monthQ = useAnalyzeMonth();
const budgetQ = useBudget();
@ -251,9 +213,8 @@ export function DashboardView() {
// Subscribes this component to the global hide-amounts flag so every
// `tugrik()`/`tugrikShort()` call below (which read the flag internally,
// but don't themselves trigger a re-render) reflects a live toggle, and so
// the hero's own `<Amount hidden>` prop stays in sync with it.
const hiddenAmounts = useHiddenAmounts();
// but don't themselves trigger a re-render) reflects a live toggle.
useHiddenAmounts();
const heroLoading = netWorthQ.isLoading || monthQ.isLoading || budgetQ.isLoading;
@ -285,21 +246,6 @@ export function DashboardView() {
);
const detected = detectRecurringMerchants(monthTxns, knownMatchKeys);
// Cap the recurring list at the top 5 by monthly amount (was dumping every
// subscription + bill unfiltered) — biggest commitments first, the rest is
// a tap away via the "Бүгдийг харах" link to the full manager.
const RECURRING_CAP = 5;
const recurringRows = React.useMemo(
() =>
[
...subs.map((sub) => ({ sub, icon: "sparkles" as const })),
...bills.map((sub) => ({ sub, icon: "card" as const })),
].sort((a, b) => dec(b.sub.monthly) - dec(a.sub.monthly)),
[subs, bills],
);
const visibleRecurring = recurringRows.slice(0, RECURRING_CAP);
const hiddenRecurringCount = Math.max(0, recurringRows.length - RECURRING_CAP);
const composition = buildNetWorthComposition(netWorthQ.data);
const netWorthLoading = netWorthQ.isLoading;
const compTotal = composition.bankTotal + composition.manualTotal;
@ -318,37 +264,38 @@ export function DashboardView() {
{/* 5. Categorize nudge the memo's keystone: made visible at the top,
not tucked in as the last card. A slim banner, not a full module. */}
{!monthTxnsQ.isLoading && uncategorizedCount > 0 && (
<div
<Link
href="/accounting/review"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
textDecoration: "none",
color: "var(--mercury-on-brand)",
background: "var(--mercury-warning-chip)",
borderRadius: "var(--seed-radius-r3)",
padding: "10px 12px 10px 10px",
padding: "10px 14px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
<IconChip icon="list" tint="rgba(0,0,0,0.08)" fg="var(--mercury-on-brand)" size={34} />
<span style={{ fontSize: 14, fontWeight: 700, color: "var(--mercury-on-brand)" }}>
{s.nudge(uncategorizedCount)}
</span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{s.nudge(uncategorizedCount)}</span>
</div>
<MercuryButton
variant="primary"
size="small"
onClick={() => router.push("/accounting/review")}
<span
style={{
fontSize: 13,
fontWeight: 700,
flexShrink: 0,
padding: "7px 14px",
borderRadius: 999,
background: "var(--mercury-on-brand)",
color: "var(--mercury-brand-yellow)",
borderColor: "transparent",
}}
>
{s.nudgeCta}
</MercuryButton>
</div>
{s.nudgeCta}
</span>
</Link>
)}
<div className="grid grid-cols-1 gap-[18px] md:grid-cols-2 md:items-start md:gap-5">
@ -361,19 +308,19 @@ export function DashboardView() {
color: "var(--mercury-on-brand)",
background: "var(--mercury-balance-card)",
borderRadius: "var(--seed-radius-r5, 20px)",
padding: "20px 22px",
padding: "18px 20px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<TugrikCircle bg="var(--mercury-balance-circle)" fg="var(--mercury-on-brand)" />
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontSize: 12, opacity: 0.85 }}>{data.overspent ? s.overspent : s.safeToSpend}</span>
<span style={{ fontSize: 30, fontWeight: 700, lineHeight: 1.15 }}>
<Amount value={data.safeToSpend} loading={heroLoading} hidden={hiddenAmounts} width="140px" />
<span style={{ fontSize: 27, fontWeight: 700, lineHeight: 1.15 }}>
<Amount value={data.safeToSpend} loading={heroLoading} width="140px" />
</span>
</div>
</div>
<div style={{ marginTop: 16, height: 6, borderRadius: 999, background: "rgba(0,0,0,0.13)", overflow: "hidden" }}>
<div style={{ marginTop: 14, height: 6, borderRadius: 999, background: "rgba(0,0,0,0.13)", overflow: "hidden" }}>
<div
style={{
height: "100%",
@ -386,14 +333,14 @@ export function DashboardView() {
<div style={{ marginTop: 10, display: "flex", justifyContent: "space-between", alignItems: "baseline", fontSize: 12 }}>
<span style={{ opacity: 0.85 }}>{s.spent}</span>
<span style={{ fontWeight: 700, fontSize: 13 }}>
<Amount value={data.monthlyExpense} loading={heroLoading} hidden={hiddenAmounts} />
<Amount value={data.monthlyExpense} loading={heroLoading} />
<span style={{ fontWeight: 400, fontSize: 11, opacity: 0.85 }}> / {tugrikShortRaw(budgetDenominator)}</span>
</span>
</div>
{hasRealMonth && (
<>
<hr style={{ margin: "18px 0 14px", border: 0, borderTop: "1px solid rgba(0,0,0,0.13)" }} />
<hr style={{ margin: "16px 0 12px", border: 0, borderTop: "1px solid rgba(0,0,0,0.13)" }} />
{trendMonths.length > 0 && <CashFlowMiniChart months={trendMonths} />}
<div style={{ marginTop: trendMonths.length > 0 ? 10 : 0, fontSize: 13, fontWeight: 700 }}>
<span style={{ color: verdict.positive ? "#1B8F60" : "#A83232" }}>
@ -405,7 +352,7 @@ export function DashboardView() {
</Link>
{/* 2. Хаана зарцуулсан бэ? — top categories + top merchants. */}
<Card style={{ display: "flex", flexDirection: "column", gap: 16, padding: 20 }}>
<Card style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<SectionHeader title={s.whereWentTitle} />
{whereItWentLoading ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
@ -453,29 +400,31 @@ export function DashboardView() {
))}
</div>
)}
<ViewAllLink href="/accounting" label={s.viewAll} />
</>
)}
</Card>
{/* 3. Тогтмол төлбөр — recurring & subscriptions. */}
<Card style={{ display: "flex", flexDirection: "column", gap: 14, padding: 20 }}>
<Card style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<SectionHeader title={s.recurringTitle} />
{recurringLoading ? (
<Skeleton height="1.4em" width="140px" />
) : (
<div style={{ display: "flex", alignItems: "baseline", gap: 6 }}>
<span style={{ fontSize: 24, fontWeight: 700 }}>{tugrik(recurringTotal)}</span>
<span style={{ fontSize: 22, fontWeight: 700 }}>{tugrik(recurringTotal)}</span>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{s.perMonth}</span>
</div>
)}
{!recurringLoading && visibleRecurring.length === 0 ? (
{!recurringLoading && subs.length === 0 && bills.length === 0 ? (
<EmptyState icon="sparkles" title={s.noSubsTitle} hint={s.noSubsHint} compact />
) : (
!recurringLoading && (
<div style={{ display: "flex", flexDirection: "column" }}>
{visibleRecurring.map(({ sub, icon }) => (
<RecurringRow key={sub.matchKey ?? sub.label} sub={sub} icon={icon} />
{subs.map((sub) => (
<RecurringRow key={sub.matchKey ?? sub.label} sub={sub} icon="sparkles" />
))}
{bills.map((bill) => (
<RecurringRow key={bill.matchKey ?? bill.label} sub={bill} icon="card" />
))}
</div>
)
@ -505,7 +454,7 @@ export function DashboardView() {
<span
style={{ fontSize: 13, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{prettyMerchant(d.label)}
{d.label}
</span>
<span style={{ fontSize: 11, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.detectedTimes(d.count)}
@ -516,16 +465,10 @@ export function DashboardView() {
))}
</div>
)}
{!recurringLoading && recurringRows.length > 0 && (
<ViewAllLink
href="/profile/subscriptions"
label={hiddenRecurringCount > 0 ? `${s.viewAll} (+${hiddenRecurringCount})` : s.viewAll}
/>
)}
</Card>
{/* 4. Цэвэр хөрөнгө — net worth + composition. */}
<Card style={{ display: "flex", flexDirection: "column", gap: 14, padding: 20 }}>
<Card style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<SectionHeader title={s.netWorthTitle} />
{netWorthLoading ? (
<Skeleton height="1.8em" width="160px" />

View file

@ -1,18 +1,9 @@
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, it, expect, beforeAll, afterEach, afterAll, vi } from "vitest";
import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
import { server } from "../../test/server";
import { DashboardView } from "./DashboardView";
// DashboardView's categorize-nudge button navigates via `useRouter().push`
// (App Router client hook), which requires a mounted router context this
// plain QueryClientProvider render doesn't provide. Mock it like other
// component tests do (see AuthForm.test.tsx) rather than pull in a full
// router harness.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
}));
/**
* Integration test: DashboardView wired to real react-query hooks, backed by
* MSW (not a hook mock) serving /networth + /analyze (month & today) +

View file

@ -1,25 +0,0 @@
import { describe, it, expect } from "vitest";
import { prettyMerchant } from "./prettyMerchant";
describe("prettyMerchant", () => {
it("collapses masked card-number strings to a clean label", () => {
expect(prettyMerchant("554835******6886:13-08-2026 12:13:52:MCI updates")).toBe("Картын гүйлгээ");
});
it("passes short clean names through unchanged", () => {
expect(prettyMerchant("NOMIN")).toBe("NOMIN");
});
it("truncates long non-card names with an ellipsis, capped at maxLen", () => {
const long = "A very long merchant name that goes on and on and on";
const out = prettyMerchant(long, 20);
expect(out.length).toBeLessThanOrEqual(20);
expect(out.endsWith("…")).toBe(true);
});
it("handles empty/blank/nullish input", () => {
expect(prettyMerchant(undefined)).toBe("");
expect(prettyMerchant(null)).toBe("");
expect(prettyMerchant(" ")).toBe("");
});
});

View file

@ -1,17 +0,0 @@
/** Merchant/payee display-name cleanup for home-screen rows. Raw card-rail
* transaction titles from the bank feed often look like
* "554835******6886:13-08-2026 12:13:52:MCI…" a masked card number glued
* to a timestamp and processor code. That's noise, not a merchant name, so
* collapse it to a plain label; anything else just gets a length cap so a
* long raw string can't blow out a row's layout. */
// A masked card-number fragment: a run of digits, 2+ asterisks, more digits.
const MASKED_CARD_PATTERN = /\d{3,}\*{2,}\d{2,}/;
export function prettyMerchant(name: string | null | undefined, maxLen = 24): string {
const raw = (name ?? "").trim();
if (!raw) return "";
if (MASKED_CARD_PATTERN.test(raw)) return "Картын гүйлгээ";
if (raw.length <= maxLen) return raw;
return `${raw.slice(0, maxLen - 1).trimEnd()}`;
}

View file

@ -26,7 +26,7 @@ export function detectRecurringMerchants(
): DetectedRecurring[] {
const groups = new Map<string, { label: string; count: number; total: number }>();
for (const t of txns) {
if (t.transfer === true || t.fee === true || t.passThrough === true || t.direction === "income") continue;
if (t.transfer === true || t.direction === "income") continue;
const key = t.matchKey || t.title;
if (!key || knownMatchKeys.has(key)) continue;
const amount = dec(t.amount);

View file

@ -18,9 +18,6 @@ export const homeStrings = {
nudge: (count: number) => `${count} гүйлгээ ангилаагүй байна`,
nudgeCta: "Ангилах",
// Shared "see the full list" link, capped sections on the home cards.
viewAll: "Бүгдийг харах",
// 2. Хаана зарцуулсан бэ?
whereWentTitle: "Хаана зарцуулсан бэ?",
categoriesLabel: "Ангилал",