278e57354c
- Monthly spend chart with category breakdown drill-down - Merchant frequency and spend analytics with per-merchant history - Subscription detection and recurring charge tracking - Fee and interest analytics endpoint - Expanded category list with formatCategory display helper
122 lines
5.0 KiB
TypeScript
122 lines
5.0 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { getCurrentUser } from "@/lib/auth";
|
|
import { queryRaw } from "@/lib/db";
|
|
|
|
// 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 COALESCE(o.category_override, t.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
|
|
JOIN statements s ON s.id = t.statement_id
|
|
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 s.owner_id = $1
|
|
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
|
|
AND t.transaction_date >= $2
|
|
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
|
|
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
|
|
JOIN statements s ON s.id = t.statement_id
|
|
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 s.owner_id = $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 COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
|
|
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 });
|
|
}
|