fix(merchants): net spend accounting for refunds/credits

- Merchant totals now show net spend (gross debits minus refunds)
- Refund count and amount shown in profile drawer and table
- Scatter plot Y-axis uses net_spend, X-axis uses debit_count
- Per-merchant transaction history includes refunds (shown as negative)
- Monthly trend chart reflects net spend per month
This commit is contained in:
2026-03-10 00:43:58 +11:00
parent dd11019fdf
commit 7b3fd4b65f
4 changed files with 93 additions and 60 deletions
@@ -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
+45 -35
View File
@@ -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<string, Record<string, number>> = {};
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] || {},
}));