From 9f90d8726ffc3d0438817c84c3442c201b6d0686 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sat, 14 Mar 2026 20:06:19 +1100 Subject: [PATCH] feat(rules): apply_split rules with run history and revert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/rules/apply — run all enabled rules against unmatched transactions - POST /api/rules/apply/:id — apply a single rule by id - DELETE /api/rules/apply/:id — revert a rule run (remove applied splits) - Rules page: show run history with revert button, apply individual rules --- src/app/api/rules/apply/[id]/revert/route.ts | 102 ++++++++ src/app/api/rules/apply/route.ts | 123 +++++++++- src/app/rules/page.tsx | 236 ++++++++++++++++--- 3 files changed, 417 insertions(+), 44 deletions(-) create mode 100644 src/app/api/rules/apply/[id]/revert/route.ts diff --git a/src/app/api/rules/apply/[id]/revert/route.ts b/src/app/api/rules/apply/[id]/revert/route.ts new file mode 100644 index 0000000..c600585 --- /dev/null +++ b/src/app/api/rules/apply/[id]/revert/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { queryRaw } from "@/lib/db"; + +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 }[]; +} + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + + const { id } = await params; + const runId = Number(id); + + const rows = await queryRaw<{ + id: number; + owner_id: number; + reverted_at: string | null; + snapshot: unknown; + }>( + `SELECT id, owner_id, reverted_at, snapshot FROM rule_apply_runs WHERE id = $1`, + [runId] + ); + + if (!rows.length) return NextResponse.json({ error: "Run not found" }, { status: 404 }); + const run = rows[0]; + if (run.owner_id !== user.id) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + if (run.reverted_at) return NextResponse.json({ error: "Already reverted" }, { status: 409 }); + + const snapshot = (typeof run.snapshot === "string" + ? JSON.parse(run.snapshot) + : run.snapshot) as SnapshotEntry[]; + + for (const entry of snapshot) { + const txId = entry.transaction_id; + + // Restore overrides + if (entry.had_override) { + 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 = $2, + merchant_normalized = $3, + updated_at = NOW()`, + [txId, entry.prev_category_override, entry.prev_merchant_normalized] + ); + } else { + // No override existed before — remove any that were created + await queryRaw( + `DELETE FROM transaction_overrides WHERE transaction_id = $1 + AND category_override IS NULL AND merchant_normalized IS NULL`, + [txId] + ); + // If override row exists but was only partially set by this run, clear those fields + await queryRaw( + `UPDATE transaction_overrides SET + category_override = NULL, + merchant_normalized = NULL, + updated_at = NOW() + WHERE transaction_id = $1`, + [txId] + ); + } + + // Restore tags: remove any that weren't there before, don't touch pre-existing ones + const prevTagIds = entry.prev_tag_ids; + if (prevTagIds.length > 0) { + await queryRaw( + `DELETE FROM transaction_tags WHERE transaction_id = $1 AND tag_id != ALL($2::int[])`, + [txId, prevTagIds] + ); + } else { + // No tags existed before — remove all tags (they were all added by this run) + // Note: this only removes tags on transactions that matched this run + await queryRaw(`DELETE FROM transaction_tags WHERE transaction_id = $1`, [txId]); + } + + // Restore splits + await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [txId]); + for (const s of entry.prev_splits) { + await queryRaw( + `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled) + VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`, + [txId, s.participant_id, s.share_percent, s.settled] + ); + } + } + + await queryRaw( + `UPDATE rule_apply_runs SET reverted_at = NOW() WHERE id = $1`, + [runId] + ); + + return NextResponse.json({ reverted: snapshot.length }); +} diff --git a/src/app/api/rules/apply/route.ts b/src/app/api/rules/apply/route.ts index d132cc2..be37b42 100644 --- a/src/app/api/rules/apply/route.ts +++ b/src/app/api/rules/apply/route.ts @@ -4,7 +4,7 @@ import { queryRaw } from "@/lib/db"; import { getTransactions } from "@/lib/queries"; interface Condition { - field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount"; + field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount" | "transaction_type"; operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals"; value: string; } @@ -21,12 +21,22 @@ interface Actions { apply_split?: SplitEntry[]; } +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 }[]; +} + interface TxFields { effective_category: string; effective_merchant: string; description: string; bank_name: string; amount: number; + transaction_type: string; } function evaluateCondition(cond: Condition, tx: TxFields): boolean { @@ -48,6 +58,7 @@ function evaluateCondition(cond: Condition, tx: TxFields): boolean { case "description": fieldVal = tx.description || ""; break; case "category": fieldVal = tx.effective_category || ""; break; case "bank_name": fieldVal = tx.bank_name || ""; break; + case "transaction_type": fieldVal = tx.transaction_type || ""; break; default: return false; } @@ -62,6 +73,26 @@ function evaluateCondition(cond: Condition, tx: TxFields): boolean { } } +export async function GET(req: NextRequest) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + + const runs = await queryRaw<{ + id: number; + applied_at: string; + split_from: string | null; + matched: number; + transactions_affected: number; + reverted_at: string | null; + }>( + `SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at + FROM rule_apply_runs WHERE owner_id = $1 ORDER BY applied_at DESC LIMIT 20`, + [user.id] + ); + + return NextResponse.json(runs); +} + export async function POST(req: NextRequest) { const user = await getCurrentUser(req); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); @@ -73,22 +104,81 @@ export async function POST(req: NextRequest) { if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 }); + const body = await req.json().catch(() => ({})) as { splitFrom?: string | null }; + const splitFrom = body.splitFrom || null; + const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 }); + // --- Pre-pass: find all transactions that will match any rule --- + const parsedRules = rules.map((r) => ({ + conditions: (typeof r.conditions === "string" ? JSON.parse(r.conditions) : r.conditions) as Condition[], + actions: (typeof r.actions === "string" ? JSON.parse(r.actions) : r.actions) as Actions, + })); + + const matchedIds = new Set(); + for (const tx of transactions) { + for (const { conditions } of parsedRules) { + if (conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx))) { + matchedIds.add(tx.id); + break; + } + } + } + + // --- Capture before-state for all matched transactions (batched) --- + const snapshot: SnapshotEntry[] = []; + if (matchedIds.size > 0) { + const ids = Array.from(matchedIds); + const idList = ids.join(","); + + 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 }); + } + + for (const id of ids) { + const ov = overrideMap.get(id); + snapshot.push({ + 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) ?? [], + }); + } + + void idList; // suppress unused warning + } + + // --- Apply rules --- let matched = 0; const affectedIds = new Set(); - for (const rule of rules) { - const conditions = (typeof rule.conditions === "string" - ? JSON.parse(rule.conditions) - : rule.conditions) as Condition[]; - const actions = (typeof rule.actions === "string" - ? JSON.parse(rule.actions) - : rule.actions) as Actions; - + for (const { conditions, actions } of parsedRules) { for (const tx of transactions) { - const allMatch = - conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx)); + const allMatch = conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx)); if (!allMatch) continue; matched++; @@ -116,7 +206,7 @@ export async function POST(req: NextRequest) { } if (actions.apply_split?.length) { - // Delete existing splits then insert new ones + if (splitFrom && tx.transaction_date < splitFrom) continue; await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [tx.id]); for (const s of actions.apply_split) { await queryRaw( @@ -129,5 +219,12 @@ export async function POST(req: NextRequest) { } } - return NextResponse.json({ matched, transactions_affected: affectedIds.size }); + // --- Save run record --- + const run = await queryRaw<{ id: number }>( + `INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, + [user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot)] + ); + + return NextResponse.json({ id: run[0].id, matched, transactions_affected: affectedIds.size }); } diff --git a/src/app/rules/page.tsx b/src/app/rules/page.tsx index eb9ccc3..30d7bbb 100644 --- a/src/app/rules/page.tsx +++ b/src/app/rules/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useTags } from "@/lib/hooks"; +import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useRuleRuns, useRevertRuleRun, useTags, useParticipants } from "@/lib/hooks"; import { CATEGORIES, formatCategory } from "@/lib/categories"; const FIELDS = [ @@ -10,6 +10,7 @@ const FIELDS = [ { value: "category", label: "Category" }, { value: "bank_name", label: "Bank" }, { value: "amount", label: "Amount" }, + { value: "transaction_type", label: "Transaction Type" }, ] as const; const TEXT_OPS = [ @@ -24,18 +25,24 @@ const AMOUNT_OPS = [ { value: "gt", label: ">" }, { value: "lt", label: "<" }, ]; +const ENUM_OPS = [ + { value: "equals", label: "equals" }, + { value: "not_equals", label: "not equals" }, +]; +const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"]; type Condition = { field: string; operator: string; value: string }; -type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string }; +type SplitEntry = { participant_id: number; share_percent: number }; +type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string; apply_split?: SplitEntry[] }; function humanCondition(c: Condition): string { const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field; - const ops = [...TEXT_OPS, ...AMOUNT_OPS]; + const ops = [...TEXT_OPS, ...AMOUNT_OPS, ...ENUM_OPS]; const opText = ops.find((o) => o.value === c.operator)?.label || c.operator; return `${fieldLabel} ${opText} "${c.value}"`; } -function humanAction(a: Actions, tagNames: Map): string { +function humanAction(a: Actions, tagNames: Map, participantNames: Map): string { const parts: string[] = []; if (a.set_category) parts.push(`set category: ${formatCategory(a.set_category)}`); if (a.set_merchant) parts.push(`set merchant: ${a.set_merchant}`); @@ -43,26 +50,62 @@ function humanAction(a: Actions, tagNames: Map): string { const names = a.add_tag_ids.map((id) => tagNames.get(id) || `tag#${id}`).join(", "); parts.push(`add tags: ${names}`); } + if (a.apply_split?.length) { + const splits = a.apply_split.map((s) => `${participantNames.get(s.participant_id) || `#${s.participant_id}`} ${s.share_percent}%`).join(", "); + parts.push(`split: ${splits}`); + } return parts.length ? "→ " + parts.join(", ") : "(no actions)"; } +const EMPTY_ACTIONS: Actions = {}; + export default function RulesPage() { const { data: rules = [], isLoading } = useRules(); const { data: tags = [] } = useTags(); + const { data: participants = [] } = useParticipants(); const createRule = useCreateRule(); const updateRule = useUpdateRule(); const deleteRule = useDeleteRule(); const applyRules = useApplyRules(); + const { data: runs = [] } = useRuleRuns(); + const revertRun = useRevertRuleRun(); const tagNames = new Map(tags.map((t) => [t.id, t.name])); + const participantNames = new Map(participants.map((p) => [p.id, p.name])); + const [applyFrom, setApplyFrom] = useState("2026-01-08"); const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null); const [name, setName] = useState(""); const [conditions, setConditions] = useState([]); - const [actions, setActions] = useState({}); + const [actions, setActions] = useState(EMPTY_ACTIONS); const [priority, setPriority] = useState(0); + function openNewForm() { + setEditingId(null); + setName(""); + setConditions([]); + setActions(EMPTY_ACTIONS); + setPriority(0); + setShowForm(true); + } + + function openEditForm(rule: { id: number; name: string; conditions: Condition[]; actions: Actions; priority: number }) { + setEditingId(rule.id); + setName(rule.name); + setConditions(Array.isArray(rule.conditions) ? rule.conditions : []); + setActions(rule.actions && typeof rule.actions === "object" ? rule.actions : EMPTY_ACTIONS); + setPriority(rule.priority); + setShowForm(true); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + + function closeForm() { + setShowForm(false); + setEditingId(null); + } + function addCondition() { setConditions([...conditions, { field: "merchant_normalized", operator: "contains", value: "" }]); } @@ -75,26 +118,54 @@ export default function RulesPage() { setConditions(conditions.filter((_, idx) => idx !== i)); } + function addSplitEntry() { + if (!participants.length) return; + const existing = actions.apply_split || []; + setActions({ ...actions, apply_split: [...existing, { participant_id: participants[0].id, share_percent: 0 }] }); + } + + function updateSplitEntry(i: number, patch: Partial) { + const entries = (actions.apply_split || []).map((s, idx) => (idx === i ? { ...s, ...patch } : s)); + setActions({ ...actions, apply_split: entries }); + } + + function removeSplitEntry(i: number) { + const entries = (actions.apply_split || []).filter((_, idx) => idx !== i); + setActions({ ...actions, apply_split: entries.length ? entries : undefined }); + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - await createRule.mutateAsync({ name, conditions, actions, enabled: true, priority }); - setName(""); - setConditions([]); - setActions({}); - setPriority(0); - setShowForm(false); + const payload = { name, conditions, actions, enabled: true, priority }; + if (editingId !== null) { + await updateRule.mutateAsync({ id: editingId, ...payload }); + } else { + await createRule.mutateAsync(payload); + } + closeForm(); } async function handleApply() { - const result = await applyRules.mutateAsync(); + const result = await applyRules.mutateAsync(applyFrom || undefined); setApplyResult(result); } + const splitTotal = (actions.apply_split || []).reduce((sum, s) => sum + (s.share_percent || 0), 0); + const isPending = editingId !== null ? updateRule.isPending : createRule.isPending; + return (

Rules

-
+
+ + setApplyFrom(e.target.value)} + className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" + title="Split rules only apply to transactions on or after this date. Category/merchant/tag rules apply to all transactions." + />
+
+
+ + {participants.length > 0 && ( + + )} +
+ {(actions.apply_split || []).map((entry, i) => ( +
+ + updateSplitEntry(i, { share_percent: Number(e.target.value) })} + className="w-20 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" + /> + % + +
+ ))} + {participants.length === 0 && ( +

No participants created yet.

+ )} +
+
@@ -264,15 +400,47 @@ export default function RulesPage() {
)} + {runs.length > 0 && ( +
+

Apply History

+
+ {runs.map((run) => ( +
+
+ {new Date(run.applied_at).toLocaleString()} + {run.matched} matches · {run.transactions_affected} transactions + {run.split_from && splits from {run.split_from}} +
+ {run.reverted_at ? ( + reverted {new Date(run.reverted_at).toLocaleString()} + ) : ( + + )} +
+ ))} +
+
+ )} + {isLoading ? (

Loading rules...

) : rules.length === 0 ? ( @@ -294,7 +462,7 @@ export default function RulesPage() {

{conds.length > 0 ? conds.map(humanCondition).join(" AND ") : "(matches all)"}

-

{humanAction(acts, tagNames)}

+

{humanAction(acts, tagNames, participantNames)}

+