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:
89
server/src/import/transfers.ts
Normal file
89
server/src/import/transfers.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user