diff --git a/src/app/api/rules/apply/route.ts b/src/app/api/rules/apply/route.ts index be37b42..89ba147 100644 --- a/src/app/api/rules/apply/route.ts +++ b/src/app/api/rules/apply/route.ts @@ -4,7 +4,7 @@ import { queryRaw } from "@/lib/db"; import { getTransactions } from "@/lib/queries"; interface Condition { - field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount" | "transaction_type"; + field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount" | "transaction_type" | "tag"; operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals"; value: string; } @@ -37,6 +37,7 @@ interface TxFields { bank_name: string; amount: number; transaction_type: string; + tags: { id: number }[]; } function evaluateCondition(cond: Condition, tx: TxFields): boolean { @@ -52,6 +53,12 @@ function evaluateCondition(cond: Condition, tx: TxFields): boolean { } } + if (cond.field === "tag") { + const tagId = Number(cond.value); + const hasTag = tx.tags.some((t) => t.id === tagId); + return cond.operator === "not_equals" ? !hasTag : hasTag; + } + let fieldVal: string; switch (cond.field) { case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break; @@ -97,16 +104,19 @@ export async function POST(req: NextRequest) { const user = await getCurrentUser(req); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + const body = await req.json().catch(() => ({})) as { splitFrom?: string | null; ruleId?: number | null }; + const splitFrom = body.splitFrom || null; + const ruleId = body.ruleId || null; + 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] + ruleId + ? `SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND id = $2` + : `SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`, + ruleId ? [user.id, ruleId] : [user.id] ); 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 --- diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 021f703..4e1a498 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -32,7 +32,7 @@ export default function RootLayout({
-
{children}
+
{children}
diff --git a/src/app/rules/page.tsx b/src/app/rules/page.tsx index 30d7bbb..8ea68c1 100644 --- a/src/app/rules/page.tsx +++ b/src/app/rules/page.tsx @@ -11,6 +11,7 @@ const FIELDS = [ { value: "bank_name", label: "Bank" }, { value: "amount", label: "Amount" }, { value: "transaction_type", label: "Transaction Type" }, + { value: "tag", label: "Tag" }, ] as const; const TEXT_OPS = [ @@ -35,8 +36,12 @@ type Condition = { field: string; operator: string; value: string }; type SplitEntry = { participant_id: number; share_percent: number }; type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string; apply_split?: SplitEntry[] }; -function humanCondition(c: Condition): string { +function humanCondition(c: Condition, tagNames?: Map): string { const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field; + if (c.field === "tag") { + const tagName = tagNames?.get(Number(c.value)) || `tag#${c.value}`; + return `Tag ${c.operator === "not_equals" ? "is not" : "is"} "${tagName}"`; + } const ops = [...TEXT_OPS, ...AMOUNT_OPS, ...ENUM_OPS]; const opText = ops.find((o) => o.value === c.operator)?.label || c.operator; return `${fieldLabel} ${opText} "${c.value}"`; @@ -73,7 +78,7 @@ export default function RulesPage() { const tagNames = new Map(tags.map((t) => [t.id, t.name])); const participantNames = new Map(participants.map((p) => [p.id, p.name])); - const [applyFrom, setApplyFrom] = useState("2026-01-08"); + const [applyFrom, setApplyFrom] = useState("2026-01-09"); const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null); @@ -145,8 +150,8 @@ export default function RulesPage() { closeForm(); } - async function handleApply() { - const result = await applyRules.mutateAsync(applyFrom || undefined); + async function handleApply(ruleId?: number) { + const result = await applyRules.mutateAsync({ splitFrom: applyFrom || undefined, ruleId }); setApplyResult(result); } @@ -167,7 +172,7 @@ export default function RulesPage() { title="Split rules only apply to transactions on or after this date. Category/merchant/tag rules apply to all transactions." />