/** * Applying a rule's `actions` to transactions, and snapshotting the before-state * so a run can be reverted. * * Shared by the two callers that write rule actions: * - `POST /api/rules/apply` — condition-matched bulk run * - `POST /api/transactions/bulk` with `action: "apply_rule"` — quick action * fired against a hand-picked selection (conditions ignored) */ import { queryRaw } from "@/lib/db"; import { completeSplit } from "@/lib/splits"; import type { Actions } from "@/lib/rules"; export interface SnapshotEntry { transaction_id: number; had_override: boolean; prev_category_override: string | null; prev_merchant_normalized: string | null; prev_tag_ids: number[]; prev_splits: { participant_id: number; share_percent: number; settled: boolean }[]; } /** * Apply `actions` to one transaction. * * Tags are additive (ON CONFLICT DO NOTHING) — re-running never duplicates and * never removes tags the rule doesn't mention. Splits replace the participant * set but upsert shares, so `settled` flags survive a re-run; a plain * delete+reinsert would silently un-settle reconciled splits. */ export async function applyRuleActions( transactionId: number, actions: Actions, opts: { skipSplit?: boolean } = {} ): Promise { if (actions.set_category || actions.set_merchant) { await queryRaw( `INSERT INTO transaction_overrides (transaction_id, category_override, merchant_normalized) VALUES ($1, $2, $3) ON CONFLICT (transaction_id) DO UPDATE SET category_override = COALESCE($2, transaction_overrides.category_override), merchant_normalized = COALESCE($3, transaction_overrides.merchant_normalized), updated_at = NOW()`, [transactionId, actions.set_category || null, actions.set_merchant || null] ); } if (actions.add_tag_ids?.length) { for (const tagId of actions.add_tag_ids) { await queryRaw( `INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [transactionId, tagId] ); } } if (actions.apply_split?.length && !opts.skipSplit) { await queryRaw( `DELETE FROM transaction_splits WHERE transaction_id = $1 AND participant_id != ALL($2::int[])`, [transactionId, actions.apply_split.map((s) => s.participant_id)] ); for (const s of actions.apply_split) { await queryRaw( `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, $3) ON CONFLICT (transaction_id, participant_id) DO UPDATE SET share_percent = EXCLUDED.share_percent`, [transactionId, s.participant_id, s.share_percent] ); } // A rule may name only the other person — "split Woolworths with Sonu 50%" // is a complete thought, and ten of the original rules are written that way. // The payer's half is implied by it, so write the implication down. await completeSplit(transactionId); } } /** Capture the overrides / tags / splits of `ids` before a run mutates them. */ export async function captureSnapshot(ids: number[]): Promise { if (ids.length === 0) return []; const overrides = await queryRaw<{ transaction_id: number; category_override: string | null; merchant_normalized: string | null; }>( `SELECT transaction_id, category_override, merchant_normalized FROM transaction_overrides WHERE transaction_id = ANY($1::int[])`, [ids] ); const overrideMap = new Map(overrides.map((o) => [o.transaction_id, o])); const tagRows = await queryRaw<{ transaction_id: number; tag_id: number }>( `SELECT transaction_id, tag_id FROM transaction_tags WHERE transaction_id = ANY($1::int[])`, [ids] ); const tagMap = new Map(); for (const row of tagRows) { if (!tagMap.has(row.transaction_id)) tagMap.set(row.transaction_id, []); tagMap.get(row.transaction_id)!.push(row.tag_id); } const splitRows = await queryRaw<{ transaction_id: number; participant_id: number; share_percent: number; settled: boolean; }>( `SELECT transaction_id, participant_id, share_percent, settled FROM transaction_splits WHERE transaction_id = ANY($1::int[])`, [ids] ); const splitMap = new Map(); for (const row of splitRows) { if (!splitMap.has(row.transaction_id)) splitMap.set(row.transaction_id, []); splitMap.get(row.transaction_id)!.push({ participant_id: row.participant_id, share_percent: row.share_percent, settled: row.settled, }); } return ids.map((id) => { const ov = overrideMap.get(id); return { transaction_id: id, had_override: !!ov, prev_category_override: ov?.category_override ?? null, prev_merchant_normalized: ov?.merchant_normalized ?? null, prev_tag_ids: tagMap.get(id) ?? [], prev_splits: splitMap.get(id) ?? [], }; }); }