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:
@@ -31,8 +31,10 @@ export async function GET(
|
|||||||
t.amount,
|
t.amount,
|
||||||
t.amount_aud,
|
t.amount_aud,
|
||||||
CASE
|
CASE
|
||||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
WHEN t.transaction_type IN ('refund', 'credit') THEN
|
||||||
ELSE COALESCE(t.amount_aud, t.amount)
|
-(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,
|
END::numeric(10,2) as my_amount,
|
||||||
t.transaction_type,
|
t.transaction_type,
|
||||||
COALESCE(o.category_override, t.category) as category,
|
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_overrides o ON o.transaction_id = t.id
|
||||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||||
WHERE s.owner_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
|
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2
|
||||||
ORDER BY t.transaction_date DESC
|
ORDER BY t.transaction_date DESC
|
||||||
LIMIT 500
|
LIMIT 500
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { queryRaw } from "@/lib/db";
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
const user = await getCurrentUser(req);
|
const user = await getCurrentUser(req);
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
@@ -13,13 +23,16 @@ export async function GET(req: NextRequest) {
|
|||||||
cutoff.setMonth(cutoff.getMonth() - months);
|
cutoff.setMonth(cutoff.getMonth() - months);
|
||||||
const fromDate = cutoff.toISOString().slice(0, 10);
|
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<{
|
const rows = await queryRaw<{
|
||||||
merchant: string;
|
merchant: string;
|
||||||
category: string;
|
category: string;
|
||||||
transaction_count: number;
|
debit_count: number;
|
||||||
total_spend: number;
|
refund_count: number;
|
||||||
avg_transaction: number;
|
gross_spend: number;
|
||||||
|
total_refunds: number;
|
||||||
|
net_spend: number;
|
||||||
|
avg_debit: number;
|
||||||
first_seen: string;
|
first_seen: string;
|
||||||
last_seen: string;
|
last_seen: string;
|
||||||
months_active: number;
|
months_active: number;
|
||||||
@@ -27,19 +40,24 @@ export async function GET(req: NextRequest) {
|
|||||||
SELECT
|
SELECT
|
||||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) as merchant,
|
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,
|
MODE() WITHIN GROUP (ORDER BY COALESCE(o.category_override, t.category)) as category,
|
||||||
COUNT(*)::int as transaction_count,
|
COUNT(*) FILTER (WHERE t.transaction_type IN ('debit', 'fee', 'interest'))::int as debit_count,
|
||||||
SUM(
|
COUNT(*) FILTER (WHERE t.transaction_type IN ('refund', 'credit'))::int as refund_count,
|
||||||
CASE
|
COALESCE(SUM(
|
||||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN
|
||||||
ELSE COALESCE(t.amount_aud, t.amount)
|
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
|
ELSE 0 END
|
||||||
)::numeric(12,2) as total_spend,
|
), 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(
|
AVG(
|
||||||
CASE
|
CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN
|
||||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
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 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,
|
MIN(t.transaction_date)::text as first_seen,
|
||||||
MAX(t.transaction_date)::text as last_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
|
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_overrides o ON o.transaction_id = t.id
|
||||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||||
WHERE s.owner_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 t.transaction_date >= $2
|
||||||
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
|
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
|
||||||
GROUP BY 1
|
GROUP BY 1
|
||||||
HAVING SUM(
|
HAVING SUM(${SPEND_EXPR}) > 0
|
||||||
CASE
|
ORDER BY net_spend DESC
|
||||||
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
|
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
`, [user.id, fromDate]);
|
`, [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);
|
const topMerchants = rows.slice(0, 50).map((r) => r.merchant);
|
||||||
|
|
||||||
interface TrendRow { merchant: string; month: string; total: number }
|
interface TrendRow { merchant: string; month: string; total: number }
|
||||||
@@ -72,18 +85,13 @@ export async function GET(req: NextRequest) {
|
|||||||
SELECT
|
SELECT
|
||||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) as merchant,
|
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,
|
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||||
SUM(
|
SUM(${SPEND_EXPR})::numeric(10,2) as total
|
||||||
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
|
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
JOIN statements s ON s.id = t.statement_id
|
JOIN statements s ON s.id = t.statement_id
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.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
|
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||||
WHERE s.owner_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 t.transaction_date >= $2
|
||||||
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3)
|
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')
|
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]);
|
`, [user.id, fromDate, topMerchants]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group trend data by merchant
|
|
||||||
const trendByMerchant: Record<string, Record<string, number>> = {};
|
const trendByMerchant: Record<string, Record<string, number>> = {};
|
||||||
for (const tr of trendRows) {
|
for (const tr of trendRows) {
|
||||||
if (!trendByMerchant[tr.merchant]) trendByMerchant[tr.merchant] = {};
|
if (!trendByMerchant[tr.merchant]) trendByMerchant[tr.merchant] = {};
|
||||||
@@ -101,9 +108,12 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const merchants = rows.map((r) => ({
|
const merchants = rows.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
transaction_count: Number(r.transaction_count),
|
debit_count: Number(r.debit_count),
|
||||||
total_spend: Number(r.total_spend),
|
refund_count: Number(r.refund_count),
|
||||||
avg_transaction: Number(r.avg_transaction),
|
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),
|
months_active: Number(r.months_active),
|
||||||
monthly_trend: trendByMerchant[r.merchant] || {},
|
monthly_trend: trendByMerchant[r.merchant] || {},
|
||||||
}));
|
}));
|
||||||
|
|||||||
+37
-19
@@ -77,9 +77,12 @@ function ScatterTooltip({
|
|||||||
<p className="font-semibold text-white truncate">{d.merchant}</p>
|
<p className="font-semibold text-white truncate">{d.merchant}</p>
|
||||||
<p className="text-zinc-400 text-xs mt-0.5">{formatCategory(d.category)}</p>
|
<p className="text-zinc-400 text-xs mt-0.5">{formatCategory(d.category)}</p>
|
||||||
<div className="mt-2 space-y-1 text-zinc-300">
|
<div className="mt-2 space-y-1 text-zinc-300">
|
||||||
<p>{fmt(d.total_spend)} total</p>
|
<p>{fmt(d.net_spend)} net</p>
|
||||||
<p>{d.transaction_count}× transactions</p>
|
{d.refund_count > 0 && (
|
||||||
<p>{fmtExact(d.avg_transaction)} avg</p>
|
<p className="text-emerald-400 text-xs">{d.refund_count} refund(s) −{fmt(d.total_refunds)}</p>
|
||||||
|
)}
|
||||||
|
<p>{d.debit_count}× transactions</p>
|
||||||
|
<p>{fmtExact(d.avg_debit)} avg</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -156,18 +159,30 @@ function MerchantProfile({
|
|||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-3 gap-3 p-5 border-b border-zinc-800">
|
<div className="grid grid-cols-3 gap-3 p-5 border-b border-zinc-800">
|
||||||
<div className="bg-zinc-800 rounded-lg p-3 text-center">
|
<div className="bg-zinc-800 rounded-lg p-3 text-center">
|
||||||
<p className="text-xs text-zinc-400 mb-1">Total Spent</p>
|
<p className="text-xs text-zinc-400 mb-1">Net Spent</p>
|
||||||
<p className="text-white font-semibold">{fmt(merchant.total_spend)}</p>
|
<p className="text-white font-semibold">{fmt(merchant.net_spend)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-800 rounded-lg p-3 text-center">
|
<div className="bg-zinc-800 rounded-lg p-3 text-center">
|
||||||
<p className="text-xs text-zinc-400 mb-1">Transactions</p>
|
<p className="text-xs text-zinc-400 mb-1">Avg per Visit</p>
|
||||||
<p className="text-white font-semibold">{merchant.transaction_count}</p>
|
<p className="text-white font-semibold">{fmtExact(merchant.avg_debit)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-800 rounded-lg p-3 text-center">
|
<div className="bg-zinc-800 rounded-lg p-3 text-center">
|
||||||
<p className="text-xs text-zinc-400 mb-1">Average</p>
|
<p className="text-xs text-zinc-400 mb-1">Visits</p>
|
||||||
<p className="text-white font-semibold">{fmtExact(merchant.avg_transaction)}</p>
|
<p className="text-white font-semibold">{merchant.debit_count}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Refund callout — only show if there are refunds */}
|
||||||
|
{merchant.refund_count > 0 && (
|
||||||
|
<div className="mx-5 mt-3 px-3 py-2 rounded-lg bg-emerald-900/20 border border-emerald-800/40 flex items-center justify-between text-sm">
|
||||||
|
<span className="text-emerald-400">
|
||||||
|
{merchant.refund_count} refund{merchant.refund_count > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-300">
|
||||||
|
<span className="text-zinc-500 mr-2">gross {fmt(merchant.gross_spend)} − refunds</span>
|
||||||
|
<span className="text-emerald-400 font-medium">−{fmt(merchant.total_refunds)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Trend chart */}
|
{/* Trend chart */}
|
||||||
{trendData.length > 1 && (
|
{trendData.length > 1 && (
|
||||||
@@ -250,12 +265,12 @@ export default function MerchantsPage() {
|
|||||||
// Stats for median lines
|
// Stats for median lines
|
||||||
const { medianX, medianY } = useMemo(() => {
|
const { medianX, medianY } = useMemo(() => {
|
||||||
if (merchants.length === 0) return { medianX: 0, medianY: 0 };
|
if (merchants.length === 0) return { medianX: 0, medianY: 0 };
|
||||||
const counts = [...merchants].sort((a, b) => a.transaction_count - b.transaction_count);
|
const counts = [...merchants].sort((a, b) => a.debit_count - b.debit_count);
|
||||||
const spends = [...merchants].sort((a, b) => a.total_spend - b.total_spend);
|
const spends = [...merchants].sort((a, b) => a.net_spend - b.net_spend);
|
||||||
const mid = Math.floor(merchants.length / 2);
|
const mid = Math.floor(merchants.length / 2);
|
||||||
return {
|
return {
|
||||||
medianX: counts[mid]?.transaction_count ?? 0,
|
medianX: counts[mid]?.debit_count ?? 0,
|
||||||
medianY: spends[mid]?.total_spend ?? 0,
|
medianY: spends[mid]?.net_spend ?? 0,
|
||||||
};
|
};
|
||||||
}, [merchants]);
|
}, [merchants]);
|
||||||
|
|
||||||
@@ -307,7 +322,7 @@ export default function MerchantsPage() {
|
|||||||
<ScatterChart margin={{ top: 10, right: 20, bottom: 20, left: 10 }}>
|
<ScatterChart margin={{ top: 10, right: 20, bottom: 20, left: 10 }}>
|
||||||
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="transaction_count"
|
dataKey="debit_count"
|
||||||
name="Transactions"
|
name="Transactions"
|
||||||
type="number"
|
type="number"
|
||||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||||
@@ -316,8 +331,8 @@ export default function MerchantsPage() {
|
|||||||
label={{ value: "Transaction Count", position: "insideBottom", offset: -10, fill: "#52525b", fontSize: 11 }}
|
label={{ value: "Transaction Count", position: "insideBottom", offset: -10, fill: "#52525b", fontSize: 11 }}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
dataKey="total_spend"
|
dataKey="net_spend"
|
||||||
name="Total Spend"
|
name="Net Spend"
|
||||||
type="number"
|
type="number"
|
||||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
@@ -404,11 +419,14 @@ export default function MerchantsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-right text-white font-medium">
|
<td className="px-4 py-2.5 text-right text-white font-medium">
|
||||||
{fmt(m.total_spend)}
|
{fmt(m.net_spend)}
|
||||||
|
{m.refund_count > 0 && (
|
||||||
|
<span className="ml-1.5 text-emerald-400 text-xs">↩{m.refund_count}</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-right text-zinc-400">{m.transaction_count}</td>
|
<td className="px-4 py-2.5 text-right text-zinc-400">{m.debit_count}</td>
|
||||||
<td className="px-4 py-2.5 text-right text-zinc-400">
|
<td className="px-4 py-2.5 text-right text-zinc-400">
|
||||||
{fmtExact(m.avg_transaction)}
|
{fmtExact(m.avg_debit)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-right text-zinc-500 text-xs hidden sm:table-cell">
|
<td className="px-4 py-2.5 text-right text-zinc-500 text-xs hidden sm:table-cell">
|
||||||
{fmtDate(m.last_seen)}
|
{fmtDate(m.last_seen)}
|
||||||
|
|||||||
+6
-3
@@ -562,9 +562,12 @@ export function useFees() {
|
|||||||
export interface MerchantRow {
|
export interface MerchantRow {
|
||||||
merchant: string;
|
merchant: string;
|
||||||
category: string;
|
category: string;
|
||||||
transaction_count: number;
|
debit_count: number;
|
||||||
total_spend: number;
|
refund_count: number;
|
||||||
avg_transaction: number;
|
gross_spend: number;
|
||||||
|
total_refunds: number;
|
||||||
|
net_spend: number;
|
||||||
|
avg_debit: number;
|
||||||
first_seen: string;
|
first_seen: string;
|
||||||
last_seen: string;
|
last_seen: string;
|
||||||
months_active: number;
|
months_active: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user