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(); }