feat(finance): Phase 5 — Rules Engine

Add rules engine with CRUD API, condition/action evaluation, and apply-all endpoint.
UI: rule builder form with field/operator/value conditions, tag multi-select, apply button with result stats.
This commit is contained in:
2026-03-08 16:48:35 +11:00
parent 93450f7caa
commit 31cffbe1bb
9 changed files with 653 additions and 8 deletions
+115
View File
@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import { getTransactions } from "@/lib/queries";
interface Condition {
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount";
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
value: string;
}
interface Actions {
set_category?: string;
add_tag_ids?: number[];
set_merchant?: string;
}
interface TxFields {
effective_category: string;
effective_merchant: string;
description: string;
bank_name: string;
amount: number;
}
function evaluateCondition(cond: Condition, tx: TxFields): boolean {
if (cond.field === "amount") {
const numVal = Number(tx.amount);
const numCond = Number(cond.value);
switch (cond.operator) {
case "equals": return numVal === numCond;
case "not_equals": return numVal !== numCond;
case "gt": return numVal > numCond;
case "lt": return numVal < numCond;
default: return false;
}
}
let fieldVal: string;
switch (cond.field) {
case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break;
case "description": fieldVal = tx.description || ""; break;
case "category": fieldVal = tx.effective_category || ""; break;
case "bank_name": fieldVal = tx.bank_name || ""; break;
default: return false;
}
const strVal = fieldVal.toLowerCase();
const strCond = cond.value.toLowerCase();
switch (cond.operator) {
case "contains": return strVal.includes(strCond);
case "equals": return strVal === strCond;
case "starts_with": return strVal.startsWith(strCond);
case "not_equals": return strVal !== strCond;
default: return false;
}
}
export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>(
`SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`,
[user.id]
);
if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 });
const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 });
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 tx of transactions) {
const allMatch =
conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx));
if (!allMatch) continue;
matched++;
affectedIds.add(tx.id);
if (actions.set_category || actions.set_merchant) {
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 = COALESCE($2, transaction_overrides.category_override),
merchant_normalized = COALESCE($3, transaction_overrides.merchant_normalized),
updated_at = NOW()`,
[tx.id, actions.set_category || null, actions.set_merchant || null]
);
}
if (actions.add_tag_ids?.length) {
for (const tagId of actions.add_tag_ids) {
await queryRaw(
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[tx.id, tagId]
);
}
}
}
}
return NextResponse.json({ matched, transactions_affected: affectedIds.size });
}