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
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw, prisma } from "@/lib/db";
export async function PATCH(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 body = await req.json();
const existing = await queryRaw<{ id: number }>(
`SELECT id FROM rules WHERE id = $1 AND owner_id = $2`,
[Number(id), user.id]
);
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
const updated = await prisma.rules.update({
where: { id: Number(id) },
data: {
...(body.name !== undefined && { name: body.name }),
...(body.conditions !== undefined && { conditions: body.conditions }),
...(body.actions !== undefined && { actions: body.actions }),
...(body.enabled !== undefined && { enabled: body.enabled }),
...(body.priority !== undefined && { priority: body.priority }),
},
});
return NextResponse.json(updated);
}
export async function DELETE(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 existing = await queryRaw<{ id: number }>(
`SELECT id FROM rules WHERE id = $1 AND owner_id = $2`,
[Number(id), user.id]
);
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
await prisma.rules.delete({ where: { id: Number(id) } });
return NextResponse.json({ ok: true });
}
+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 });
}
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw, prisma } from "@/lib/db";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const rows = await queryRaw<{
id: number;
name: string;
conditions: unknown;
actions: unknown;
enabled: boolean;
priority: number;
created_at: string;
}>(
`SELECT id, name, conditions, actions, enabled, priority, created_at
FROM rules WHERE owner_id = $1 ORDER BY priority DESC, id ASC`,
[user.id]
);
return NextResponse.json(rows);
}
export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { name, conditions, actions, enabled = true, priority = 0 } = await req.json();
if (!name) return NextResponse.json({ error: "name required" }, { status: 400 });
const rule = await prisma.rules.create({
data: {
owner_id: user.id,
name,
conditions: conditions ?? [],
actions: actions ?? {},
enabled,
priority,
},
});
return NextResponse.json(rule, { status: 201 });
}
+6 -3
View File
@@ -1,7 +1,10 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { getStatements } from "@/lib/queries";
export async function GET() {
const statements = await getStatements();
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const statements = await getStatements(user.id);
return NextResponse.json(statements);
}
+6 -2
View File
@@ -1,16 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { getTransactions } from "@/lib/queries";
export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams;
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const result = await getTransactions({
const sp = req.nextUrl.searchParams;
const result = await getTransactions(user.id, {
from: sp.get("from") || undefined,
to: sp.get("to") || undefined,
category: sp.get("category") || undefined,
bank_name: sp.get("bank_name") || undefined,
search: sp.get("search") || undefined,
statement_id: sp.get("statement_id") || undefined,
tag_id: sp.get("tag_id") || undefined,
sort_by: sp.get("sort_by") || undefined,
sort_dir: sp.get("sort_dir") || undefined,
limit: sp.get("limit") ? Number(sp.get("limit")) : undefined,