Files
finance-app/src/app/statements/page.tsx
T
siddharthd d6b4ec84f6
ci / lint-test (push) Successful in 39s
feat(integrity): balance assertions, category constraint, refund netting
Four related fixes to spend correctness.

Balance assertions. Nothing checked that a statement's transactions add up to
its closing balance. getStatements now computes opening + movement - closing
and the statements page flags any statement that does not reconcile. Sign
depends on what the balance means: on a credit card or loan it is what you owe,
so spending increases it; on a transaction or offset account it is what you
hold. 11 statements currently fail, $4,177 unexplained - including two adjacent
ANZ statements off by exactly +/-$230.38, a transaction filed against the wrong
one.

Category normalisation (migration 0015). Categories arrive from Gemini (which
writes straight to Postgres from N8N), CSV import and manual edits, so the rule
belongs in the database - same reasoning as normalize_statement_type in 0013.
Adds normalize_category(), triggers on transactions and transaction_overrides,
and CHECK constraints. Backfilled 122 rows: 19 'payment' ($42,569) to transfers,
14 'refund' recovered to the merchant's usual category, and title-case duplicates
folded into their canonical spelling - 'Shopping' and 'shopping' had been
counted as separate categories by every GROUP BY.

Refund netting. Monthly analytics counted only debits, so a refund was counted
nowhere: excluded from spend by type, and not income by category. A $2,888.92
Expedia purchase refunded in full eight days later still read as $2,888.92 of
spend. SPEND_SIGNED and NET_SPEND_ROWS bring refunds in as negatives; 'income'
joins the excluded categories so incoming money cannot leak in as negative
spend. $36,773 across 93 rows now nets correctly.

Drill-down share. The insights drill-down used my_share_percent ?? 100, which
ignored transaction_splits entirely and repeated the bug fixed in ab00f8c one
layer up. getTransactions now returns my_share_pct and my_amount resolved
server-side, and the table shows gross alongside your share.
2026-07-26 10:35:59 +10:00

274 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { useStatements, useParticipants, useUpdateStatement } from "@/lib/hooks";
import {
STATEMENT_TYPES,
STATEMENT_TYPE_LABELS,
asStatementType,
isLiability,
} from "@/lib/statement-types";
function formatDate(d: string | null) {
if (!d) return "—";
return new Date(d).toLocaleDateString("en-AU", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
function formatPeriod(start: string | null, end: string | null) {
if (!start && !end) return "—";
const fmt = (d: string) =>
new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "2-digit" });
if (!start) return `until ${fmt(end!)}`;
if (!end) return `from ${fmt(start)}`;
return `${fmt(start)} – ${fmt(end)}`;
}
function formatAmount(n: number | null): string {
if (n === null || n === undefined) return "—";
return new Intl.NumberFormat("en-AU", {
style: "currency",
currency: "AUD",
minimumFractionDigits: 2,
}).format(Number(n));
}
const selectCls =
"bg-zinc-900 border border-zinc-700 rounded text-xs px-2 py-1.5 text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-indigo-500";
export default function StatementsPage() {
const { data: statements, isLoading } = useStatements();
const { data: participants } = useParticipants();
const updateStatement = useUpdateStatement();
const [bankFilter, setBankFilter] = useState("");
const [typeFilter, setTypeFilter] = useState<"all" | (typeof STATEMENT_TYPES)[number]>("all");
const [ownerFilter, setOwnerFilter] = useState("");
const [yearFilter, setYearFilter] = useState("");
const banks = useMemo(
() => [...new Set((statements ?? []).map((s) => s.bank_name))].sort(),
[statements]
);
const years = useMemo(
() =>
[
...new Set(
(statements ?? [])
.map((s) => s.billing_end_date?.slice(0, 4))
.filter(Boolean) as string[]
),
].sort((a, b) => b.localeCompare(a)),
[statements]
);
const filtered = useMemo(() => {
if (!statements) return [];
return statements.filter((s) => {
if (bankFilter && s.bank_name !== bankFilter) return false;
if (typeFilter !== "all" && asStatementType(s.statement_type) !== typeFilter) return false;
if (ownerFilter && String(s.owner_id) !== ownerFilter) return false;
if (yearFilter && s.billing_end_date?.slice(0, 4) !== yearFilter) return false;
return true;
});
}, [statements, bankFilter, typeFilter, ownerFilter, yearFilter]);
const hasFilters = bankFilter || typeFilter !== "all" || ownerFilter || yearFilter;
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-display">Statements</h2>
{!isLoading && statements && (
<span className="text-xs text-zinc-500">
{hasFilters ? `${filtered.length} of ${statements.length}` : statements.length} statements
</span>
)}
</div>
{/* Filters */}
{!isLoading && statements && (
<div className="flex flex-wrap gap-2 mb-4">
<select value={bankFilter} onChange={(e) => setBankFilter(e.target.value)} className={selectCls}>
<option value="">All banks</option>
{banks.map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value as typeof typeFilter)} className={selectCls}>
<option value="all">All types</option>
{STATEMENT_TYPES.map((t) => (
<option key={t} value={t}>{STATEMENT_TYPE_LABELS[t]}</option>
))}
</select>
{participants && participants.length > 1 && (
<select value={ownerFilter} onChange={(e) => setOwnerFilter(e.target.value)} className={selectCls}>
<option value="">All owners</option>
{participants.map((p) => (
<option key={p.id} value={String(p.id)}>{p.name}</option>
))}
</select>
)}
<select value={yearFilter} onChange={(e) => setYearFilter(e.target.value)} className={selectCls}>
<option value="">All years</option>
{years.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
{hasFilters && (
<button
onClick={() => { setBankFilter(""); setTypeFilter("all"); setOwnerFilter(""); setYearFilter(""); }}
className="text-xs text-zinc-500 hover:text-zinc-300 px-2 py-1.5 transition-colors"
>
× Clear
</button>
)}
</div>
)}
{isLoading ? (
<p className="text-zinc-500 text-sm">Loading...</p>
) : !filtered.length ? (
<p className="text-zinc-500 text-sm">{hasFilters ? "No statements match filters" : "No statements found"}</p>
) : (
<div className="border border-zinc-700 rounded-xl overflow-x-auto">
<table className="w-full text-sm min-w-[800px]">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
<th className="text-left px-3 py-2.5 text-xs text-zinc-600 font-medium w-8">#</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Bank</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Account</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Period</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Due / End</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Ccy</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Amount</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Txns</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Owner</th>
<th className="px-4 py-2.5 hidden sm:table-cell"></th>
</tr>
</thead>
<tbody>
{filtered.map((s, idx) => {
const stmtType = asStatementType(s.statement_type);
const owed = isLiability(s.statement_type);
// Cards headline the amount due; everything else (including loans,
// where the balance is what's still owed) headlines the balance.
const displayAmount =
stmtType === "credit_card" ? s.total_amount_due : s.closing_balance;
const amount = Number(displayAmount);
const amountColor = owed
? "text-red-400"
: amount >= 0
? "text-green-400"
: "text-red-400";
return (
<tr key={s.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20 transition-colors">
<td className="px-3 py-3 text-xs text-zinc-600 tabular-nums">{idx + 1}</td>
<td className="px-4 py-3 sticky left-0 z-10 bg-zinc-950 border-r border-zinc-800/80">
<div className="font-medium truncate max-w-[160px]" title={s.bank_name}>
{s.bank_name}
</div>
{s.card_name && (
<div className="text-xs text-zinc-500 truncate max-w-[160px]">{s.card_name}</div>
)}
{stmtType === "loan" && (s.interest_rate || s.scheduled_repayment) && (
<div className="text-xs text-zinc-500 truncate max-w-[160px]">
{[
s.interest_rate ? `${Number(s.interest_rate).toFixed(2)}% p.a.` : null,
s.scheduled_repayment
? `${formatAmount(s.scheduled_repayment)}${
s.repayment_frequency ? ` ${s.repayment_frequency}` : ""
}`
: null,
]
.filter(Boolean)
.join(" · ")}
</div>
)}
<Link
href={`/transactions?statement_id=${s.id}`}
className="sm:hidden text-xs text-indigo-400 hover:text-indigo-300 mt-1 inline-block"
>
View →
</Link>
</td>
<td className="px-4 py-3 text-zinc-400 font-mono text-xs">
{s.account_number}
</td>
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
{formatPeriod(s.billing_start_date, s.billing_end_date)}
</td>
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
{formatDate(s.payment_due_date ?? s.billing_end_date)}
</td>
<td className="px-4 py-3 text-zinc-500 text-xs">
{s.currency}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{displayAmount !== null && displayAmount !== undefined ? (
<span className={amountColor}>{formatAmount(displayAmount)}</span>
) : (
<span className="text-zinc-600">—</span>
)}
</td>
<td className="px-4 py-3 text-right text-zinc-500">
{s.transaction_count}
{/* Balance assertion: flags statements whose transactions
don't add up to the closing balance. */}
{s.balance_diff !== null && s.balance_diff !== undefined &&
Math.abs(Number(s.balance_diff)) >= 0.02 && (
<div
className="text-[10px] text-amber-400 mt-0.5 whitespace-nowrap"
title={`Transactions don't reconcile: expected closing ${formatAmount(
s.expected_closing
)}, statement says ${formatAmount(s.closing_balance)}`}
>
⚠ {formatAmount(Math.abs(Number(s.balance_diff)))} off
</div>
)}
</td>
<td className="px-4 py-3">
{participants?.length ? (
<select
value={s.owner_id ?? ""}
onChange={(e) =>
updateStatement.mutate({ id: s.id, owner_id: Number(e.target.value) })
}
className="bg-zinc-800 border border-zinc-700 rounded text-xs px-2 py-1 text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-indigo-500"
>
{participants.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
) : (
<span className="text-zinc-600 text-xs">{s.owner_name}</span>
)}
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<Link
href={`/transactions?statement_id=${s.id}`}
className="px-3 py-1 bg-zinc-800 hover:bg-zinc-700 rounded text-xs transition-colors whitespace-nowrap"
>
View →
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}