feat(statements+analytics): normalise statement_type; fix analytics scoping
ci / lint-test (push) Successful in 38s
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:
+19
-10
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user