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:
+37
-19
@@ -77,9 +77,12 @@ function ScatterTooltip({
|
||||
<p className="font-semibold text-white truncate">{d.merchant}</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">
|
||||
<p>{fmt(d.total_spend)} total</p>
|
||||
<p>{d.transaction_count}× transactions</p>
|
||||
<p>{fmtExact(d.avg_transaction)} avg</p>
|
||||
<p>{fmt(d.net_spend)} net</p>
|
||||
{d.refund_count > 0 && (
|
||||
<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>
|
||||
);
|
||||
@@ -156,18 +159,30 @@ function MerchantProfile({
|
||||
{/* Stats */}
|
||||
<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">
|
||||
<p className="text-xs text-zinc-400 mb-1">Total Spent</p>
|
||||
<p className="text-white font-semibold">{fmt(merchant.total_spend)}</p>
|
||||
<p className="text-xs text-zinc-400 mb-1">Net Spent</p>
|
||||
<p className="text-white font-semibold">{fmt(merchant.net_spend)}</p>
|
||||
</div>
|
||||
<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-white font-semibold">{merchant.transaction_count}</p>
|
||||
<p className="text-xs text-zinc-400 mb-1">Avg per Visit</p>
|
||||
<p className="text-white font-semibold">{fmtExact(merchant.avg_debit)}</p>
|
||||
</div>
|
||||
<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-white font-semibold">{fmtExact(merchant.avg_transaction)}</p>
|
||||
<p className="text-xs text-zinc-400 mb-1">Visits</p>
|
||||
<p className="text-white font-semibold">{merchant.debit_count}</p>
|
||||
</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 */}
|
||||
{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() {
|
||||
<ScatterChart margin={{ top: 10, right: 20, bottom: 20, left: 10 }}>
|
||||
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="transaction_count"
|
||||
dataKey="debit_count"
|
||||
name="Transactions"
|
||||
type="number"
|
||||
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 }}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="total_spend"
|
||||
name="Total Spend"
|
||||
dataKey="net_spend"
|
||||
name="Net Spend"
|
||||
type="number"
|
||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||
axisLine={false}
|
||||
@@ -404,11 +419,14 @@ export default function MerchantsPage() {
|
||||
</span>
|
||||
</td>
|
||||
<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 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">
|
||||
{fmtExact(m.avg_transaction)}
|
||||
{fmtExact(m.avg_debit)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-zinc-500 text-xs hidden sm:table-cell">
|
||||
{fmtDate(m.last_seen)}
|
||||
|
||||
Reference in New Issue
Block a user