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:
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user