From cc852e7c6f4e3a23a319f04ccc2f11a808ea44eb Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sun, 26 Jul 2026 15:08:29 +1000 Subject: [PATCH] feat(rules): preview what a rule would change before applying it Selecting a rule now shows the transactions it would alter, so a subset can be ticked and applied rather than trusting a bulk run. The apply step takes explicit transaction ids (the existing bulk apply_rule path), so what you tick is exactly what changes - a rule whose conditions are too broad cannot reach further than the preview showed. Matches are split into 'would change' and 'already correct'. A merchant normalisation rule matching 400 rows where 380 already hold the right value is 20 changes and 380 rows of noise; only the 20 are listed. Preview is offered for every rule including manual_only quick actions, which previously had no way to see their reach at all. A rule with no conditions matches every transaction - that is how apply already behaves, so the preview reports it prominently rather than hiding it. Applies still snapshot to rule_apply_runs, so they remain revertable. --- .gitignore | 3 + src/app/api/rules/[id]/matches/route.ts | 127 ++++++++++++++++++ src/app/rules/page.tsx | 24 +++- src/components/rule-preview-modal.tsx | 164 ++++++++++++++++++++++++ src/lib/hooks.ts | 60 +++++++++ 5 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 src/app/api/rules/[id]/matches/route.ts create mode 100644 src/components/rule-preview-modal.tsx diff --git a/.gitignore b/.gitignore index 1228ccf..6e7f868 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ yarn-error.log* next-env.d.ts /src/generated/prisma + +# Raw statement exports — real financial data, never commit +dump/ diff --git a/src/app/api/rules/[id]/matches/route.ts b/src/app/api/rules/[id]/matches/route.ts new file mode 100644 index 0000000..7179a77 --- /dev/null +++ b/src/app/api/rules/[id]/matches/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { queryRaw } from "@/lib/db"; +import { getTransactions } from "@/lib/queries"; +import { evaluateCondition, type Condition, type Actions } from "@/lib/rules"; + +/** + * Dry run: which transactions would this rule touch, and what would change? + * + * Nothing is written. This exists so a rule can be inspected before it is + * applied — the apply path (POST /api/transactions/bulk with action + * "apply_rule") takes an explicit list of ids, so the flow is preview, pick, + * then apply. + * + * Matches are split into two groups because they need different attention: + * `changes` are rows the rule would actually alter, `noops` already have the + * value the rule would set. A merchant-normalisation rule matching 400 rows + * where 380 are already correct is 20 changes and 380 rows of noise. + */ + +interface Change { + field: "category" | "merchant" | "tags" | "split"; + from: string | null; + to: string; +} + +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 rules = await queryRaw<{ + id: number; name: string; conditions: unknown; actions: unknown; + }>( + `SELECT id, name, conditions, actions FROM rules WHERE id = $1 AND owner_id = $2`, + [Number(id), user.id] + ); + if (!rules.length) return NextResponse.json({ error: "Rule not found" }, { status: 404 }); + + const rule = rules[0]; + 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; + + const { searchParams } = new URL(req.url); + const statementId = searchParams.get("statement_id"); + const limit = Math.min(2000, Math.max(1, Number(searchParams.get("limit") || "500"))); + + const { data: transactions } = await getTransactions(user.id, { + limit: 100000, + offset: 0, + ...(statementId ? { statement_id: statementId } : {}), + }); + + // A rule with no conditions matches every transaction. That is how the apply + // endpoint behaves too, so the preview must show it rather than hide it — + // seeing "matches 3,721" is the point. + const matchAll = conditions.length === 0; + + const tagIds = new Set(actions.add_tag_ids ?? []); + const splitTarget = actions.apply_split ?? []; + + const changes: unknown[] = []; + let noops = 0; + + for (const tx of transactions) { + if (!matchAll && !conditions.every((c) => evaluateCondition(c, tx))) continue; + + const diff: Change[] = []; + + if (actions.set_category && tx.effective_category !== actions.set_category) { + diff.push({ field: "category", from: tx.effective_category ?? null, to: actions.set_category }); + } + if (actions.set_merchant && tx.effective_merchant !== actions.set_merchant) { + diff.push({ field: "merchant", from: tx.effective_merchant ?? null, to: actions.set_merchant }); + } + if (tagIds.size) { + const have = new Set((tx.tags ?? []).map((t) => t.id)); + const missing = [...tagIds].filter((t) => !have.has(t)); + if (missing.length) { + diff.push({ field: "tags", from: null, to: missing.join(",") }); + } + } + if (splitTarget.length) { + // Compare the participant/share set the rule would write against what is + // already there; a re-run that changes nothing should not look like work. + const have = new Map((tx.splits ?? []).map((s) => [s.participant_id, Number(s.share_percent)])); + const differs = splitTarget.length !== have.size + || splitTarget.some((s) => have.get(s.participant_id) !== Number(s.share_percent)); + if (differs) { + diff.push({ + field: "split", + from: [...have.entries()].map(([p, s]) => `${p}:${s}%`).join(" ") || null, + to: splitTarget.map((s) => `${s.participant_id}:${s.share_percent}%`).join(" "), + }); + } + } + + if (diff.length === 0) { noops++; continue; } + + changes.push({ + id: tx.id, + transaction_date: tx.transaction_date, + description: tx.description, + amount: tx.amount, + amount_aud: tx.amount_aud, + currency: tx.currency, + bank_name: tx.bank_name, + effective_merchant: tx.effective_merchant, + effective_category: tx.effective_category, + changes: diff, + }); + } + + return NextResponse.json({ + rule: { id: rule.id, name: rule.name, matches_everything: matchAll }, + total_matched: changes.length + noops, + would_change: changes.length, + already_correct: noops, + transactions: changes.slice(0, limit), + truncated: changes.length > limit, + }); +} diff --git a/src/app/rules/page.tsx b/src/app/rules/page.tsx index 2881868..10049e7 100644 --- a/src/app/rules/page.tsx +++ b/src/app/rules/page.tsx @@ -3,6 +3,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"; const FIELDS = [ { value: "merchant_normalized", label: "Merchant" }, @@ -82,6 +83,7 @@ export default function RulesPage() { const [showForm, setShowForm] = useState(false); 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 [name, setName] = useState(""); const [conditions, setConditions] = useState([]); const [actions, setActions] = useState(EMPTY_ACTIONS); @@ -509,9 +511,17 @@ export default function RulesPage() {

{humanAction(acts, tagNames, participantNames)}

- {/* No Apply for quick actions: their conditions are empty, so a - bulk apply would hit every transaction. They run from the - transactions page against a selection instead. */} + {/* Preview is safe for every rule, including quick actions: + it writes nothing, and the apply step takes the ids you + tick rather than re-running the conditions. */} + + {/* No blind Apply for quick actions: their conditions are + empty, so a bulk apply would hit every transaction. */} {!rule.manual_only && (
)} + + {preview && ( + setPreview(null)} + /> + )} ); } diff --git a/src/components/rule-preview-modal.tsx b/src/components/rule-preview-modal.tsx new file mode 100644 index 0000000..de9fff9 --- /dev/null +++ b/src/components/rule-preview-modal.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRuleMatches, useApplyRuleToSelection, type RuleMatchChange } from "@/lib/hooks"; +import { formatCategory } from "@/lib/categories"; + +/** + * Preview what a rule would do, then apply it to a chosen subset. + * + * The apply path takes explicit transaction ids rather than re-running the + * conditions, so what you tick is exactly what changes — a rule whose + * conditions are too broad cannot quietly reach further than the preview showed. + */ + +function fmt(amount: number, currency = "AUD") { + return new Intl.NumberFormat("en-AU", { style: "currency", currency }).format(amount); +} + +function describe(c: RuleMatchChange) { + 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 `+ tags`; + return c.from ? `split ${c.from} → ${c.to}` : `split → ${c.to}`; +} + +export function RulePreviewModal({ + ruleId, + ruleName, + onClose, +}: { + ruleId: number; + ruleName: string; + onClose: () => void; +}) { + const { data, isLoading, error } = useRuleMatches(ruleId); + const apply = useApplyRuleToSelection(); + const [selected, setSelected] = useState>(new Set()); + const [done, setDone] = useState(null); + + // Default to everything the rule would change — the common case is "yes, all + // of these", with deselection as the exception. + useEffect(() => { + if (data?.transactions) setSelected(new Set(data.transactions.map((t) => t.id))); + }, [data]); + + const rows = data?.transactions ?? []; + const allSelected = rows.length > 0 && selected.size === rows.length; + const totalValue = useMemo( + () => rows.filter((r) => selected.has(r.id)) + .reduce((a, r) => a + Number(r.amount_aud ?? r.amount), 0), + [rows, selected] + ); + + return ( +
+
+
+

Preview: {ruleName}

+ {data && ( +

+ {data.total_matched} matched · {data.would_change} would change + {data.already_correct > 0 && ` · ${data.already_correct} already correct`} +

+ )} + {data?.rule.matches_everything && ( +

+ This rule has no conditions — it matches every transaction. +

+ )} +
+ +
+ {isLoading &&

Checking…

} + {error &&

Failed to load matches.

} + {data && rows.length === 0 && ( +

+ Nothing would change{data.already_correct > 0 && ` — all ${data.already_correct} matching transactions already have these values`}. +

+ )} + {rows.length > 0 && ( + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
+ + setSelected(e.target.checked ? new Set(rows.map((r) => r.id)) : new Set()) + } + /> + DateDescriptionAmountWould change
+ { + const next = new Set(selected); + if (e.target.checked) next.add(r.id); else next.delete(r.id); + setSelected(next); + }} + /> + {String(r.transaction_date).slice(0, 10)} + {r.effective_merchant || r.description} + {r.bank_name} + + {fmt(Number(r.amount_aud ?? r.amount))} + + {r.changes.map((c, i) =>
{describe(c)}
)} +
+ )} + {data?.truncated && ( +

Showing the first {rows.length} — narrow the rule to see the rest.

+ )} +
+ +
+ + {done ?? `${selected.size} selected · ${fmt(totalValue)}`} + +
+ + +
+
+
+
+ ); +} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 00a8cfc..6e023ab 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -530,6 +530,66 @@ export function useDeleteRule() { }); } +export interface RuleMatchChange { + field: "category" | "merchant" | "tags" | "split"; + from: string | null; + to: string; +} +export interface RuleMatchRow { + id: number; + transaction_date: string; + description: string; + amount: number; + amount_aud: number | null; + currency: string; + bank_name: string; + effective_merchant: string; + effective_category: string; + changes: RuleMatchChange[]; +} +export interface RuleMatches { + rule: { id: number; name: string; matches_everything: boolean }; + total_matched: number; + would_change: number; + already_correct: number; + transactions: RuleMatchRow[]; + truncated: boolean; +} + +/** Dry run — what a rule would change. Writes nothing. */ +export function useRuleMatches(ruleId: number | null) { + return useQuery({ + queryKey: ["rule-matches", ruleId], + enabled: ruleId != null, + queryFn: async () => { + const res = await fetch(`/api/rules/${ruleId}/matches`); + if (!res.ok) throw new Error("Failed to load rule matches"); + return res.json(); + }, + }); +} + +/** Apply a rule to a hand-picked set of transactions (conditions ignored). */ +export function useApplyRuleToSelection() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ ruleId, ids }: { ruleId: number; ids: number[] }) => { + const res = await fetch("/api/transactions/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "apply_rule", rule_id: ruleId, ids }), + }); + if (!res.ok) throw new Error((await res.json()).error || "Failed to apply rule"); + return res.json(); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["transactions"] }); + qc.invalidateQueries({ queryKey: ["rule-matches"] }); + qc.invalidateQueries({ queryKey: ["rules"] }); + }, + }); +} + export function useApplyRules() { const qc = useQueryClient(); return useMutation({