feat: initial finance SPA — phases 1 & 2

Next.js 16 personal finance dashboard connected to postgres-personal.

Phase 1 (Foundation):
- API routes: GET /api/transactions (paginated, filterable, sortable),
  GET /api/statements, GET /api/merchants
- Transactions data table with date/category/bank/search filters, pagination, sort
- Statements card grid with period, due date, amount, transaction count
- Sidebar layout with nav for all planned sections

Phase 2 (Normalisation):
- PATCH /api/transactions/[id] — upsert transaction_overrides
- POST /api/transactions/bulk — bulk categorize/normalize
- Inline click-to-edit category (22 options) and merchant name
- Blue dot override indicator, bulk action bar
- Effective values via COALESCE(override, llm_value) pattern

Stack: Next.js 16 (App Router, standalone), Prisma 7.x + @prisma/adapter-pg,
TanStack Query, Tailwind CSS. Auth via Traefik chain-oauth@file.
This commit is contained in:
2026-03-07 23:31:40 +11:00
parent 28207b42b5
commit 35a5be97b0
31 changed files with 2243 additions and 99 deletions
+187
View File
@@ -0,0 +1,187 @@
import { queryRaw } from "./db";
export interface TransactionRow {
id: number;
statement_id: number;
transaction_date: string;
description: string;
amount: number;
transaction_type: string;
merchant_name: string | null;
merchant_normalized: string | null;
location: string | null;
foreign_currency_amount: number | null;
foreign_currency_code: string | null;
category: string;
row_index: number;
created_at: string;
// override fields
category_override: string | null;
merchant_override: string | null;
notes: string | null;
effective_category: string;
effective_merchant: string;
// statement context
bank_name: string;
}
export interface StatementRow {
id: number;
bank_name: string;
card_name: string | null;
account_number: string;
account_type: string | null;
billing_start_date: string | null;
billing_end_date: string | null;
total_amount_due: number;
minimum_amount_due: number | null;
payment_due_date: string;
opening_balance: number | null;
closing_balance: number | null;
total_credits: number | null;
total_debits: number | null;
interest_charged: number | null;
fees_charged: number | null;
credit_limit: number | null;
currency: string;
tier_used: string | null;
created_at: string;
transaction_count: number;
}
interface TransactionFilters {
from?: string;
to?: string;
category?: string;
bank_name?: string;
search?: string;
statement_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;
if (filters.from) {
conditions.push(`t.transaction_date >= $${paramIdx++}`);
params.push(filters.from);
}
if (filters.to) {
conditions.push(`t.transaction_date <= $${paramIdx++}`);
params.push(filters.to);
}
if (filters.category) {
conditions.push(`COALESCE(o.category_override, t.category) = $${paramIdx++}`);
params.push(filters.category);
}
if (filters.bank_name) {
conditions.push(`s.bank_name = $${paramIdx++}`);
params.push(filters.bank_name);
}
if (filters.search) {
conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx})`);
params.push(`%${filters.search}%`);
paramIdx++;
}
if (filters.statement_id) {
conditions.push(`t.statement_id = $${paramIdx++}`);
params.push(Number(filters.statement_id));
}
const where = conditions.length > 0 ? `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
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
${where}
`;
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
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
${where}
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
`;
params.push(limit, offset);
const data = await queryRaw<TransactionRow>(dataSql, params);
return { data, total, limit, offset };
}
export async function getTransactionById(id: number) {
const sql = `
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
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
WHERE t.id = $1
`;
const rows = await queryRaw<TransactionRow>(sql, [id]);
return rows[0] || null;
}
export async function getStatements() {
const sql = `
SELECT s.*,
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count
FROM statements s
ORDER BY s.billing_end_date DESC NULLS LAST, s.created_at DESC
`;
return queryRaw<StatementRow>(sql);
}
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
FROM statements s
WHERE s.id = $1
`;
const rows = await queryRaw<StatementRow>(sql, [id]);
return rows[0] || null;
}
export async function getMerchantSuggestions(search: string) {
const sql = `
SELECT DISTINCT COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as merchant
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
WHERE COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) ILIKE $1
ORDER BY merchant
LIMIT 20
`;
return queryRaw<{ merchant: string }>(sql, [`%${search}%`]);
}
export async function getBankNames() {
const sql = `SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`;
return queryRaw<{ bank_name: string }>(sql);
}