Files
finance-app/src/app/statements/page.tsx
T
siddharthd 95d8544752
ci / lint-test (push) Successful in 46s
transactions: change who paid, on a row and on a statement
Owner was write-once for every ingestion path — a pantry receipt hardcodes
DEFAULT_OWNER_ID and there was not one `UPDATE ... SET owner_id` in src/ —
so a shop the other person paid for was permanently filed as yours.

PATCH /api/transactions/[id] now takes owner_id, for manual rows only. A
statement row returns 400 statement_owned and points at the statements
page: its effective owner is COALESCE(t.owner_id, s.owner_id), so writing
it there would either no-op or detach one row from the account it came
from.

PATCH /api/statements/[id] is new. The statements page has had an owner
dropdown since it was built, wired to a route with no PATCH handler —
every change 405'd, and because useUpdateStatement never checked res.ok
it failed silently and the select snapped back on refetch. It writes both
tables: 2,194 statement rows carry their own owner_id against 1,803 that
inherit, so updating `statements` alone moves less than half and splits
one account's history between two people.

The guard is the point. Access is "owner OR holds a split", so handing a
row over while holding no split removes it from your list and 404s every
route that could put it back — only the new owner can undo it. That is
409 would_lose_access, and the modal offers both ways forward: add my
split first, or give it away anyway. Taking a row onto your own ledger is
never blocked, and claiming a row you cannot see is a 404 before any
owner logic runs.

Splits are deliberately not rewritten. They record shares, not direction,
so a 50/50 flips from "they owe me" to "I owe them" untouched, settled
included.

Also adds the missing res.ok check to useUpdateTransaction, without which
every rejection resolved as success: the modal closed, the list
refetched, and the edit silently vanished.

14 new integration tests; 203 integration + 130 unit green.
2026-08-15 16:17:38 +10:00

304 lines
14 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)}
{/* An account cannot be billed twice for the same day, so
an overlap means these transactions are in the ledger
twice. Red rather than amber: the balance warning above
means a statement doesn't add up, this means the data is
double-counted everywhere it is summed. */}
{s.overlaps?.length > 0 && (
<div
className="text-[10px] text-red-400 mt-0.5"
title={`Billing period overlaps statement ${s.overlaps
.map((o) => `#${o.id} by ${o.days} day${o.days === 1 ? "" : "s"}`)
.join(", ")}. The overlapping transactions are likely imported twice.`}
>
overlaps #{s.overlaps.map((o) => o.id).join(", #")}
</div>
)}
</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) => {
const next = Number(e.target.value);
const name = participants.find((p) => p.id === next)?.name ?? "them";
// This page only lists statements you own, so handing
// one over removes it — and every transaction on it —
// from your view, and only they can hand it back.
if (
!confirm(
`Reassign this statement to ${name}?\n\nEvery transaction on it moves to their ledger, and the statement leaves your list — only ${name} can move it back.`
)
) {
return;
}
updateStatement.mutate(
{ id: s.id, owner_id: next },
{ onError: (err) => alert(err instanceof Error ? err.message : "Failed to reassign") }
);
}}
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>
);
}