Files
finance-app/src/app/api/analytics/merchants/route.ts
T
siddharthd 6c14b493ef
ci / lint-test (push) Successful in 1m25s
analytics: your share of what someone else paid is your spend
Every spend analytic gated on `OWNER_SCOPE = $1` and scaled by `mySplitOf`
*within* that gate, so ownership was a precondition for an expense being
yours. Half a grocery shop Sonu paid for counted as zero — in monthly,
daily, merchants, subscriptions, fees and the budget page. 167 rows /
$3,210.91 across Jan-Jul 2026, worst in April (+$1,354.83, the Europe
trips), while getParticipantBalances booked the matching debt correctly.
The app could say you owed her for a shop while insisting you had not
spent anything on it.

New MY_SPEND_SCOPE(): owner = me OR I hold a split. OWNER_SCOPE stays on
the things that measure an *account* rather than a person — the income
and investment lines, and the statement-level fee rollup.

myShare had to change with it, and widening the gate alone would have
been worse than the bug: its `100 - everyone else` fallback is the
payer's remainder, so on someone else's unsplit row it returns 100 and
moves their whole bill onto you. It now branches on ownership — my row
resolves as before; their row takes an explicit split row only, absent
meaning 0. That 0 is what makes the wider gate safe.

my_share_percent is deliberately not read on someone else's row: one
unscoped column, writable by anyone who can see the row, so "my" can
only mean the owner's. All 402 rows carrying one today are owner-side.

MY_SHARE_PCT mirrors myShare for the transactions list, which has no
viewer-scoped ts join; a test asserts the two agree across seven fixture
shapes.

No historical restatement — every non-owner split is 2026-dated, and the
1,266 pre-2026 SplitMyExpenses splits are all on rows you own.

Also fixes a latent failure in the NATIVE_CURRENCY test, which inserted a
statement relying on participant id 1 existing (owner_id is NOT NULL
DEFAULT 1 with an FK) and only passed when a sibling file had left one
behind. It now owns its fixture.

15 new integration tests; 189 integration + 130 unit green.
2026-08-15 15:37:32 +10:00

125 lines
4.8 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import { MY_SPEND_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf, toDateStr } from "@/lib/analytics-sql";
// Split-adjusted amount helper (positive for spend, negative for refunds)
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
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 = toDateStr(cutoff);
// 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 ${MY_SPEND_SCOPE()}
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
AND t.transaction_date >= $2
AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
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 ${MY_SPEND_SCOPE()}
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}
AND ${EXCLUDE_RECONCILED_SOURCE}
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 });
}