diff --git a/src/app/api/analytics/merchants/[merchant]/route.ts b/src/app/api/analytics/merchants/[merchant]/route.ts index adbe6ac..614da38 100644 --- a/src/app/api/analytics/merchants/[merchant]/route.ts +++ b/src/app/api/analytics/merchants/[merchant]/route.ts @@ -31,8 +31,10 @@ export async function GET( t.amount, t.amount_aud, CASE - WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 - ELSE COALESCE(t.amount_aud, t.amount) + WHEN t.transaction_type IN ('refund', 'credit') THEN + -(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END) + ELSE + (CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.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, @@ -43,7 +45,7 @@ export async function GET( 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') + AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit') AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2 ORDER BY t.transaction_date DESC LIMIT 500 diff --git a/src/app/api/analytics/merchants/route.ts b/src/app/api/analytics/merchants/route.ts index 783386c..2cd7fad 100644 --- a/src/app/api/analytics/merchants/route.ts +++ b/src/app/api/analytics/merchants/route.ts @@ -2,6 +2,16 @@ 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 SPEND_EXPR = ` + CASE + WHEN t.transaction_type IN ('refund', 'credit') THEN + -(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END) + ELSE + (CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END) + END +`; + export async function GET(req: NextRequest) { const user = await getCurrentUser(req); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -13,13 +23,16 @@ export async function GET(req: NextRequest) { cutoff.setMonth(cutoff.getMonth() - months); const fromDate = cutoff.toISOString().slice(0, 10); - // Merchant aggregates — split-adjusted, debit transactions only + // Merchant aggregates — net spend (debits + fees - refunds/credits) const rows = await queryRaw<{ merchant: string; category: string; - transaction_count: number; - total_spend: number; - avg_transaction: number; + 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; @@ -27,19 +40,24 @@ 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, - COUNT(*)::int as transaction_count, - SUM( - CASE - WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 - ELSE COALESCE(t.amount_aud, t.amount) - END - )::numeric(12,2) as total_spend, + 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 + CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END + ELSE 0 END + ), 0)::numeric(12,2) as gross_spend, + COALESCE(SUM( + CASE WHEN t.transaction_type IN ('refund', 'credit') THEN + CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END + ELSE 0 END + ), 0)::numeric(12,2) as total_refunds, + SUM(${SPEND_EXPR})::numeric(12,2) as net_spend, AVG( - CASE - WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 - ELSE COALESCE(t.amount_aud, t.amount) + CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN + CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END END - )::numeric(10,2) as avg_transaction, + )::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 @@ -48,21 +66,16 @@ export async function GET(req: NextRequest) { 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') + 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( - CASE - WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 - ELSE COALESCE(t.amount_aud, t.amount) - END - ) > 0 - ORDER BY total_spend DESC + HAVING SUM(${SPEND_EXPR}) > 0 + ORDER BY net_spend DESC LIMIT 200 `, [user.id, fromDate]); - // Monthly trend per merchant (top 50 by spend) + // 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 } @@ -72,18 +85,13 @@ export async function GET(req: NextRequest) { 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( - CASE - WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 - ELSE COALESCE(t.amount_aud, t.amount) - END - )::numeric(10,2) as total + 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') + 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') @@ -92,7 +100,6 @@ export async function GET(req: NextRequest) { `, [user.id, fromDate, topMerchants]); } - // Group trend data by merchant const trendByMerchant: Record> = {}; for (const tr of trendRows) { if (!trendByMerchant[tr.merchant]) trendByMerchant[tr.merchant] = {}; @@ -101,9 +108,12 @@ export async function GET(req: NextRequest) { const merchants = rows.map((r) => ({ ...r, - transaction_count: Number(r.transaction_count), - total_spend: Number(r.total_spend), - avg_transaction: Number(r.avg_transaction), + 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] || {}, })); diff --git a/src/app/merchants/page.tsx b/src/app/merchants/page.tsx index f9d70d8..ecd19e7 100644 --- a/src/app/merchants/page.tsx +++ b/src/app/merchants/page.tsx @@ -77,9 +77,12 @@ function ScatterTooltip({

{d.merchant}

{formatCategory(d.category)}

-

{fmt(d.total_spend)} total

-

{d.transaction_count}× transactions

-

{fmtExact(d.avg_transaction)} avg

+

{fmt(d.net_spend)} net

+ {d.refund_count > 0 && ( +

{d.refund_count} refund(s) −{fmt(d.total_refunds)}

+ )} +

{d.debit_count}× transactions

+

{fmtExact(d.avg_debit)} avg

); @@ -156,18 +159,30 @@ function MerchantProfile({ {/* Stats */}
-

Total Spent

-

{fmt(merchant.total_spend)}

+

Net Spent

+

{fmt(merchant.net_spend)}

-

Transactions

-

{merchant.transaction_count}

+

Avg per Visit

+

{fmtExact(merchant.avg_debit)}

-

Average

-

{fmtExact(merchant.avg_transaction)}

+

Visits

+

{merchant.debit_count}

+ {/* Refund callout — only show if there are refunds */} + {merchant.refund_count > 0 && ( +
+ + {merchant.refund_count} refund{merchant.refund_count > 1 ? "s" : ""} + + + gross {fmt(merchant.gross_spend)} − refunds + −{fmt(merchant.total_refunds)} + +
+ )} {/* Trend chart */} {trendData.length > 1 && ( @@ -250,12 +265,12 @@ export default function MerchantsPage() { // Stats for median lines const { medianX, medianY } = useMemo(() => { if (merchants.length === 0) return { medianX: 0, medianY: 0 }; - const counts = [...merchants].sort((a, b) => a.transaction_count - b.transaction_count); - const spends = [...merchants].sort((a, b) => a.total_spend - b.total_spend); + const counts = [...merchants].sort((a, b) => a.debit_count - b.debit_count); + const spends = [...merchants].sort((a, b) => a.net_spend - b.net_spend); const mid = Math.floor(merchants.length / 2); return { - medianX: counts[mid]?.transaction_count ?? 0, - medianY: spends[mid]?.total_spend ?? 0, + medianX: counts[mid]?.debit_count ?? 0, + medianY: spends[mid]?.net_spend ?? 0, }; }, [merchants]); @@ -307,7 +322,7 @@ export default function MerchantsPage() { - {fmt(m.total_spend)} + {fmt(m.net_spend)} + {m.refund_count > 0 && ( + ↩{m.refund_count} + )} - {m.transaction_count} + {m.debit_count} - {fmtExact(m.avg_transaction)} + {fmtExact(m.avg_debit)} {fmtDate(m.last_seen)} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index cddcb78..38f3e9d 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -562,9 +562,12 @@ export function useFees() { export interface MerchantRow { merchant: string; category: string; - transaction_count: number; - total_spend: number; - avg_transaction: number; + 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;