diff --git a/prisma/migrations/0017_rule_run_provenance/migration.sql b/prisma/migrations/0017_rule_run_provenance/migration.sql new file mode 100644 index 0000000..9eadda5 --- /dev/null +++ b/prisma/migrations/0017_rule_run_provenance/migration.sql @@ -0,0 +1,28 @@ +-- Record which rule a run came from. +-- +-- rule_apply_runs stored only counts ("13 matches · 13 transactions"), which is +-- not enough to decide whether to revert: you cannot tell a merchant rename from +-- a 50/50 split of your entire history. The snapshot column already holds the +-- before-state, but nothing said what was applied or why. +-- +-- rule_name is denormalised deliberately. A run must stay readable after the +-- rule it came from is edited or deleted -- the history is a record of what +-- happened, not a pointer to what the rule says today. + +ALTER TABLE rule_apply_runs + ADD COLUMN IF NOT EXISTS rule_id INTEGER, + ADD COLUMN IF NOT EXISTS rule_name TEXT, + -- 'all' = bulk run over every enabled rule; 'rule' = one rule by conditions; + -- 'selection' = one rule against hand-picked transactions (preview → apply). + ADD COLUMN IF NOT EXISTS source TEXT; + +ALTER TABLE rule_apply_runs DROP CONSTRAINT IF EXISTS rule_apply_runs_source_check; +ALTER TABLE rule_apply_runs ADD CONSTRAINT rule_apply_runs_source_check + CHECK (source IS NULL OR source IN ('all', 'rule', 'selection')); + +-- No FK to rules: deleting a rule must not cascade away the audit trail. +CREATE INDEX IF NOT EXISTS idx_rule_apply_runs_rule + ON rule_apply_runs (rule_id) WHERE rule_id IS NOT NULL; + +-- Existing rows stay NULL. There is no way to recover which rule they ran; +-- the UI shows them as "unknown rule" rather than guessing. diff --git a/src/app/api/rules/apply/route.ts b/src/app/api/rules/apply/route.ts index 550bbc3..30e0929 100644 --- a/src/app/api/rules/apply/route.ts +++ b/src/app/api/rules/apply/route.ts @@ -17,8 +17,12 @@ export async function GET(req: NextRequest) { matched: number; transactions_affected: number; reverted_at: string | null; + rule_id: number | null; + rule_name: string | null; + source: string | null; }>( - `SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at + `SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at, + rule_id, rule_name, source FROM rule_apply_runs WHERE owner_id = $1 ORDER BY applied_at DESC LIMIT 20`, [user.id] ); @@ -37,11 +41,11 @@ export async function POST(req: NextRequest) { // Manual-only rules ("quick actions") never take part in a condition-matched // run — their conditions are typically empty, so they would match every // transaction. They are fired from the transactions page against a selection. - const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>( + const rules = await queryRaw<{ id: number; name: string; conditions: unknown; actions: unknown }>( ruleId - ? `SELECT id, conditions, actions FROM rules + ? `SELECT id, name, conditions, actions FROM rules WHERE owner_id = $1 AND id = $2 AND manual_only = false` - : `SELECT id, conditions, actions FROM rules + : `SELECT id, name, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true AND manual_only = false ORDER BY priority DESC`, ruleId ? [user.id, ruleId] : [user.id] @@ -92,9 +96,11 @@ export async function POST(req: NextRequest) { // --- 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)] + `INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot, + rule_id, rule_name, source) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, + [user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot), + ruleId, ruleId ? (rules[0]?.name ?? null) : null, ruleId ? "rule" : "all"] ); return NextResponse.json({ id: run[0].id, matched, transactions_affected: affectedIds.size }); diff --git a/src/app/api/rules/runs/[id]/route.ts b/src/app/api/rules/runs/[id]/route.ts new file mode 100644 index 0000000..be31924 --- /dev/null +++ b/src/app/api/rules/runs/[id]/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { queryRaw } from "@/lib/db"; + +/** + * What a rule run actually did — the before-state from its snapshot set against + * the values now. + * + * The run list only had counts, which is not enough to decide whether to revert: + * "13 matches · 13 transactions" reads the same whether it renamed a merchant or + * split your history with someone. Reverting is destructive, so the detail has + * to be visible before the button is pressed. + */ + +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 GET( + 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 runs = await queryRaw<{ + id: number; applied_at: string; reverted_at: string | null; + split_from: string | null; matched: number; transactions_affected: number; + rule_id: number | null; rule_name: string | null; source: string | null; + snapshot: unknown; + }>( + `SELECT id, applied_at, reverted_at, split_from, matched, transactions_affected, + rule_id, rule_name, source, snapshot + FROM rule_apply_runs WHERE id = $1 AND owner_id = $2`, + [Number(id), user.id] + ); + if (!runs.length) return NextResponse.json({ error: "Run not found" }, { status: 404 }); + + const run = runs[0]; + const snapshot = (typeof run.snapshot === "string" + ? JSON.parse(run.snapshot) : run.snapshot) as SnapshotEntry[]; + const byId = new Map(snapshot.map((s) => [s.transaction_id, s])); + const ids = snapshot.map((s) => s.transaction_id); + + if (ids.length === 0) { + return NextResponse.json({ run: { ...run, snapshot: undefined }, transactions: [] }); + } + + const current = await queryRaw<{ + id: number; transaction_date: string; description: string; + amount: number; amount_aud: number | null; bank_name: string; + category: string | null; category_override: string | null; + merchant_normalized: string | null; merchant_override: string | null; + merchant_name: string | null; + tag_ids: number[]; splits: { participant_id: number; share_percent: number }[]; + }>( + `SELECT t.id, t.transaction_date::text, t.description, t.amount, t.amount_aud, + COALESCE(s.bank_name, 'Manual') as bank_name, + t.category, o.category_override, + t.merchant_normalized, o.merchant_normalized as merchant_override, t.merchant_name, + COALESCE((SELECT json_agg(tt.tag_id) FROM transaction_tags tt WHERE tt.transaction_id = t.id), '[]'::json) as tag_ids, + COALESCE((SELECT json_agg(json_build_object('participant_id', ts.participant_id, 'share_percent', ts.share_percent)) + FROM transaction_splits ts WHERE ts.transaction_id = t.id), '[]'::json) as splits + FROM transactions t + LEFT JOIN statements s ON s.id = t.statement_id + LEFT JOIN transaction_overrides o ON o.transaction_id = t.id + WHERE t.id = ANY($1::int[]) + ORDER BY t.transaction_date DESC`, + [ids] + ); + + const parse = (v: unknown, fallback: T): T => + (typeof v === "string" ? JSON.parse(v) : v) ?? fallback; + + const transactions = current.map((t) => { + const prev = byId.get(t.id); + const nowTags: number[] = parse(t.tag_ids, []); + const nowSplits = parse<{ participant_id: number; share_percent: number }[]>(t.splits, []); + const prevTags = prev?.prev_tag_ids ?? []; + const prevSplits = prev?.prev_splits ?? []; + + const nowCategory = t.category_override ?? t.category; + const prevCategory = prev?.prev_category_override ?? t.category; + const nowMerchant = t.merchant_override ?? t.merchant_normalized ?? t.merchant_name; + const prevMerchant = prev?.prev_merchant_normalized ?? t.merchant_normalized ?? t.merchant_name; + + const changes: { field: string; from: string | null; to: string | null }[] = []; + if (nowCategory !== prevCategory) { + changes.push({ field: "category", from: prevCategory, to: nowCategory }); + } + if (nowMerchant !== prevMerchant) { + changes.push({ field: "merchant", from: prevMerchant, to: nowMerchant }); + } + const addedTags = nowTags.filter((x) => !prevTags.includes(x)); + if (addedTags.length) { + changes.push({ field: "tags", from: null, to: addedTags.join(",") }); + } + const fmtSplits = (arr: { participant_id: number; share_percent: number }[]) => + arr.length ? arr.map((s) => `${s.participant_id}:${Number(s.share_percent)}%`).sort().join(" ") : null; + if (fmtSplits(nowSplits) !== fmtSplits(prevSplits)) { + changes.push({ field: "split", from: fmtSplits(prevSplits), to: fmtSplits(nowSplits) }); + } + + return { + id: t.id, + transaction_date: t.transaction_date, + description: t.description, + amount: t.amount, + amount_aud: t.amount_aud, + bank_name: t.bank_name, + merchant: nowMerchant, + changes, + }; + }); + + return NextResponse.json({ + run: { + id: run.id, applied_at: run.applied_at, reverted_at: run.reverted_at, + split_from: run.split_from, matched: run.matched, + transactions_affected: run.transactions_affected, + rule_id: run.rule_id, rule_name: run.rule_name, source: run.source, + }, + // A reverted run still lists its transactions, but they will show no changes + // because the values are back where they started. + transactions, + still_changed: transactions.filter((t) => t.changes.length > 0).length, + }); +} diff --git a/src/app/api/transactions/bulk/route.ts b/src/app/api/transactions/bulk/route.ts index c220f6b..e8c626d 100644 --- a/src/app/api/transactions/bulk/route.ts +++ b/src/app/api/transactions/bulk/route.ts @@ -110,9 +110,11 @@ export async function POST(req: NextRequest) { } const run = await queryRaw<{ id: number }>( - `INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot) - VALUES ($1, NULL, $2, $3, $4) RETURNING id`, - [user.id, ids.length, ids.length, JSON.stringify(snapshot)] + `INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot, + rule_id, rule_name, source) + VALUES ($1, NULL, $2, $3, $4, $5, $6, 'selection') RETURNING id`, + [user.id, ids.length, ids.length, JSON.stringify(snapshot), + rules[0].id, rules[0].name] ); return NextResponse.json({ updated: ids.length, run_id: run[0].id, rule: rules[0].name }); diff --git a/src/app/rules/page.tsx b/src/app/rules/page.tsx index 10049e7..1155761 100644 --- a/src/app/rules/page.tsx +++ b/src/app/rules/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useRuleRuns, useRevertRuleRun, useTags, useParticipants } from "@/lib/hooks"; import { CATEGORIES, formatCategory } from "@/lib/categories"; import { RulePreviewModal } from "@/components/rule-preview-modal"; +import { RuleRunDetail } from "@/components/rule-run-detail"; const FIELDS = [ { value: "merchant_normalized", label: "Merchant" }, @@ -84,6 +85,7 @@ export default function RulesPage() { const [editingId, setEditingId] = useState(null); const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null); const [preview, setPreview] = useState<{ id: number; name: string } | null>(null); + const [expandedRun, setExpandedRun] = useState(null); const [name, setName] = useState(""); const [conditions, setConditions] = useState([]); const [actions, setActions] = useState(EMPTY_ACTIONS); @@ -451,27 +453,49 @@ export default function RulesPage() {

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}} +
+
+
+ + {new Date(run.applied_at).toLocaleString()} + {/* Which rule ran matters most: a merchant rename and a + 50/50 split of everything look identical as counts. */} + + {run.rule_name ?? (run.source === "all" ? "All rules" : "Unknown rule")} + + {run.source === "selection" && ( + + selection + + )} + {run.transactions_affected} transactions + {run.split_from && ( + splits from {String(run.split_from).slice(0, 10)} + )} +
+ {run.reverted_at ? ( + reverted {new Date(run.reverted_at).toLocaleString()} + ) : ( + + )}
- {run.reverted_at ? ( - reverted {new Date(run.reverted_at).toLocaleString()} - ) : ( - - )} + {expandedRun === run.id && }
))}
diff --git a/src/components/rule-run-detail.tsx b/src/components/rule-run-detail.tsx new file mode 100644 index 0000000..76e6e63 --- /dev/null +++ b/src/components/rule-run-detail.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useRuleRunDetail } from "@/lib/hooks"; +import { formatCategory } from "@/lib/categories"; + +/** + * The expanded body of an Apply History row: which transactions the run touched + * and what it changed on each, so Revert is an informed decision rather than a + * guess from a count. + */ + +function fmt(n: number) { + return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD" }).format(n); +} + +function describe(c: { field: string; from: string | null; to: string | null }) { + if (c.field === "category") return `${formatCategory(c.from)} → ${formatCategory(c.to)}`; + if (c.field === "merchant") return `${c.from ?? "—"} → ${c.to ?? "—"}`; + if (c.field === "tags") return `+ tag ${c.to}`; + return `split ${c.from ?? "none"} → ${c.to ?? "none"}`; +} + +export function RuleRunDetail({ runId }: { runId: number }) { + const { data, isLoading, error } = useRuleRunDetail(runId); + + if (isLoading) return

Loading…

; + if (error) return

Failed to load detail.

; + if (!data) return null; + + const { transactions, still_changed, run } = data; + + return ( +
+
+ {transactions.length} transactions + {run.reverted_at ? ( + already reverted — values are back to their originals + ) : ( + {still_changed} still showing this run's changes + )} + {still_changed < transactions.length && !run.reverted_at && ( + + {transactions.length - still_changed} since changed by something else — reverting restores the pre-run value + + )} +
+
+ + + + + + + + + + + {transactions.map((t) => ( + + + + + + + ))} + +
DateTransactionAmountChanged
+ {String(t.transaction_date).slice(0, 10)} + + {t.merchant || t.description} + {t.bank_name} + + {fmt(Number(t.amount_aud ?? t.amount))} + + {t.changes.length === 0 ? ( + no change + ) : ( + t.changes.map((c, i) => ( +
{describe(c)}
+ )) + )} +
+
+
+ ); +} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 6e023ab..8bd5a50 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -617,6 +617,38 @@ export interface RuleRun { matched: number; transactions_affected: number; reverted_at: string | null; + // Provenance (migration 0017). NULL on runs recorded before it existed. + rule_id: number | null; + rule_name: string | null; + source: "all" | "rule" | "selection" | null; +} + +export interface RuleRunDetail { + run: RuleRun & { transactions_affected: number }; + transactions: { + id: number; + transaction_date: string; + description: string; + amount: number; + amount_aud: number | null; + bank_name: string; + merchant: string | null; + changes: { field: string; from: string | null; to: string | null }[]; + }[]; + still_changed: number; +} + +/** What a run actually did — loaded on demand when a row is expanded. */ +export function useRuleRunDetail(runId: number | null) { + return useQuery({ + queryKey: ["rule-run-detail", runId], + enabled: runId != null, + queryFn: async () => { + const res = await fetch(`/api/rules/runs/${runId}`); + if (!res.ok) throw new Error("Failed to load run detail"); + return res.json(); + }, + }); } export function useRuleRuns() {