ci / lint-test (push) Successful in 38s
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.
123 lines
4.9 KiB
TypeScript
123 lines
4.9 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { getCurrentUser } from "@/lib/auth";
|
|
import { queryRaw } from "@/lib/db";
|
|
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`;
|
|
const SPEND_EXPR = `
|
|
CASE
|
|
WHEN t.transaction_type IN ('refund', 'credit') THEN -(${MY_AMOUNT})
|
|
ELSE (${MY_AMOUNT})
|
|
END
|
|
`;
|
|
|
|
export async function GET(req: NextRequest) {
|
|
const user = await getCurrentUser(req);
|
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
|
|
const { searchParams } = new URL(req.url);
|
|
const months = Math.min(24, Math.max(1, Number(searchParams.get("months") || "12")));
|
|
|
|
const cutoff = new Date();
|
|
cutoff.setMonth(cutoff.getMonth() - months);
|
|
const fromDate = cutoff.toISOString().slice(0, 10);
|
|
|
|
// Merchant aggregates — net spend (debits + fees - refunds/credits)
|
|
const rows = await queryRaw<{
|
|
merchant: string;
|
|
category: string;
|
|
debit_count: number;
|
|
refund_count: number;
|
|
gross_spend: number;
|
|
total_refunds: number;
|
|
net_spend: number;
|
|
avg_debit: number;
|
|
first_seen: string;
|
|
last_seen: string;
|
|
months_active: number;
|
|
}>(`
|
|
SELECT
|
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) as merchant,
|
|
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(
|
|
CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN
|
|
${MY_AMOUNT}
|
|
ELSE 0 END
|
|
), 0)::numeric(12,2) as gross_spend,
|
|
COALESCE(SUM(
|
|
CASE WHEN t.transaction_type IN ('refund', 'credit') THEN
|
|
${MY_AMOUNT}
|
|
ELSE 0 END
|
|
), 0)::numeric(12,2) as total_refunds,
|
|
SUM(${SPEND_EXPR})::numeric(12,2) as net_spend,
|
|
AVG(
|
|
CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN
|
|
${MY_AMOUNT}
|
|
END
|
|
)::numeric(10,2) as avg_debit,
|
|
MIN(t.transaction_date)::text as first_seen,
|
|
MAX(t.transaction_date)::text as last_seen,
|
|
COUNT(DISTINCT TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM'))::int as months_active
|
|
FROM transactions t
|
|
${STATEMENTS_JOIN}
|
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
|
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
|
WHERE ${OWNER_SCOPE} = $1
|
|
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
|
|
AND t.transaction_date >= $2
|
|
AND ${EXCLUDE_NON_SPEND}
|
|
GROUP BY 1
|
|
HAVING SUM(${SPEND_EXPR}) > 0
|
|
ORDER BY net_spend DESC
|
|
LIMIT 200
|
|
`, [user.id, fromDate]);
|
|
|
|
// Monthly net trend per merchant (top 50 by net spend)
|
|
const topMerchants = rows.slice(0, 50).map((r) => r.merchant);
|
|
|
|
interface TrendRow { merchant: string; month: string; total: number }
|
|
let trendRows: TrendRow[] = [];
|
|
if (topMerchants.length > 0) {
|
|
trendRows = await queryRaw<TrendRow>(`
|
|
SELECT
|
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) as merchant,
|
|
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
|
SUM(${SPEND_EXPR})::numeric(10,2) as total
|
|
FROM transactions t
|
|
${STATEMENTS_JOIN}
|
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
|
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
|
WHERE ${OWNER_SCOPE} = $1
|
|
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
|
|
AND t.transaction_date >= $2
|
|
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3)
|
|
AND ${EXCLUDE_NON_SPEND}
|
|
GROUP BY 1, 2
|
|
ORDER BY 1, 2
|
|
`, [user.id, fromDate, topMerchants]);
|
|
}
|
|
|
|
const trendByMerchant: Record<string, Record<string, number>> = {};
|
|
for (const tr of trendRows) {
|
|
if (!trendByMerchant[tr.merchant]) trendByMerchant[tr.merchant] = {};
|
|
trendByMerchant[tr.merchant][tr.month] = Number(tr.total);
|
|
}
|
|
|
|
const merchants = rows.map((r) => ({
|
|
...r,
|
|
debit_count: Number(r.debit_count),
|
|
refund_count: Number(r.refund_count),
|
|
gross_spend: Number(r.gross_spend),
|
|
total_refunds: Number(r.total_refunds),
|
|
net_spend: Number(r.net_spend),
|
|
avg_debit: Number(r.avg_debit),
|
|
months_active: Number(r.months_active),
|
|
monthly_trend: trendByMerchant[r.merchant] || {},
|
|
}));
|
|
|
|
return NextResponse.json({ merchants, months });
|
|
}
|