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,89 @@
import { and, between, eq, inArray, isNull, ne } from 'drizzle-orm';
import type { Suggester } from '../categorize/suggest';
import type { AppContext } from '../context';
import { accounts, transactions, type TransactionRow } from '../db/schema';
import { daysBetween, shiftDate } from './parse';
// Pairs the two halves of a card payment or a move between your own accounts:
// money leaving one account and the same amount arriving in another within a
// few days. Paired halves are filed as transfers and never count as spending
// or income.
const WINDOW_DAYS = 5;
export function pairTransfers(ctx: AppContext, suggester: Suggester, ids: number[]): number {
if (!ids.length) return 0;
const last4ByAccount = new Map(
ctx.db
.select({ id: accounts.id, last4: accounts.last4 })
.from(accounts)
.all()
.map((a) => [a.id, a.last4]),
);
const fresh = ctx.db.select().from(transactions).where(inArray(transactions.id, ids)).all();
let paired = 0;
for (const t of fresh) {
if (t.transferPeerId || t.amountCents === 0) continue;
const current = ctx.db.select().from(transactions).where(eq(transactions.id, t.id)).get();
if (!current || current.transferPeerId) continue;
const candidates = ctx.db
.select()
.from(transactions)
.where(
and(
ne(transactions.accountId, t.accountId),
eq(transactions.amountCents, -t.amountCents),
isNull(transactions.transferPeerId),
eq(transactions.isSplit, false),
between(transactions.date, shiftDate(t.date, -WINDOW_DAYS), shiftDate(t.date, WINDOW_DAYS)),
),
)
.all();
const tLike = suggester.isTransferLike(t);
const score = (c: TransactionRow) => {
const cLike = suggester.isTransferLike(c);
if (!tLike && !cLike) return -1;
let s = (tLike ? 2 : 0) + (cLike ? 2 : 0);
const cLast4 = last4ByAccount.get(c.accountId);
const tLast4 = last4ByAccount.get(t.accountId);
if (cLast4 && t.description.includes(cLast4)) s += 3;
if (tLast4 && c.description.includes(tLast4)) s += 3;
return s * 10 - Math.abs(daysBetween(t.date, c.date));
};
const best = candidates
.map((c) => ({ c, s: score(c) }))
.filter((x) => x.s >= 0)
.sort((a, b) => b.s - a.s)[0];
if (!best) continue;
const categoryId = suggester.transferCategoryFor(t.accountId, t.description, best.c.accountId);
const now = new Date().toISOString();
for (const [self, peer] of [
[t.id, best.c.id],
[best.c.id, t.id],
] as const) {
ctx.db
.update(transactions)
.set({ transferPeerId: peer, categoryId, needsReview: false, suggestedBy: 'transfer', isSplit: false, updatedAt: now })
.where(eq(transactions.id, self))
.run();
}
paired++;
}
return paired;
}
/** Undo a pairing, putting both halves back in Review. */
export function unpair(ctx: AppContext, transactionId: number): void {
const t = ctx.db.select().from(transactions).where(eq(transactions.id, transactionId)).get();
if (!t?.transferPeerId) return;
const now = new Date().toISOString();
ctx.db
.update(transactions)
.set({ transferPeerId: null, needsReview: true, updatedAt: now })
.where(inArray(transactions.id, [t.id, t.transferPeerId]))
.run();
}