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>
90 lines
3.3 KiB
TypeScript
90 lines
3.3 KiB
TypeScript
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();
|
|
}
|