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:
2026-10-07 19:52:07 -04:00
commit c3cf0f3eb8
147 changed files with 21713 additions and 0 deletions

View 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;
}