Milestones 0 and 1: foundation and money in (20261007.10)
Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite), local sign-in with long-lived sessions and passkeys, roles (read-only, full access, admin), nightly and pre-upgrade backups, light and dark themes, date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED. Money in: accounts with balances, CSV import with bank detection, a column mapping pop-up for unknown layouts, duplicate checks, transfer pairing and undo; one-tap review with learned rules; activity with search, splits, tags, notes and cash entries. Statement files are blocked from commits by a pre-commit hook; tests use synthetic fixtures only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
207
server/src/categorize/suggest.ts
Normal file
207
server/src/categorize/suggest.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
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<string, CategoryRow>();
|
||||
private byId = new Map<number, CategoryRow>();
|
||||
private rulesByMerchant = new Map<string, RuleRow[]>();
|
||||
private accountKind = new Map<number, string>();
|
||||
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<SuggestInput, 'description' | 'bankType' | 'bankCategory'>): 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<number, number>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user