feat(rules): preview what a rule would change before applying it
ci / lint-test (push) Successful in 34s
ci / lint-test (push) Successful in 34s
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.
This commit is contained in:
@@ -42,3 +42,6 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
|
||||||
|
# Raw statement exports — real financial data, never commit
|
||||||
|
dump/
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
+21
-3
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useRuleRuns, useRevertRuleRun, useTags, useParticipants } from "@/lib/hooks";
|
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useRuleRuns, useRevertRuleRun, useTags, useParticipants } from "@/lib/hooks";
|
||||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
|
import { RulePreviewModal } from "@/components/rule-preview-modal";
|
||||||
|
|
||||||
const FIELDS = [
|
const FIELDS = [
|
||||||
{ value: "merchant_normalized", label: "Merchant" },
|
{ value: "merchant_normalized", label: "Merchant" },
|
||||||
@@ -82,6 +83,7 @@ export default function RulesPage() {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(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 [name, setName] = useState("");
|
||||||
const [conditions, setConditions] = useState<Condition[]>([]);
|
const [conditions, setConditions] = useState<Condition[]>([]);
|
||||||
const [actions, setActions] = useState<Actions>(EMPTY_ACTIONS);
|
const [actions, setActions] = useState<Actions>(EMPTY_ACTIONS);
|
||||||
@@ -509,9 +511,17 @@ export default function RulesPage() {
|
|||||||
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames, participantNames)}</p>
|
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames, participantNames)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
{/* No Apply for quick actions: their conditions are empty, so a
|
{/* Preview is safe for every rule, including quick actions:
|
||||||
bulk apply would hit every transaction. They run from the
|
it writes nothing, and the apply step takes the ids you
|
||||||
transactions page against a selection instead. */}
|
tick rather than re-running the conditions. */}
|
||||||
|
<button
|
||||||
|
onClick={() => setPreview({ id: rule.id, name: rule.name })}
|
||||||
|
className="text-xs text-indigo-400 hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
{/* No blind Apply for quick actions: their conditions are
|
||||||
|
empty, so a bulk apply would hit every transaction. */}
|
||||||
{!rule.manual_only && (
|
{!rule.manual_only && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApply(rule.id)}
|
onClick={() => handleApply(rule.id)}
|
||||||
@@ -554,6 +564,14 @@ export default function RulesPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<RulePreviewModal
|
||||||
|
ruleId={preview.id}
|
||||||
|
ruleName={preview.name}
|
||||||
|
onClose={() => setPreview(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Set<number>>(new Set());
|
||||||
|
const [done, setDone] = useState<string | null>(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 (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-lg w-full max-w-4xl max-h-[85vh] flex flex-col">
|
||||||
|
<div className="px-5 py-4 border-b border-zinc-800">
|
||||||
|
<h2 className="text-lg font-medium">Preview: {ruleName}</h2>
|
||||||
|
{data && (
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
{data.total_matched} matched · <span className="text-amber-400">{data.would_change} would change</span>
|
||||||
|
{data.already_correct > 0 && ` · ${data.already_correct} already correct`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data?.rule.matches_everything && (
|
||||||
|
<p className="text-xs text-red-400 mt-1">
|
||||||
|
This rule has no conditions — it matches every transaction.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto px-5 py-3">
|
||||||
|
{isLoading && <p className="text-sm text-zinc-500">Checking…</p>}
|
||||||
|
{error && <p className="text-sm text-red-400">Failed to load matches.</p>}
|
||||||
|
{data && rows.length === 0 && (
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Nothing would change{data.already_correct > 0 && ` — all ${data.already_correct} matching transactions already have these values`}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-zinc-900">
|
||||||
|
<tr className="border-b border-zinc-800 text-zinc-500">
|
||||||
|
<th className="p-2 w-8">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelected(e.target.checked ? new Set(rows.map((r) => r.id)) : new Set())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="text-left p-2">Date</th>
|
||||||
|
<th className="text-left p-2">Description</th>
|
||||||
|
<th className="text-right p-2">Amount</th>
|
||||||
|
<th className="text-left p-2">Would change</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id} className="border-b border-zinc-800/40 hover:bg-zinc-800/30">
|
||||||
|
<td className="p-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(r.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = new Set(selected);
|
||||||
|
if (e.target.checked) next.add(r.id); else next.delete(r.id);
|
||||||
|
setSelected(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-zinc-500 whitespace-nowrap">{String(r.transaction_date).slice(0, 10)}</td>
|
||||||
|
<td className="p-2 text-zinc-300 max-w-[280px] truncate" title={r.description}>
|
||||||
|
{r.effective_merchant || r.description}
|
||||||
|
<span className="text-zinc-600 ml-2">{r.bank_name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-right tabular-nums text-zinc-400">
|
||||||
|
{fmt(Number(r.amount_aud ?? r.amount))}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-amber-400">
|
||||||
|
{r.changes.map((c, i) => <div key={i}>{describe(c)}</div>)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{data?.truncated && (
|
||||||
|
<p className="text-xs text-zinc-500 mt-2">Showing the first {rows.length} — narrow the rule to see the rest.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-3 border-t border-zinc-800 flex items-center justify-between">
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
{done ?? `${selected.size} selected · ${fmt(totalValue)}`}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={onClose} className="px-3 py-1.5 text-sm text-zinc-400 hover:text-white">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={selected.size === 0 || apply.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
apply.mutate(
|
||||||
|
{ ruleId, ids: Array.from(selected) },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setDone(`Applied to ${selected.size} transaction${selected.size !== 1 ? "s" : ""}`);
|
||||||
|
setSelected(new Set());
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="px-3 py-1.5 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 rounded text-sm"
|
||||||
|
>
|
||||||
|
{apply.isPending ? "Applying…" : `Apply to ${selected.size}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<RuleMatches>({
|
||||||
|
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() {
|
export function useApplyRules() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
Reference in New Issue
Block a user