feat(rules): apply_split rules with run history and revert

- 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
This commit is contained in:
2026-03-14 20:06:19 +11:00
parent 859043f5a5
commit 9f90d8726f
3 changed files with 417 additions and 44 deletions
@@ -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 });
}
+110 -13
View File
@@ -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<number>();
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<number, number[]>();
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<number, { participant_id: number; share_percent: number; settled: boolean }[]>();
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<number>();
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 });
}