From d455738732e02f4e040495e7cc1e77ea85c77570 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sun, 8 Mar 2026 16:57:33 +1100 Subject: [PATCH] =?UTF-8?q?feat(finance):=20Phase=206=20=E2=80=94=20Budget?= =?UTF-8?q?=20&=20Analytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add monthly budgets per category with spend-vs-budget dashboard and 6-month trend table. Includes upsert budget API, monthly analytics endpoint, inline budget editing, and route auth fixes. --- prisma/migrations/0006_budgets/migration.sql | 11 + prisma/schema.prisma | 12 + src/app/api/analytics/monthly/route.ts | 105 +++++ src/app/api/budgets/[id]/route.ts | 18 + src/app/api/budgets/route.ts | 46 +++ src/app/api/statements/route.ts | 9 +- src/app/api/transactions/route.ts | 8 +- src/app/budget/page.tsx | 380 ++++++++++++++++++- src/lib/hooks.ts | 69 ++++ 9 files changed, 650 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/0006_budgets/migration.sql create mode 100644 src/app/api/analytics/monthly/route.ts create mode 100644 src/app/api/budgets/[id]/route.ts create mode 100644 src/app/api/budgets/route.ts diff --git a/prisma/migrations/0006_budgets/migration.sql b/prisma/migrations/0006_budgets/migration.sql new file mode 100644 index 0000000..9abbd10 --- /dev/null +++ b/prisma/migrations/0006_budgets/migration.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS budgets ( + id SERIAL PRIMARY KEY, + owner_id INTEGER NOT NULL REFERENCES participants(id), + category TEXT NOT NULL, + month DATE NOT NULL, + amount_limit NUMERIC(10,2) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(owner_id, category, month) +); +CREATE INDEX IF NOT EXISTS idx_budgets_owner_month ON budgets(owner_id, month); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc06f66..da68a9b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -65,3 +65,15 @@ model transaction_tags { @@id([transaction_id, tag_id]) } + +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/analytics/monthly/route.ts b/src/app/api/analytics/monthly/route.ts new file mode 100644 index 0000000..70e4052 --- /dev/null +++ b/src/app/api/analytics/monthly/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { queryRaw } from "@/lib/db"; + +export async function GET(req: NextRequest) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + + const { searchParams } = new URL(req.url); + const monthCount = Math.min(Math.max(Number(searchParams.get("months") || "6"), 1), 24); + + const now = new Date(); + const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const startDate = new Date(now.getFullYear(), now.getMonth() - monthCount + 1, 1); + + const startStr = startDate.toISOString().slice(0, 10); + const endStr = endDate.toISOString().slice(0, 10); + + const spendRows = await queryRaw<{ + month: string; + category: string; + total_spent: number; + transaction_count: number; + }>( + `SELECT + TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month, + COALESCE(o.category_override, t.category) as category, + SUM(t.amount)::numeric(12,2) as total_spent, + COUNT(*)::int as transaction_count + FROM transactions t + LEFT JOIN transaction_overrides o ON o.transaction_id = t.id + JOIN statements s ON s.id = t.statement_id + WHERE s.owner_id = $1 + AND t.transaction_type = 'debit' + AND t.transaction_date >= $2 + AND t.transaction_date < $3 + GROUP BY 1, 2 + ORDER BY 1 DESC, total_spent DESC`, + [user.id, startStr, endStr] + ); + + const budgetRows = await queryRaw<{ month: string; category: string; amount_limit: number }>( + `SELECT TO_CHAR(month, 'YYYY-MM') as month, category, amount_limit::numeric + FROM budgets + WHERE owner_id = $1 AND month >= $2::date AND month < $3::date`, + [user.id, startStr, endStr] + ); + + // Build month list (most recent first) + const months: string[] = []; + for (let i = monthCount - 1; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`); + } + months.reverse(); + + const spendMap = new Map(); + const countMap = new Map(); + const budgetMap = new Map(); + + for (const r of spendRows) { + spendMap.set(`${r.category}:${r.month}`, Number(r.total_spent)); + countMap.set(`${r.category}:${r.month}`, r.transaction_count); + } + for (const r of budgetRows) { + budgetMap.set(`${r.category}:${r.month}`, Number(r.amount_limit)); + } + + const allCategories = new Set(); + for (const r of spendRows) allCategories.add(r.category); + for (const r of budgetRows) allCategories.add(r.category); + + const rows = Array.from(allCategories) + .sort() + .map((cat) => { + const spent: Record = {}; + const budget: Record = {}; + const txCount: Record = {}; + for (const m of months) { + const s = spendMap.get(`${cat}:${m}`); + const b = budgetMap.get(`${cat}:${m}`); + const c = countMap.get(`${cat}:${m}`); + if (s !== undefined) spent[m] = s; + if (b !== undefined) budget[m] = b; + if (c !== undefined) txCount[m] = c; + } + return { category: cat, spent, budget, txCount }; + }); + + const totals: Record = {}; + for (const m of months) { + let s = 0; + let b = 0; + for (const row of rows) { + s += row.spent[m] || 0; + b += row.budget[m] || 0; + } + totals[m] = { + spent: Math.round(s * 100) / 100, + budget: Math.round(b * 100) / 100, + }; + } + + return NextResponse.json({ months, rows, totals }); +} diff --git a/src/app/api/budgets/[id]/route.ts b/src/app/api/budgets/[id]/route.ts new file mode 100644 index 0000000..e5c973f --- /dev/null +++ b/src/app/api/budgets/[id]/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { queryRaw } from "@/lib/db"; + +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 budgets WHERE id = $1 AND owner_id = $2`, + [Number(id), user.id] + ); + if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + await queryRaw(`DELETE FROM budgets WHERE id = $1`, [Number(id)]); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/budgets/route.ts b/src/app/api/budgets/route.ts new file mode 100644 index 0000000..bfea018 --- /dev/null +++ b/src/app/api/budgets/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { queryRaw } from "@/lib/db"; + +export async function GET(req: NextRequest) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + + const { searchParams } = new URL(req.url); + const month = searchParams.get("month"); + + let monthDate: string; + if (month) { + monthDate = month.length === 7 ? `${month}-01` : month; + } else { + const now = new Date(); + monthDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`; + } + + const rows = await queryRaw<{ id: number; category: string; month: string; amount_limit: number }>( + `SELECT id, category, month::text, amount_limit::numeric FROM budgets WHERE owner_id = $1 AND month = $2::date`, + [user.id, monthDate] + ); + 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 { category, month, amount_limit } = await req.json(); + if (!category || !month || amount_limit === undefined) { + return NextResponse.json({ error: "category, month, and amount_limit required" }, { status: 400 }); + } + + const monthDate = month.length === 7 ? `${month}-01` : month; + + const rows = await queryRaw<{ id: number; category: string; month: string; amount_limit: number }>( + `INSERT INTO budgets (owner_id, category, month, amount_limit) + VALUES ($1, $2, $3::date, $4) + ON CONFLICT (owner_id, category, month) DO UPDATE SET amount_limit = $4, updated_at = NOW() + RETURNING id, category, month::text, amount_limit::numeric`, + [user.id, category, monthDate, amount_limit] + ); + return NextResponse.json(rows[0], { 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/budget/page.tsx b/src/app/budget/page.tsx index 06bd120..7c4288a 100644 --- a/src/app/budget/page.tsx +++ b/src/app/budget/page.tsx @@ -1,8 +1,382 @@ +"use client"; + +import { useState } from "react"; +import { + useBudgets, + useUpsertBudget, + useDeleteBudget, + useMonthlyAnalytics, +} from "@/lib/hooks"; +import { CATEGORIES, formatCategory } from "@/lib/categories"; + +function formatMonth(m: string): string { + const [year, month] = m.split("-"); + const date = new Date(Number(year), Number(month) - 1, 1); + return date.toLocaleString("default", { month: "long", year: "numeric" }); +} + +function prevMonth(m: string): string { + const [year, month] = m.split("-").map(Number); + const d = new Date(year, month - 2, 1); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +function nextMonth(m: string): string { + const [year, month] = m.split("-").map(Number); + const d = new Date(year, month, 1); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +function currentMonthStr(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; +} + +function barColor(pct: number): string { + if (pct > 100) return "bg-red-500"; + if (pct > 80) return "bg-yellow-400"; + return "bg-emerald-500"; +} + export default function BudgetPage() { + const [selectedMonth, setSelectedMonth] = useState(currentMonthStr); + const [editingCategory, setEditingCategory] = useState(null); + const [editValue, setEditValue] = useState(""); + + const { data: budgets = [], isLoading: budgetsLoading } = useBudgets(selectedMonth); + const { data: analytics, isLoading: analyticsLoading } = useMonthlyAnalytics(6); + const upsertBudget = useUpsertBudget(); + const deleteBudget = useDeleteBudget(); + + const budgetMap = new Map(budgets.map((b) => [b.category, b])); + + // Categories with spend this month or a budget set + const currentMonthRows = analytics?.rows.filter((r) => r.spent[selectedMonth] !== undefined) || []; + const allCategories = new Set([ + ...currentMonthRows.map((r) => r.category), + ...budgets.map((b) => b.category), + ]); + + const tableRows = Array.from(allCategories) + .sort() + .map((cat) => { + const analyticsRow = analytics?.rows.find((r) => r.category === cat); + const spent = analyticsRow?.spent[selectedMonth] || 0; + const txCount = analyticsRow?.txCount[selectedMonth] || 0; + const budget = budgetMap.get(cat); + const limit = budget ? Number(budget.amount_limit) : null; + const remaining = limit !== null ? limit - spent : null; + const pct = limit !== null && limit > 0 ? (spent / limit) * 100 : null; + return { cat, spent, txCount, budget, limit, remaining, pct }; + }); + + const totalBudgeted = budgets.reduce((s, b) => s + Number(b.amount_limit), 0); + const totalSpent = tableRows.reduce((s, r) => s + r.spent, 0); + const overBudgetCount = tableRows.filter((r) => r.pct !== null && r.pct > 100).length; + + async function handleBudgetSave(cat: string) { + const val = parseFloat(editValue); + if (isNaN(val) || val < 0) return; + await upsertBudget.mutateAsync({ category: cat, month: selectedMonth, amount_limit: val }); + setEditingCategory(null); + setEditValue(""); + } + return ( -
-

Budget

-

Coming soon - monthly budgets and analytics.

+
+ {/* Month selector */} +
+

Budget

+
+ + {formatMonth(selectedMonth)} + +
+
+ + {/* Summary cards */} +
+
+

Total Budgeted

+

${totalBudgeted.toFixed(2)}

+
+
+

Total Spent

+

0 && totalSpent > totalBudgeted ? "text-red-400" : "" + }`} + > + ${totalSpent.toFixed(2)} +

+
+
+

Over Budget

+

0 ? "text-red-400" : "text-emerald-400" + }`} + > + {overBudgetCount} {overBudgetCount === 1 ? "category" : "categories"} +

+
+
+ + {/* Budget table for current month */} +
+ + + + + + + + + + + + + {budgetsLoading || analyticsLoading ? ( + + + + ) : tableRows.length === 0 ? ( + + + + ) : ( + tableRows.map(({ cat, spent, txCount, budget, limit, remaining, pct }) => ( + + + + + + + + + )) + )} + +
CategoryBudgetSpentRemainingProgress# Txns
+ Loading... +
+ No spending data for this month. Set a budget for any category below. +
{formatCategory(cat)} + {editingCategory === cat ? ( +
+ setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleBudgetSave(cat); + if (e.key === "Escape") { + setEditingCategory(null); + setEditValue(""); + } + }} + className="w-24 bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-right text-sm" + placeholder="0.00" + /> + + +
+ ) : ( +
+ + {budget && ( + + )} +
+ )} +
${spent.toFixed(2)} + {remaining !== null ? `$${remaining.toFixed(2)}` : "—"} + + {pct !== null ? ( +
+
+
+
+ + {pct.toFixed(0)}% + +
+ ) : ( + + )} +
{txCount}
+
+ + {/* Add budget for any category not yet shown */} +
+

Set budget for another category:

+
+ {CATEGORIES.filter((c) => !allCategories.has(c)).map((cat) => ( + + ))} +
+ {editingCategory && !allCategories.has(editingCategory) && ( +
+ {formatCategory(editingCategory)} + setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleBudgetSave(editingCategory); + if (e.key === "Escape") { + setEditingCategory(null); + setEditValue(""); + } + }} + className="w-28 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" + placeholder="Budget amount" + /> + +
+ )} +
+ + {/* 6-month trend table */} + {analytics && analytics.months.length > 0 && ( +
+

6-Month Trend

+
+ + + + + {analytics.months.map((m) => ( + + ))} + + + + {analytics.rows.map((row) => ( + + + {analytics.months.map((m) => { + const spent = row.spent[m]; + const budget = row.budget[m]; + const overBudget = + spent !== undefined && budget !== undefined && spent > budget; + return ( + + ); + })} + + ))} + + + {analytics.months.map((m) => { + const t = analytics.totals[m]; + const over = t && t.budget > 0 && t.spent > t.budget; + return ( + + ); + })} + + +
+ Category + + {m} +
+ {formatCategory(row.category)} + + {spent !== undefined ? `$${Number(spent).toFixed(0)}` : "—"} +
Total + ${(t?.spent || 0).toFixed(0)} +
+
+
+ )}
); } diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 7d3f8e8..afd508a 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -352,3 +352,72 @@ export function useCreateParticipant() { }, }); } + +// --- Budgets & Analytics --- + +export interface BudgetRow { + id: number; + category: string; + month: string; + amount_limit: number; +} + +export interface MonthlyAnalyticsRow { + category: string; + spent: Record; + budget: Record; + txCount: Record; +} + +export interface MonthlyAnalytics { + months: string[]; + rows: MonthlyAnalyticsRow[]; + totals: Record; +} + +export function useBudgets(month: string) { + return useQuery({ + queryKey: ["budgets", month], + queryFn: async () => { + const res = await fetch(`/api/budgets?month=${month}`); + return res.json(); + }, + }); +} + +export function useUpsertBudget() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (data: { category: string; month: string; amount_limit: number }) => { + const res = await fetch("/api/budgets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error("Failed to save budget"); + return res.json(); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }), + }); +} + +export function useDeleteBudget() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: number) => { + await fetch(`/api/budgets/${id}`, { method: "DELETE" }); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }), + }); +} + +export function useMonthlyAnalytics(months?: number) { + const m = months || 6; + return useQuery({ + queryKey: ["analytics", "monthly", m], + queryFn: async () => { + const res = await fetch(`/api/analytics/monthly?months=${m}`); + return res.json(); + }, + }); +}