2 Commits

Author SHA1 Message Date
siddharthd d455738732 feat(finance): Phase 6 — Budget & Analytics
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.
2026-03-08 16:57:33 +11:00
siddharthd 93450f7caa feat(finance): Phase 4 — Tags
- tags table: name, color; transaction_tags junction table
- GET/POST /api/tags; DELETE /api/tags/:id
- POST/DELETE /api/transactions/:id/tags for per-transaction tagging
- Bulk tag/untag via /api/transactions/bulk (action: tag/untag)
- Tags returned inline with transaction list via LATERAL join
- Tag filter on Transactions page
- Bulk "Tag as..." in bulk action bar
- Tag pills + "+" picker on each transaction row
- /tags page: create with color picker, list with counts, delete
2026-03-08 16:28:03 +11:00
18 changed files with 1420 additions and 29 deletions
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL DEFAULT '#6366f1',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS transaction_tags (
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (transaction_id, tag_id)
);
@@ -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);
+62
View File
@@ -15,3 +15,65 @@ model transaction_overrides {
notes String?
updated_at DateTime @default(now()) @updatedAt
}
model participants {
id Int @id @default(autoincrement())
name String @unique
email String? @unique
created_at DateTime @default(now())
splits transaction_splits[]
account_owner_mappings account_owner_mappings[]
}
model account_owner_mappings {
id Int @id @default(autoincrement())
bank_name String
account_number String
owner_id Int
created_at DateTime @default(now())
owner participants @relation(fields: [owner_id], references: [id])
@@unique([bank_name, account_number])
}
model transaction_splits {
id Int @id @default(autoincrement())
transaction_id Int
participant_id Int
share_percent Decimal @db.Decimal(5, 2)
settled Boolean @default(false)
settled_at DateTime?
created_at DateTime @default(now())
participant participants @relation(fields: [participant_id], references: [id])
@@unique([transaction_id, participant_id])
}
model tags {
id Int @id @default(autoincrement())
name String @unique
color String @default("#6366f1")
created_at DateTime @default(now())
transaction_tags transaction_tags[]
}
model transaction_tags {
transaction_id Int
tag_id Int
created_at DateTime @default(now())
tag tags @relation(fields: [tag_id], references: [id], onDelete: Cascade)
@@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])
}
+105
View File
@@ -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<string, number>();
const countMap = new Map<string, number>();
const budgetMap = new Map<string, number>();
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<string>();
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<string, number> = {};
const budget: Record<string, number> = {};
const txCount: Record<string, number> = {};
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<string, { spent: number; budget: number }> = {};
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 });
}
+18
View File
@@ -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 });
}
+46
View File
@@ -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 });
}
+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);
}
+8
View File
@@ -0,0 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRaw } from "@/lib/db";
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await queryRaw(`DELETE FROM tags WHERE id = $1`, [Number(id)]);
return NextResponse.json({ ok: true });
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { getTags } from "@/lib/queries";
import { queryRaw } from "@/lib/db";
export async function GET() {
const tags = await getTags();
return NextResponse.json(tags);
}
export async function POST(req: NextRequest) {
const { name, color } = await req.json();
if (!name?.trim()) {
return NextResponse.json({ error: "name required" }, { status: 400 });
}
const rows = await queryRaw<{ id: number; name: string; color: string }>(
`INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id, name, color`,
[name.trim(), color || "#6366f1"]
);
return NextResponse.json(rows[0], { status: 201 });
}
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRaw } from "@/lib/db";
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { tag_id } = await req.json();
if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 });
await queryRaw(
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[Number(id), Number(tag_id)]
);
return NextResponse.json({ ok: true });
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { tag_id } = await req.json();
if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 });
await queryRaw(
`DELETE FROM transaction_tags WHERE transaction_id = $1 AND tag_id = $2`,
[Number(id), Number(tag_id)]
);
return NextResponse.json({ ok: true });
}
+41 -2
View File
@@ -1,13 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { prisma, queryRaw } from "@/lib/db";
export async function POST(req: NextRequest) {
const body = await req.json();
const { action, ids, category, merchant_normalized } = body as {
const { action, ids, category, merchant_normalized, splits, tag_id } = body as {
action: string;
ids: number[];
category?: string;
merchant_normalized?: string;
splits?: { participant_id: number; share_percent: number }[];
tag_id?: number;
};
if (!ids || !Array.isArray(ids) || ids.length === 0) {
@@ -38,5 +40,42 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ updated: ids.length });
}
if (action === "split" && Array.isArray(splits) && splits.length > 0) {
const total = splits.reduce((s, x) => s + x.share_percent, 0);
if (Math.abs(total - 100) > 0.01) {
return NextResponse.json({ error: "Shares must sum to 100%" }, { status: 400 });
}
await prisma.$transaction(
ids.flatMap((id) => [
prisma.transaction_splits.deleteMany({ where: { transaction_id: id } }),
prisma.transaction_splits.createMany({
data: splits.map((s) => ({
transaction_id: id,
participant_id: s.participant_id,
share_percent: s.share_percent,
})),
}),
])
);
return NextResponse.json({ updated: ids.length });
}
if ((action === "tag" || action === "untag") && tag_id) {
if (action === "tag") {
for (const id of ids) {
await queryRaw(
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[id, tag_id]
);
}
} else {
await queryRaw(
`DELETE FROM transaction_tags WHERE transaction_id = ANY($1::int[]) AND tag_id = $2`,
[ids, tag_id]
);
}
return NextResponse.json({ updated: ids.length });
}
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}
+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,
+377 -3
View File
@@ -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<string | null>(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<string>([
...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 (
<div>
<h2 className="text-xl font-semibold mb-4">Budget</h2>
<p className="text-zinc-500">Coming soon - monthly budgets and analytics.</p>
<div className="space-y-6">
{/* Month selector */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Budget</h2>
<div className="flex items-center gap-3">
<button
onClick={() => setSelectedMonth(prevMonth(selectedMonth))}
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
>
</button>
<span className="text-sm font-medium min-w-36 text-center">{formatMonth(selectedMonth)}</span>
<button
onClick={() => setSelectedMonth(nextMonth(selectedMonth))}
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
>
</button>
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Total Budgeted</p>
<p className="text-2xl font-semibold">${totalBudgeted.toFixed(2)}</p>
</div>
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Total Spent</p>
<p
className={`text-2xl font-semibold ${
totalBudgeted > 0 && totalSpent > totalBudgeted ? "text-red-400" : ""
}`}
>
${totalSpent.toFixed(2)}
</p>
</div>
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Over Budget</p>
<p
className={`text-2xl font-semibold ${
overBudgetCount > 0 ? "text-red-400" : "text-emerald-400"
}`}
>
{overBudgetCount} {overBudgetCount === 1 ? "category" : "categories"}
</p>
</div>
</div>
{/* Budget table for current month */}
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-4 py-3 text-xs text-zinc-500 font-medium">Category</th>
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Budget</th>
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Spent</th>
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Remaining</th>
<th className="px-4 py-3 text-xs text-zinc-500 font-medium w-36">Progress</th>
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium"># Txns</th>
</tr>
</thead>
<tbody>
{budgetsLoading || analyticsLoading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
Loading...
</td>
</tr>
) : tableRows.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
No spending data for this month. Set a budget for any category below.
</td>
</tr>
) : (
tableRows.map(({ cat, spent, txCount, budget, limit, remaining, pct }) => (
<tr key={cat} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td className="px-4 py-3 font-medium">{formatCategory(cat)}</td>
<td className="px-4 py-3 text-right">
{editingCategory === cat ? (
<div className="flex items-center justify-end gap-1">
<input
autoFocus
type="number"
min="0"
step="0.01"
value={editValue}
onChange={(e) => 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"
/>
<button
onClick={() => handleBudgetSave(cat)}
className="text-xs text-emerald-400 hover:text-emerald-300 px-1"
>
</button>
<button
onClick={() => {
setEditingCategory(null);
setEditValue("");
}}
className="text-xs text-zinc-500 hover:text-zinc-300 px-1"
>
</button>
</div>
) : (
<div className="flex items-center justify-end gap-1">
<button
onClick={() => {
setEditingCategory(cat);
setEditValue(limit?.toString() || "");
}}
className="text-zinc-300 hover:text-white underline-offset-2 hover:underline"
>
{limit !== null ? (
`$${limit.toFixed(2)}`
) : (
<span className="text-zinc-600 italic">Set...</span>
)}
</button>
{budget && (
<button
onClick={() => deleteBudget.mutate(budget.id)}
className="text-zinc-600 hover:text-red-400 text-xs ml-1"
>
×
</button>
)}
</div>
)}
</td>
<td className="px-4 py-3 text-right">${spent.toFixed(2)}</td>
<td
className={`px-4 py-3 text-right ${
remaining !== null
? remaining < 0
? "text-red-400"
: "text-emerald-400"
: "text-zinc-600"
}`}
>
{remaining !== null ? `$${remaining.toFixed(2)}` : "—"}
</td>
<td className="px-4 py-3">
{pct !== null ? (
<div className="flex items-center gap-2">
<div className="flex-1 bg-zinc-800 rounded-full h-2 overflow-hidden">
<div
className={`h-full rounded-full ${barColor(pct)}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
<span className="text-xs text-zinc-400 w-10 text-right">
{pct.toFixed(0)}%
</span>
</div>
) : (
<span className="text-zinc-600 text-xs"></span>
)}
</td>
<td className="px-4 py-3 text-right text-zinc-400">{txCount}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Add budget for any category not yet shown */}
<div>
<p className="text-xs text-zinc-500 mb-2">Set budget for another category:</p>
<div className="flex flex-wrap gap-2">
{CATEGORIES.filter((c) => !allCategories.has(c)).map((cat) => (
<button
key={cat}
onClick={() => {
setEditingCategory(cat);
setEditValue("");
}}
className="px-3 py-1.5 text-xs rounded-lg border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
>
{formatCategory(cat)}
</button>
))}
</div>
{editingCategory && !allCategories.has(editingCategory) && (
<div className="flex items-center gap-2 mt-3">
<span className="text-sm font-medium">{formatCategory(editingCategory)}</span>
<input
autoFocus
type="number"
min="0"
step="0.01"
value={editValue}
onChange={(e) => 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"
/>
<button
onClick={() => handleBudgetSave(editingCategory)}
className="px-3 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg"
>
Save
</button>
</div>
)}
</div>
{/* 6-month trend table */}
{analytics && analytics.months.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-zinc-400 mb-3">6-Month Trend</h3>
<div className="overflow-x-auto rounded-xl border border-zinc-700">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
<th className="text-left px-3 py-2 text-zinc-500 font-medium sticky left-0 bg-zinc-900 min-w-32">
Category
</th>
{analytics.months.map((m) => (
<th
key={m}
className="text-right px-3 py-2 text-zinc-500 font-medium whitespace-nowrap"
>
{m}
</th>
))}
</tr>
</thead>
<tbody>
{analytics.rows.map((row) => (
<tr key={row.category} className="border-b border-zinc-800/40 hover:bg-zinc-900/30">
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950">
{formatCategory(row.category)}
</td>
{analytics.months.map((m) => {
const spent = row.spent[m];
const budget = row.budget[m];
const overBudget =
spent !== undefined && budget !== undefined && spent > budget;
return (
<td
key={m}
className={`px-3 py-2 text-right tabular-nums ${
spent === undefined
? "text-zinc-700"
: overBudget
? "text-red-300 bg-red-950/40"
: "text-zinc-300"
}`}
>
{spent !== undefined ? `$${Number(spent).toFixed(0)}` : "—"}
</td>
);
})}
</tr>
))}
<tr className="border-t-2 border-zinc-700 font-semibold bg-zinc-900/50">
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Total</td>
{analytics.months.map((m) => {
const t = analytics.totals[m];
const over = t && t.budget > 0 && t.spent > t.budget;
return (
<td
key={m}
className={`px-3 py-2 text-right tabular-nums ${over ? "text-red-300" : ""}`}
>
${(t?.spent || 0).toFixed(0)}
</td>
);
})}
</tr>
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
+103 -1
View File
@@ -1,8 +1,110 @@
"use client";
import { useState } from "react";
import { useTags, useCreateTag, useDeleteTag } from "@/lib/hooks";
const PRESET_COLORS = [
"#6366f1", // indigo
"#8b5cf6", // violet
"#ec4899", // pink
"#ef4444", // red
"#f97316", // orange
"#eab308", // yellow
"#22c55e", // green
"#14b8a6", // teal
"#3b82f6", // blue
"#6b7280", // gray
];
export default function TagsPage() {
const { data: tags, isLoading } = useTags();
const createTag = useCreateTag();
const deleteTag = useDeleteTag();
const [name, setName] = useState("");
const [color, setColor] = useState(PRESET_COLORS[0]);
const [error, setError] = useState("");
const handleCreate = async () => {
if (!name.trim()) return;
setError("");
try {
await createTag.mutateAsync({ name: name.trim(), color });
setName("");
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to create tag");
}
};
return (
<div>
<h2 className="text-xl font-semibold mb-4">Tags</h2>
<p className="text-zinc-500">Coming soon - tag transactions for trips, projects, and more.</p>
{/* Create form */}
<div className="mb-6 p-4 bg-zinc-900/50 border border-zinc-800 rounded-lg">
<h3 className="text-sm font-medium mb-3">New Tag</h3>
<div className="flex items-center gap-3 flex-wrap">
<input
type="text"
placeholder="Tag name..."
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
className="bg-zinc-800 border border-zinc-700 rounded px-3 py-1.5 text-sm w-48"
/>
<div className="flex items-center gap-1.5">
{PRESET_COLORS.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`w-5 h-5 rounded-full transition-transform ${color === c ? "scale-125 ring-2 ring-white ring-offset-1 ring-offset-zinc-900" : "hover:scale-110"}`}
style={{ backgroundColor: c }}
/>
))}
</div>
<button
disabled={!name.trim() || createTag.isPending}
onClick={handleCreate}
className="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 rounded text-sm"
>
Add Tag
</button>
</div>
{error && <p className="text-red-400 text-xs mt-2">{error}</p>}
</div>
{/* Tags list */}
{isLoading ? (
<p className="text-zinc-500">Loading...</p>
) : !tags?.length ? (
<p className="text-zinc-500">No tags yet. Create one above.</p>
) : (
<div className="space-y-1">
{tags.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between px-4 py-2.5 bg-zinc-900/50 border border-zinc-800 rounded-lg"
>
<div className="flex items-center gap-3">
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: tag.color }}
/>
<span className="text-sm font-medium">{tag.name}</span>
<span className="text-xs text-zinc-500">
{tag.transaction_count} transaction{tag.transaction_count !== 1 ? "s" : ""}
</span>
</div>
<button
onClick={() => deleteTag.mutate(tag.id)}
className="text-xs text-zinc-600 hover:text-red-400 transition-colors px-2 py-0.5 rounded hover:bg-zinc-800"
>
Delete
</button>
</div>
))}
</div>
)}
</div>
);
}
+88 -3
View File
@@ -1,8 +1,10 @@
"use client";
import { useState, useCallback } from "react";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction } from "@/lib/hooks";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import { SplitModal } from "@/components/split-modal";
import { TagPicker } from "@/components/tag-picker";
function formatDate(d: string) {
return new Date(d).toLocaleDateString("en-AU", {
@@ -102,6 +104,7 @@ export default function TransactionsPage() {
category: "",
bank_name: "",
search: "",
tag_id: "",
sort_by: "transaction_date",
sort_dir: "desc",
limit: 50,
@@ -109,9 +112,12 @@ export default function TransactionsPage() {
});
const [selected, setSelected] = useState<Set<number>>(new Set());
const [bulkCategory, setBulkCategory] = useState("");
const [bulkTagId, setBulkTagId] = useState("");
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string } | null>(null);
const { data, isLoading } = useTransactions(filters);
const { data: banks } = useBanks();
const { data: tags } = useTags();
const updateTxn = useUpdateTransaction();
const bulkAction = useBulkAction();
@@ -196,6 +202,16 @@ export default function TransactionsPage() {
<option key={b} value={b}>{b}</option>
))}
</select>
<select
value={filters.tag_id}
onChange={(e) => setFilters((f) => ({ ...f, tag_id: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
>
<option value="">All Tags</option>
{tags?.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
{/* Bulk action bar */}
@@ -224,6 +240,39 @@ export default function TransactionsPage() {
>
Apply
</button>
<button
onClick={() =>
setSplitModal({
transactionIds: Array.from(selected),
description: `${selected.size} selected transaction${selected.size !== 1 ? "s" : ""}`,
})
}
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 rounded text-sm"
>
Split...
</button>
<select
value={bulkTagId}
onChange={(e) => setBulkTagId(e.target.value)}
className="bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm"
>
<option value="">Tag as...</option>
{tags?.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<button
disabled={!bulkTagId || bulkAction.isPending}
onClick={() => {
bulkAction.mutate(
{ action: "tag", ids: Array.from(selected), tag_id: Number(bulkTagId) },
{ onSuccess: () => { setSelected(new Set()); setBulkTagId(""); } }
);
}}
className="px-3 py-1 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 rounded text-sm"
>
Tag
</button>
<button
onClick={() => setSelected(new Set())}
className="px-3 py-1 text-zinc-400 hover:text-white text-sm"
@@ -263,13 +312,15 @@ export default function TransactionsPage() {
<th className="p-2 text-left">Type</th>
<th className="p-2 text-left">Category</th>
<th className="p-2 text-left">Bank</th>
<th className="p-2 text-left">Tags</th>
<th className="p-2"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={8} className="p-8 text-center text-zinc-500">Loading...</td></tr>
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">Loading...</td></tr>
) : !data?.data.length ? (
<tr><td colSpan={8} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
) : (
data.data.map((t) => (
<tr
@@ -319,6 +370,29 @@ export default function TransactionsPage() {
</div>
</td>
<td className="p-2 text-zinc-400 whitespace-nowrap">{t.bank_name}</td>
<td className="p-2">
<div className="flex items-center gap-1 flex-wrap">
{t.tags?.map((tag) => (
<span
key={tag.id}
className="px-1.5 py-0.5 rounded text-xs font-medium text-white"
style={{ backgroundColor: tag.color + "99" }}
>
{tag.name}
</span>
))}
<TagPicker transactionId={t.id} currentTags={t.tags ?? []} />
</div>
</td>
<td className="p-2">
<button
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, transactionIds: undefined })}
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
title="Split this transaction"
>
Split
</button>
</td>
</tr>
))
)}
@@ -326,6 +400,17 @@ export default function TransactionsPage() {
</table>
</div>
{/* Split modal */}
{splitModal && (
<SplitModal
transactionId={splitModal.transactionId}
transactionIds={splitModal.transactionIds}
amount={splitModal.amount}
description={splitModal.description}
onClose={() => { setSplitModal(null); if (splitModal.transactionIds) setSelected(new Set()); }}
/>
)}
{/* Pagination */}
{data && data.total > filters.limit && (
<div className="flex items-center justify-between mt-4">
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useTags, useAddTransactionTag, useRemoveTransactionTag } from "@/lib/hooks";
import type { TagRow } from "@/lib/queries";
interface Props {
transactionId: number;
currentTags: TagRow[];
}
export function TagPicker({ transactionId, currentTags }: Props) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { data: allTags } = useTags();
const addTag = useAddTransactionTag();
const removeTag = useRemoveTransactionTag();
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const currentIds = new Set(currentTags.map((t) => t.id));
const toggle = (tag: TagRow) => {
if (currentIds.has(tag.id)) {
removeTag.mutate({ transactionId, tagId: tag.id });
} else {
addTag.mutate({ transactionId, tagId: tag.id });
}
};
return (
<div ref={ref} className="relative inline-block">
<button
onClick={() => setOpen((o) => !o)}
className="text-zinc-600 hover:text-zinc-300 transition-colors text-xs px-1"
title="Add tag"
>
+
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl w-44 py-1">
{!allTags?.length ? (
<p className="px-3 py-2 text-xs text-zinc-500">No tags yet create on Tags page</p>
) : (
allTags.map((tag) => {
const active = currentIds.has(tag.id);
return (
<button
key={tag.id}
onClick={() => toggle(tag)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-zinc-800 transition-colors"
>
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: tag.color }}
/>
<span className={active ? "text-white" : "text-zinc-400"}>{tag.name}</span>
{active && <span className="ml-auto text-zinc-500"></span>}
</button>
);
})
)}
</div>
)}
</div>
);
}
+294 -1
View File
@@ -1,7 +1,8 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { TransactionRow, StatementRow } from "./queries";
import type { TransactionRow, StatementRow, TagRow } from "./queries";
import type { CurrentUser } from "./auth";
interface TransactionsResponse {
data: TransactionRow[];
@@ -17,6 +18,7 @@ interface TransactionFilters {
bank_name?: string;
search?: string;
statement_id?: string;
tag_id?: string;
sort_by?: string;
sort_dir?: string;
limit?: number;
@@ -115,16 +117,307 @@ export function useBulkAction() {
ids: number[];
category?: string;
merchant_normalized?: string;
splits?: { participant_id: number; share_percent: number }[];
tag_id?: number;
}) => {
const res = await fetch("/api/transactions/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Bulk action failed");
}
return res.json();
},
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: ["transactions"] });
if (variables.action === "split") {
qc.invalidateQueries({ queryKey: ["splits"] });
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
qc.invalidateQueries({ queryKey: ["participant-balances"] });
}
if (variables.action === "tag" || variables.action === "untag") {
qc.invalidateQueries({ queryKey: ["tags"] });
}
},
});
}
export function useParticipants() {
return useQuery<{ id: number; name: string; created_at: string }[]>({
queryKey: ["participants"],
queryFn: async () => {
const res = await fetch("/api/participants");
return res.json();
},
});
}
export function useParticipantBalances() {
return useQuery<{ id: number; name: string; total_owed: number; unsettled_count: number }[]>({
queryKey: ["participant-balances"],
queryFn: async () => {
const res = await fetch("/api/participants/balances");
return res.json();
},
});
}
export function useSharedTransactions() {
return useQuery({
queryKey: ["shared-transactions"],
queryFn: async () => {
const res = await fetch("/api/shared-transactions");
return res.json();
},
});
}
export function useTransactionSplits(transactionId: number) {
return useQuery({
queryKey: ["splits", transactionId],
queryFn: async () => {
const res = await fetch(`/api/transactions/${transactionId}/splits`);
return res.json();
},
});
}
export function useSetSplits() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
transactionId,
splits,
}: {
transactionId: number;
splits: { participant_id: number; share_percent: number }[];
}) => {
const res = await fetch(`/api/transactions/${transactionId}/splits`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ splits }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to set splits");
}
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["splits"] });
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
qc.invalidateQueries({ queryKey: ["participant-balances"] });
},
});
}
export function useSettleSplits() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: { participant_id?: number; split_ids?: number[] }) => {
const res = await fetch("/api/splits/settle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
qc.invalidateQueries({ queryKey: ["participant-balances"] });
},
});
}
export function useCurrentUser() {
return useQuery<CurrentUser>({
queryKey: ["me"],
queryFn: async () => {
const res = await fetch("/api/me");
if (!res.ok) throw new Error("Not authenticated");
return res.json();
},
});
}
export function useUpdateStatement() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, owner_id }: { id: number; owner_id: number }) => {
const res = await fetch(`/api/statements/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ owner_id }),
});
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["statements"] });
qc.invalidateQueries({ queryKey: ["transactions"] });
},
});
}
export function useTags() {
return useQuery<(TagRow & { transaction_count: number })[]>({
queryKey: ["tags"],
queryFn: async () => {
const res = await fetch("/api/tags");
return res.json();
},
});
}
export function useCreateTag() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ name, color }: { name: string; color?: string }) => {
const res = await fetch("/api/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, color }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to create tag");
}
return res.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["tags"] }),
});
}
export function useDeleteTag() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await fetch(`/api/tags/${id}`, { method: "DELETE" });
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tags"] });
qc.invalidateQueries({ queryKey: ["transactions"] });
},
});
}
export function useAddTransactionTag() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ transactionId, tagId }: { transactionId: number; tagId: number }) => {
await fetch(`/api/transactions/${transactionId}/tags`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag_id: tagId }),
});
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }),
});
}
export function useRemoveTransactionTag() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ transactionId, tagId }: { transactionId: number; tagId: number }) => {
await fetch(`/api/transactions/${transactionId}/tags`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag_id: tagId }),
});
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }),
});
}
export function useCreateParticipant() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ name, email }: { name: string; email?: string }) => {
const res = await fetch("/api/participants", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to create participant");
}
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["participants"] });
qc.invalidateQueries({ queryKey: ["participant-balances"] });
},
});
}
// --- Budgets & Analytics ---
export interface BudgetRow {
id: number;
category: string;
month: string;
amount_limit: number;
}
export interface MonthlyAnalyticsRow {
category: string;
spent: Record<string, number>;
budget: Record<string, number>;
txCount: Record<string, number>;
}
export interface MonthlyAnalytics {
months: string[];
rows: MonthlyAnalyticsRow[];
totals: Record<string, { spent: number; budget: number }>;
}
export function useBudgets(month: string) {
return useQuery<BudgetRow[]>({
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<MonthlyAnalytics>({
queryKey: ["analytics", "monthly", m],
queryFn: async () => {
const res = await fetch(`/api/analytics/monthly?months=${m}`);
return res.json();
},
});
}
+124 -14
View File
@@ -1,5 +1,11 @@
import { queryRaw } from "./db";
export interface TagRow {
id: number;
name: string;
color: string;
}
export interface TransactionRow {
id: number;
statement_id: number;
@@ -23,6 +29,10 @@ export interface TransactionRow {
effective_merchant: string;
// statement context
bank_name: string;
owner_id: number;
owner_name: string;
// tags
tags: TagRow[];
}
export interface StatementRow {
@@ -31,6 +41,7 @@ export interface StatementRow {
card_name: string | null;
account_number: string;
account_type: string | null;
account_holder_name: string | null;
billing_start_date: string | null;
billing_end_date: string | null;
total_amount_due: number;
@@ -45,6 +56,8 @@ export interface StatementRow {
credit_limit: number | null;
currency: string;
tier_used: string | null;
owner_id: number;
owner_name: string;
created_at: string;
transaction_count: number;
}
@@ -56,16 +69,17 @@ interface TransactionFilters {
bank_name?: string;
search?: string;
statement_id?: string;
tag_id?: string;
sort_by?: string;
sort_dir?: string;
limit?: number;
offset?: number;
}
export async function getTransactions(filters: TransactionFilters) {
const conditions: string[] = [];
const params: unknown[] = [];
let paramIdx = 1;
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
const conditions: string[] = [`s.owner_id = $1`];
const params: unknown[] = [ownerId];
let paramIdx = 2;
if (filters.from) {
conditions.push(`t.transaction_date >= $${paramIdx++}`);
@@ -92,15 +106,18 @@ export async function getTransactions(filters: TransactionFilters) {
conditions.push(`t.statement_id = $${paramIdx++}`);
params.push(Number(filters.statement_id));
}
if (filters.tag_id) {
conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = $${paramIdx++})`);
params.push(Number(filters.tag_id));
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const where = `WHERE ${conditions.join(" AND ")}`;
const sortCol = filters.sort_by === "amount" ? "t.amount" : "t.transaction_date";
const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
const limit = filters.limit || 50;
const offset = filters.offset || 0;
// Count query
const countSql = `
SELECT COUNT(*)::int as total
FROM transactions t
@@ -111,23 +128,35 @@ export async function getTransactions(filters: TransactionFilters) {
const countResult = await queryRaw<{ total: number }>(countSql, params);
const total = countResult[0]?.total || 0;
// Data query
const dataSql = `
SELECT t.*,
o.category_override, o.merchant_normalized as merchant_override, o.notes,
COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
s.bank_name
s.bank_name, s.owner_id,
p.name as owner_name,
txn_tags.tags
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p ON p.id = s.owner_id
LEFT JOIN LATERAL (
SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags
FROM transaction_tags tt
JOIN tags tg ON tg.id = tt.tag_id
WHERE tt.transaction_id = t.id
) txn_tags ON true
${where}
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
`;
params.push(limit, offset);
const data = await queryRaw<TransactionRow>(dataSql, params);
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[] }>(dataSql, params);
const data = raw.map((r) => ({
...r,
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
})) as TransactionRow[];
return { data, total, limit, offset };
}
@@ -138,31 +167,38 @@ export async function getTransactionById(id: number) {
o.category_override, o.merchant_normalized as merchant_override, o.notes,
COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
s.bank_name
s.bank_name, s.owner_id,
p.name as owner_name
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p ON p.id = s.owner_id
WHERE t.id = $1
`;
const rows = await queryRaw<TransactionRow>(sql, [id]);
return rows[0] || null;
}
export async function getStatements() {
export async function getStatements(ownerId: number) {
const sql = `
SELECT s.*,
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count,
p.name as owner_name
FROM statements s
LEFT JOIN participants p ON p.id = s.owner_id
WHERE s.owner_id = $1
ORDER BY s.billing_end_date DESC NULLS LAST, s.created_at DESC
`;
return queryRaw<StatementRow>(sql);
return queryRaw<StatementRow>(sql, [ownerId]);
}
export async function getStatementById(id: number) {
const sql = `
SELECT s.*,
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count,
p.name as owner_name
FROM statements s
LEFT JOIN participants p ON p.id = s.owner_id
WHERE s.id = $1
`;
const rows = await queryRaw<StatementRow>(sql, [id]);
@@ -185,3 +221,77 @@ export async function getBankNames() {
const sql = `SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`;
return queryRaw<{ bank_name: string }>(sql);
}
export interface ParticipantBalance {
id: number;
name: string;
total_owed: number;
unsettled_count: number;
}
export async function getParticipantBalances(ownerId: number) {
return queryRaw<ParticipantBalance>(`
SELECT p.id, p.name,
COALESCE(SUM(CASE WHEN ts.settled = false THEN t.amount * ts.share_percent / 100 ELSE 0 END), 0)::numeric(12,2) as total_owed,
COUNT(CASE WHEN ts.settled = false THEN 1 END)::int as unsettled_count
FROM participants p
LEFT JOIN transaction_splits ts ON ts.participant_id = p.id
LEFT JOIN transactions t ON t.id = ts.transaction_id
LEFT JOIN statements s ON s.id = t.statement_id
WHERE (s.owner_id = $1 OR s.id IS NULL)
GROUP BY p.id, p.name
ORDER BY p.name
`, [ownerId]);
}
export interface SharedTransactionRow extends TransactionRow {
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
}
export async function getTags() {
return queryRaw<TagRow & { transaction_count: number }>(`
SELECT tg.id, tg.name, tg.color,
COUNT(tt.transaction_id)::int as transaction_count
FROM tags tg
LEFT JOIN transaction_tags tt ON tt.tag_id = tg.id
GROUP BY tg.id
ORDER BY tg.name
`);
}
export async function getSharedTransactions(ownerId: number) {
const rows = await queryRaw<TransactionRow & { split_data: string }>(`
SELECT t.*,
o.category_override, o.merchant_normalized as merchant_override, o.notes,
COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
s.bank_name, s.owner_id,
p_owner.name as owner_name,
json_agg(json_build_object(
'split_id', ts.id,
'participant_id', ts.participant_id,
'name', p.name,
'share_percent', ts.share_percent,
'settled', ts.settled
) ORDER BY p.name) as split_data
FROM transactions t
JOIN transaction_splits ts ON ts.transaction_id = t.id
JOIN participants p ON p.id = ts.participant_id
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p_owner ON p_owner.id = s.owner_id
WHERE s.owner_id = $1
AND EXISTS (
SELECT 1 FROM transaction_splits ts2
JOIN participants p2 ON p2.id = ts2.participant_id
WHERE ts2.transaction_id = t.id AND p2.name != 'Me'
)
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name
ORDER BY t.transaction_date DESC
`, [ownerId]);
return rows.map((r) => ({
...r,
splits: typeof r.split_data === "string" ? JSON.parse(r.split_data) : r.split_data,
}));
}