ci / lint-test (push) Successful in 46s
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.
304 lines
14 KiB
TypeScript
304 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|