feat(statements+analytics): normalise statement_type; fix analytics scoping
ci / lint-test (push) Successful in 38s

Groundwork for importing bank and loan statements alongside credit cards.

statement_type was whatever free text Gemini put in account_type ('Credit Card',
'credit card', 'credit_card', 'Business Card', 'ACCESS ADVANTAGE',
'multi-currency account'). The UI coped only by doing .includes("card"), which
breaks as soon as bank and loan statements arrive.

- Migration 0013 adds normalize_statement_type() + a BEFORE INSERT/UPDATE
  trigger and a CHECK constraint over credit_card|transaction|savings|loan|
  offset|investment|other. The trigger means the N8N workflow keeps working
  unchanged while it still sends free text. Raw value stays in account_type.
  Backfilled 99 existing rows.
- src/lib/statement-types.ts mirrors the vocabulary for the UI; statements page
  now filters by the real types and headlines balance vs amount due per type.

Analytics were scoped with INNER JOIN statements + s.owner_id, which silently
dropped all 180 manual/CSV transactions (statement_id IS NULL) from every
report. Switched all six routes to LEFT JOIN + COALESCE(t.owner_id, s.owner_id)
via shared fragments in src/lib/analytics-sql.ts, so the transfers/investment
exclusion that stops card-payment double counting stays consistent. Also
extended that exclusion to trip analytics, which had none.

Drive-by: /api/analytics/subscriptions was returning 500 on an unserialisable
BigInt from COUNT(*) + 1.

Verified against the live DB: monthly spend picks up the previously invisible
manual transactions (Apr 9,849.91 -> 14,286.70) and all four analytics
endpoints return 200.
This commit is contained in:
2026-07-26 00:00:55 +10:00
parent e0b0fc91e0
commit 25ef504574
12 changed files with 269 additions and 39 deletions
+19 -10
View File
@@ -3,6 +3,12 @@
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 "—";
@@ -40,7 +46,7 @@ export default function StatementsPage() {
const updateStatement = useUpdateStatement();
const [bankFilter, setBankFilter] = useState("");
const [typeFilter, setTypeFilter] = useState<"all" | "card" | "bank">("all");
const [typeFilter, setTypeFilter] = useState<"all" | (typeof STATEMENT_TYPES)[number]>("all");
const [ownerFilter, setOwnerFilter] = useState("");
const [yearFilter, setYearFilter] = useState("");
@@ -63,10 +69,8 @@ export default function StatementsPage() {
const filtered = useMemo(() => {
if (!statements) return [];
return statements.filter((s) => {
const isCard = s.statement_type?.toLowerCase().includes("card") ?? false;
if (bankFilter && s.bank_name !== bankFilter) return false;
if (typeFilter === "card" && !isCard) return false;
if (typeFilter === "bank" && isCard) 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;
@@ -98,8 +102,9 @@ export default function StatementsPage() {
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value as typeof typeFilter)} className={selectCls}>
<option value="all">All types</option>
<option value="card">Credit card</option>
<option value="bank">Bank account</option>
{STATEMENT_TYPES.map((t) => (
<option key={t} value={t}>{STATEMENT_TYPE_LABELS[t]}</option>
))}
</select>
{participants && participants.length > 1 && (
@@ -152,10 +157,14 @@ export default function StatementsPage() {
</thead>
<tbody>
{filtered.map((s, idx) => {
const isCreditCard = s.statement_type?.toLowerCase().includes("card") ?? false;
const displayAmount = isCreditCard ? s.total_amount_due : s.closing_balance;
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 = isCreditCard
const amountColor = owed
? "text-red-400"
: amount >= 0
? "text-green-400"
@@ -185,7 +194,7 @@ export default function StatementsPage() {
{formatPeriod(s.billing_start_date, s.billing_end_date)}
</td>
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
{isCreditCard ? formatDate(s.payment_due_date) : formatDate(s.billing_end_date)}
{formatDate(s.payment_due_date ?? s.billing_end_date)}
</td>
<td className="px-4 py-3 text-zinc-500 text-xs">
{s.currency}