diff --git a/prisma/migrations/0005_rules/migration.sql b/prisma/migrations/0005_rules/migration.sql new file mode 100644 index 0000000..7039ab9 --- /dev/null +++ b/prisma/migrations/0005_rules/migration.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS rules ( + id SERIAL PRIMARY KEY, + owner_id INTEGER NOT NULL REFERENCES participants(id), + name TEXT NOT NULL, + conditions JSONB NOT NULL DEFAULT '[]', + actions JSONB NOT NULL DEFAULT '{}', + enabled BOOLEAN NOT NULL DEFAULT true, + priority INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_rules_owner ON rules(owner_id); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc06f66..ae35559 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -65,3 +65,27 @@ model transaction_tags { @@id([transaction_id, tag_id]) } + +model rules { + id Int @id @default(autoincrement()) + owner_id Int + name String + conditions Json @default("[]") + actions Json @default("{}") + enabled Boolean @default(true) + priority Int @default(0) + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt +} + +model budgets { + id Int @id @default(autoincrement()) + owner_id Int + category String + month DateTime @db.Date + amount_limit Decimal @db.Decimal(10, 2) + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt + + @@unique([owner_id, category, month]) +} diff --git a/src/app/api/rules/[id]/route.ts b/src/app/api/rules/[id]/route.ts new file mode 100644 index 0000000..533ff0d --- /dev/null +++ b/src/app/api/rules/[id]/route.ts @@ -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 }); +} diff --git a/src/app/api/rules/apply/route.ts b/src/app/api/rules/apply/route.ts new file mode 100644 index 0000000..1c1fd58 --- /dev/null +++ b/src/app/api/rules/apply/route.ts @@ -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(); + + 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 }); +} diff --git a/src/app/api/rules/route.ts b/src/app/api/rules/route.ts new file mode 100644 index 0000000..81d3d9a --- /dev/null +++ b/src/app/api/rules/route.ts @@ -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 }); +} diff --git a/src/app/api/statements/route.ts b/src/app/api/statements/route.ts index 18548e2..57ee1fa 100644 --- a/src/app/api/statements/route.ts +++ b/src/app/api/statements/route.ts @@ -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); } diff --git a/src/app/api/transactions/route.ts b/src/app/api/transactions/route.ts index 6b69a80..4d87ff2 100644 --- a/src/app/api/transactions/route.ts +++ b/src/app/api/transactions/route.ts @@ -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, diff --git a/src/app/rules/page.tsx b/src/app/rules/page.tsx index cd6c5b6..eb9ccc3 100644 --- a/src/app/rules/page.tsx +++ b/src/app/rules/page.tsx @@ -1,8 +1,329 @@ +"use client"; + +import { useState } from "react"; +import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useTags } from "@/lib/hooks"; +import { CATEGORIES, formatCategory } from "@/lib/categories"; + +const FIELDS = [ + { value: "merchant_normalized", label: "Merchant" }, + { value: "description", label: "Description" }, + { value: "category", label: "Category" }, + { value: "bank_name", label: "Bank" }, + { value: "amount", label: "Amount" }, +] as const; + +const TEXT_OPS = [ + { value: "contains", label: "contains" }, + { value: "equals", label: "equals" }, + { value: "starts_with", label: "starts with" }, + { value: "not_equals", label: "not equals" }, +]; +const AMOUNT_OPS = [ + { value: "equals", label: "=" }, + { value: "not_equals", label: "≠" }, + { value: "gt", label: ">" }, + { value: "lt", label: "<" }, +]; + +type Condition = { field: string; operator: string; value: string }; +type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string }; + +function humanCondition(c: Condition): string { + const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field; + const ops = [...TEXT_OPS, ...AMOUNT_OPS]; + const opText = ops.find((o) => o.value === c.operator)?.label || c.operator; + return `${fieldLabel} ${opText} "${c.value}"`; +} + +function humanAction(a: Actions, tagNames: Map): string { + const parts: string[] = []; + if (a.set_category) parts.push(`set category: ${formatCategory(a.set_category)}`); + if (a.set_merchant) parts.push(`set merchant: ${a.set_merchant}`); + if (a.add_tag_ids?.length) { + const names = a.add_tag_ids.map((id) => tagNames.get(id) || `tag#${id}`).join(", "); + parts.push(`add tags: ${names}`); + } + return parts.length ? "→ " + parts.join(", ") : "(no actions)"; +} + export default function RulesPage() { + const { data: rules = [], isLoading } = useRules(); + const { data: tags = [] } = useTags(); + const createRule = useCreateRule(); + const updateRule = useUpdateRule(); + const deleteRule = useDeleteRule(); + const applyRules = useApplyRules(); + + const tagNames = new Map(tags.map((t) => [t.id, t.name])); + + const [showForm, setShowForm] = useState(false); + const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null); + const [name, setName] = useState(""); + const [conditions, setConditions] = useState([]); + const [actions, setActions] = useState({}); + const [priority, setPriority] = useState(0); + + function addCondition() { + setConditions([...conditions, { field: "merchant_normalized", operator: "contains", value: "" }]); + } + + function updateCondition(i: number, patch: Partial) { + setConditions(conditions.map((c, idx) => (idx === i ? { ...c, ...patch } : c))); + } + + function removeCondition(i: number) { + setConditions(conditions.filter((_, idx) => idx !== i)); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + await createRule.mutateAsync({ name, conditions, actions, enabled: true, priority }); + setName(""); + setConditions([]); + setActions({}); + setPriority(0); + setShowForm(false); + } + + async function handleApply() { + const result = await applyRules.mutateAsync(); + setApplyResult(result); + } + return ( -
-

Rules

-

Coming soon - auto-classify transactions with rules.

+
+
+

Rules

+
+ + +
+
+ + {applyResult && ( +
+ Applied: {applyResult.matched} condition matches across{" "} + {applyResult.transactions_affected} transactions. + +
+ )} + + {showForm && ( +
+

New Rule

+ +
+ + setName(e.target.value)} + required + placeholder="e.g. Tag Woolworths as groceries" + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm" + /> +
+ +
+
+ + +
+ {conditions.map((cond, i) => { + const isAmount = cond.field === "amount"; + const ops = isAmount ? AMOUNT_OPS : TEXT_OPS; + return ( +
+ + + updateCondition(i, { value: e.target.value })} + placeholder="value" + className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" + /> + +
+ ); + })} + {conditions.length === 0 && ( +

No conditions — rule will match ALL transactions.

+ )} +
+ +
+
+ + +
+
+ + setActions({ ...actions, set_merchant: e.target.value || undefined })} + placeholder="Normalized name" + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm" + /> +
+
+ +
+ +
+ {tags.map((tag) => { + const selected = (actions.add_tag_ids || []).includes(tag.id); + return ( + + ); + })} + {tags.length === 0 &&

No tags created yet.

} +
+
+ +
+
+ + setPriority(Number(e.target.value))} + className="w-24 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm" + /> +
+ +
+
+ )} + + {isLoading ? ( +

Loading rules...

+ ) : rules.length === 0 ? ( +

No rules yet. Create one to auto-classify transactions.

+ ) : ( +
+ {rules.map((rule) => { + const conds = Array.isArray(rule.conditions) ? rule.conditions : []; + const acts = + rule.actions && typeof rule.actions === "object" ? (rule.actions as Actions) : {}; + return ( +
+
+
+
+ {rule.name} + priority: {rule.priority} +
+

+ {conds.length > 0 ? conds.map(humanCondition).join(" AND ") : "(matches all)"} +

+

{humanAction(acts, tagNames)}

+
+
+ + +
+
+
+ ); + })} +
+ )}
); } diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 7d3f8e8..4943903 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -352,3 +352,82 @@ export function useCreateParticipant() { }, }); } + +// --- Rules --- + +export interface RuleRow { + id: number; + name: string; + conditions: { field: string; operator: string; value: string }[]; + actions: { set_category?: string; add_tag_ids?: number[]; set_merchant?: string }; + enabled: boolean; + priority: number; + created_at: string; +} + +export function useRules() { + return useQuery({ + queryKey: ["rules"], + queryFn: async () => { + const res = await fetch("/api/rules"); + return res.json(); + }, + }); +} + +export function useCreateRule() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (data: Omit) => { + const res = await fetch("/api/rules", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error("Failed to create rule"); + return res.json(); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }), + }); +} + +export function useUpdateRule() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, ...data }: Partial & { id: number }) => { + const res = await fetch(`/api/rules/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error("Failed to update rule"); + return res.json(); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }), + }); +} + +export function useDeleteRule() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: number) => { + await fetch(`/api/rules/${id}`, { method: "DELETE" }); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }), + }); +} + +export function useApplyRules() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => { + const res = await fetch("/api/rules/apply", { method: "POST" }); + if (!res.ok) throw new Error("Failed to apply rules"); + return res.json() as Promise<{ matched: number; transactions_affected: number }>; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["transactions"] }); + qc.invalidateQueries({ queryKey: ["rules"] }); + }, + }); +}