From 315bc06d0da4388f2f6d0792215a62c444367ef5 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sun, 26 Jul 2026 01:35:50 +1000 Subject: [PATCH] fix(merchants): page crashed on an uncategorised transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from the analytics scoping work. Chain: 1. Three transactions have a NULL category. The old WHERE clause `COALESCE(o.category_override, t.category) NOT IN ('transfers','investment')` evaluated to NULL for those rows, so they were silently dropped — which is the bug EXCLUDE_NON_SPEND fixed by defaulting to 'other'. 2. Including them exposed that the SELECT still used the non-null-safe COALESCE, so MODE() returned NULL for the affected merchant ("Shared expenses carryover (SplitMyExpenses)", a manual transaction that the LEFT JOIN fix also newly included). 3. formatCategory(null) threw "Cannot read properties of null (reading 'split')" and took down the whole page render. Fixed at both layers: - Both merchants routes now use the EFFECTIVE_CATEGORY fragment, so the API cannot emit a null category. - formatCategory tolerates null/undefined and returns "Uncategorised". An uncategorised transaction should never be able to crash a page. Verified against the live DB: 200 merchants, zero null categories, and /merchants /transactions /insights /budget /statements all render 200. --- src/app/api/analytics/merchants/[merchant]/route.ts | 4 ++-- src/app/api/analytics/merchants/route.ts | 4 ++-- src/lib/categories.ts | 5 ++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/app/api/analytics/merchants/[merchant]/route.ts b/src/app/api/analytics/merchants/[merchant]/route.ts index 28bb4d5..2361bd3 100644 --- a/src/app/api/analytics/merchants/[merchant]/route.ts +++ b/src/app/api/analytics/merchants/[merchant]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; -import { OWNER_SCOPE, STATEMENTS_JOIN } from "@/lib/analytics-sql"; +import { OWNER_SCOPE, STATEMENTS_JOIN, EFFECTIVE_CATEGORY } from "@/lib/analytics-sql"; export async function GET( req: NextRequest, @@ -38,7 +38,7 @@ export async function GET( (CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END) END::numeric(10,2) as my_amount, t.transaction_type, - COALESCE(o.category_override, t.category) as category, + ${EFFECTIVE_CATEGORY} as category, COALESCE(s.bank_name, 'Manual') as bank_name, t.statement_id FROM transactions t diff --git a/src/app/api/analytics/merchants/route.ts b/src/app/api/analytics/merchants/route.ts index 45799c5..23cdc41 100644 --- a/src/app/api/analytics/merchants/route.ts +++ b/src/app/api/analytics/merchants/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; -import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND } from "@/lib/analytics-sql"; +import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EFFECTIVE_CATEGORY } from "@/lib/analytics-sql"; // Split-adjusted amount helper (positive for spend, negative for refunds) const MY_AMOUNT = `CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END`; @@ -39,7 +39,7 @@ export async function GET(req: NextRequest) { }>(` SELECT COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) as merchant, - MODE() WITHIN GROUP (ORDER BY COALESCE(o.category_override, t.category)) as category, + MODE() WITHIN GROUP (ORDER BY ${EFFECTIVE_CATEGORY}) as category, COUNT(*) FILTER (WHERE t.transaction_type IN ('debit', 'fee', 'interest'))::int as debit_count, COUNT(*) FILTER (WHERE t.transaction_type IN ('refund', 'credit'))::int as refund_count, COALESCE(SUM( diff --git a/src/lib/categories.ts b/src/lib/categories.ts index 1178815..8a74989 100644 --- a/src/lib/categories.ts +++ b/src/lib/categories.ts @@ -36,7 +36,10 @@ export const CATEGORIES = [ export type Category = (typeof CATEGORIES)[number]; -export function formatCategory(cat: string): string { +// Tolerates null/undefined: an uncategorised transaction must never be able to +// crash a page that renders its category. +export function formatCategory(cat: string | null | undefined): string { + if (!cat) return "Uncategorised"; return cat .split("_") .map((w) => w.charAt(0).toUpperCase() + w.slice(1))