import { and, count, desc, eq, isNotNull, isNull } from 'drizzle-orm'; import type { AppContext } from '../context'; import { accounts, categories, rules, transactions, type CategoryRow, type RuleRow } from '../db/schema'; import { BANK_CATEGORIES, knownMerchant, TRANSFER_PATTERN } from './known'; // Where a pre-filled category comes from, strongest first: // 1. your rules ("always file X as Y") // 2. your history: the last 3 picks for this merchant (all agreeing = confident) // 3. card payments and transfers, recognized by their wording // 4. built-in knowledge of common merchants // 5. the bank's own category // Your choices always outrank built-in guesses. export interface SuggestInput { accountId: number; merchant: string; description: string; amountCents: number; bankCategory?: string | null; bankType?: string | null; } export interface Suggestion { categoryId: number | null; /** Up to three likely categories, best first. */ suggestions: number[]; confident: boolean; source: 'rule' | 'history' | 'transfer' | 'known' | 'bank' | null; } const CARD_WORDS = /CARD|CRD|DISCOVER|CHASE|AMEX|AMERICAN EXPRESS|CITI|CAPITAL ONE|CAP ONE|SYNCHRONY|BARCLAY|APPLECARD|GSBANK/i; export class Suggester { private byName = new Map(); private byId = new Map(); private rulesByMerchant = new Map(); private accountKind = new Map(); private popular: { expense: number[]; income: number[] } | null = null; constructor(private ctx: AppContext) { for (const c of ctx.db.select().from(categories).where(eq(categories.archived, false)).all()) { this.byName.set(c.name.toLowerCase(), c); this.byId.set(c.id, c); } for (const r of ctx.db.select().from(rules).all()) { const key = r.merchant.toLowerCase(); this.rulesByMerchant.set(key, [...(this.rulesByMerchant.get(key) ?? []), r]); } for (const a of ctx.db.select({ id: accounts.id, kind: accounts.kind }).from(accounts).all()) this.accountKind.set(a.id, a.kind); } categoryId(name: string): number | null { return this.byName.get(name.toLowerCase())?.id ?? null; } /** The rule that renames or files this merchant, if any (an account-specific rule wins). */ rule(merchant: string, accountId: number): RuleRow | undefined { const list = this.rulesByMerchant.get(merchant.toLowerCase()) ?? []; return list.find((r) => r.accountId === accountId) ?? list.find((r) => r.accountId === null); } isTransferLike(input: Pick): boolean { return ( TRANSFER_PATTERN.test(input.description) || /^payment$/i.test(input.bankType ?? '') || /^payments and credits$/i.test(input.bankCategory ?? '') ); } transferCategoryFor(accountId: number, description: string, otherAccountId?: number): number | null { const cardInvolved = this.accountKind.get(accountId) === 'credit_card' || (otherAccountId !== undefined && this.accountKind.get(otherAccountId) === 'credit_card') || CARD_WORDS.test(description); return this.categoryId(cardInvolved ? 'Credit Card Payment' : 'Transfer Between Accounts'); } private fits(categoryId: number | null, amountCents: number): categoryId is number { if (categoryId === null) return false; const c = this.byId.get(categoryId); if (!c) return false; // Income categories only make sense for money coming in. return !(c.kind === 'income' && amountCents < 0); } private history(merchant: string): number[] { return this.ctx.db .select({ categoryId: transactions.categoryId }) .from(transactions) .where( and( eq(transactions.merchant, merchant), eq(transactions.needsReview, false), eq(transactions.isSplit, false), isNotNull(transactions.categoryId), ), ) .orderBy(desc(transactions.date), desc(transactions.id)) .limit(20) .all() .map((r) => r.categoryId!); } private popularCategories(direction: 'expense' | 'income'): number[] { if (!this.popular) { const rows = this.ctx.db .select({ categoryId: transactions.categoryId, n: count() }) .from(transactions) .where(and(eq(transactions.needsReview, false), isNotNull(transactions.categoryId))) .groupBy(transactions.categoryId) .orderBy(desc(count())) .limit(30) .all(); const used = rows.map((r) => r.categoryId!).filter((id) => this.byId.has(id)); const defaults = (names: string[]) => names.map((n) => this.categoryId(n)).filter((id): id is number => id !== null); const of = (kind: 'expense' | 'income') => used.filter((id) => this.byId.get(id)!.kind === kind); this.popular = { expense: [...of('expense'), ...defaults(['Groceries', 'Dining Out', 'General Merchandise', 'Household Supplies'])], income: [...of('income'), ...defaults(['Paycheck', 'Other Income', 'Interest and Dividends'])], }; } return this.popular[direction]; } suggest(input: SuggestInput): Suggestion { const candidates: number[] = []; const add = (id: number | null) => { if (this.fits(id, input.amountCents) && !candidates.includes(id)) candidates.push(id); }; let source: Suggestion['source'] = null; let confident = false; const rule = this.rule(input.merchant, input.accountId); if (rule?.categoryId) { add(rule.categoryId); source = 'rule'; confident = true; } const past = this.history(input.merchant); if (past.length) { const last3 = past.slice(0, 3); if (!source) { add(last3[0]!); source = 'history'; confident = last3.length === 3 && last3.every((id) => id === last3[0]); } // Other categories this merchant has had, most frequent first. const freq = new Map(); for (const id of past) freq.set(id, (freq.get(id) ?? 0) + 1); [...freq.entries()].sort((a, b) => b[1] - a[1]).forEach(([id]) => add(id)); } if (this.isTransferLike(input)) { add(this.transferCategoryFor(input.accountId, input.description)); source ??= 'transfer'; } const known = knownMerchant(input.description); if (known?.category) { add(this.categoryId(known.category)); source ??= 'known'; } const bank = input.bankCategory ? BANK_CATEGORIES[input.bankCategory.trim().toLowerCase()] : undefined; if (bank) { add(this.categoryId(bank)); source ??= 'bank'; } // Fill the alternates with the best guess's neighbours first (Electric and // Gas → Water and Trash, Internet), then with what this household uses most. const lead = candidates[0] !== undefined ? this.byId.get(candidates[0]) : undefined; if (lead) { for (const c of this.byId.values()) { if (candidates.length >= 3) break; if (c.groupId === lead.groupId) add(c.id); } } for (const id of this.popularCategories(input.amountCents > 0 && this.accountKind.get(input.accountId) !== 'credit_card' ? 'income' : 'expense')) { if (candidates.length >= 3) break; add(id); } return { categoryId: source ? (candidates[0] ?? null) : null, suggestions: candidates.slice(0, 3), confident, source: source && candidates.length ? source : null, }; } } /** Remember "always file this merchant this way" (one rule per merchant, newest wins). */ export function rememberRule(ctx: AppContext, merchant: string, categoryId: number, source: 'learned' | 'manual' = 'learned') { const now = new Date().toISOString(); const existing = ctx.db .select() .from(rules) .where(and(eq(rules.merchant, merchant), isNull(rules.accountId))) .get(); if (existing) { ctx.db.update(rules).set({ categoryId, source, lastUsedAt: now }).where(eq(rules.id, existing.id)).run(); return existing.id; } return ctx.db.insert(rules).values({ merchant, categoryId, source, createdAt: now }).returning().get().id; }