Files
finance-app/src/app/merchants/page.tsx
T
siddharthd 7b3fd4b65f 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
2026-03-10 00:43:58 +11:00

449 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useMemo } from "react";
import {
ScatterChart,
Scatter,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
LineChart,
Line,
CartesianGrid,
} from "recharts";
import { useMerchants, useMerchantTransactions, MerchantRow } from "@/lib/hooks";
import { formatCategory } from "@/lib/categories";
function fmt(n: number) {
return new Intl.NumberFormat("en-AU", {
style: "currency",
currency: "AUD",
maximumFractionDigits: 0,
}).format(n);
}
function fmtExact(n: number) {
return new Intl.NumberFormat("en-AU", {
style: "currency",
currency: "AUD",
minimumFractionDigits: 2,
}).format(n);
}
function fmtDate(d: string) {
return new Date(d + "T00:00:00").toLocaleDateString("en-AU", {
day: "numeric",
month: "short",
year: "numeric",
});
}
const CATEGORY_COLORS: Record<string, string> = {
groceries: "#4ade80",
dining: "#fb923c",
transport: "#60a5fa",
fuel: "#facc15",
shopping: "#f472b6",
utilities: "#a78bfa",
entertainment: "#34d399",
travel: "#38bdf8",
health: "#f87171",
insurance: "#94a3b8",
subscriptions: "#c084fc",
government: "#6b7280",
education: "#fbbf24",
rent: "#e879f9",
home_goods: "#67e8f9",
home_maintenance: "#c084fc",
personal_care: "#fb7185",
pets: "#a3e635",
gifts: "#f9a8d4",
charity: "#6ee7b7",
other: "#71717a",
};
// Custom scatter tooltip
function ScatterTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: MerchantRow }>;
}) {
if (!active || !payload?.length) return null;
const d = payload[0].payload;
return (
<div className="bg-zinc-800 border border-zinc-700 rounded-lg p-3 text-sm shadow-xl max-w-48">
<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.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>
);
}
// Quadrant labels
function QuadrantLabels({ medianX, medianY }: { medianX: number; medianY: number }) {
return (
<div className="absolute inset-0 pointer-events-none select-none">
<div className="absolute top-2 right-4 text-xs text-zinc-600 text-right">
high cost · frequent
</div>
<div className="absolute top-2 left-16 text-xs text-zinc-600">
high cost · rare
</div>
<div className="absolute bottom-8 right-4 text-xs text-zinc-600 text-right">
low cost · frequent
</div>
<div className="absolute bottom-8 left-16 text-xs text-zinc-600">
low cost · rare
</div>
</div>
);
}
// Merchant profile drawer
function MerchantProfile({
merchant,
onClose,
}: {
merchant: MerchantRow;
onClose: () => void;
}) {
const { data } = useMerchantTransactions(merchant.merchant);
const transactions = data?.transactions ?? [];
// Build trend chart data from monthly_trend
const trendData = useMemo(() => {
const months = Object.keys(merchant.monthly_trend).sort();
return months.map((m) => ({
month: m,
label: new Date(m + "-01").toLocaleDateString("en-AU", { month: "short", year: "2-digit" }),
amount: merchant.monthly_trend[m],
}));
}, [merchant.monthly_trend]);
const color = CATEGORY_COLORS[merchant.category] || "#6366f1";
return (
<div className="fixed inset-0 z-50 flex justify-end">
{/* backdrop */}
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
{/* drawer */}
<div className="relative w-full max-w-lg bg-zinc-900 border-l border-zinc-700 h-full overflow-y-auto flex flex-col shadow-2xl">
{/* Header */}
<div className="flex items-start justify-between p-5 border-b border-zinc-800">
<div className="flex-1 min-w-0 pr-4">
<h2 className="text-lg font-semibold text-white truncate">{merchant.merchant}</h2>
<span
className="inline-block mt-1 px-2 py-0.5 rounded text-xs font-medium"
style={{ background: color + "33", color }}
>
{formatCategory(merchant.category)}
</span>
</div>
<button
onClick={onClose}
className="text-zinc-400 hover:text-white text-xl leading-none mt-0.5"
>
×
</button>
</div>
{/* 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">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">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">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 && (
<div className="p-5 border-b border-zinc-800">
<p className="text-sm font-medium text-zinc-300 mb-3">Monthly Spend</p>
<ResponsiveContainer width="100%" height={120}>
<LineChart data={trendData}>
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
<XAxis
dataKey="label"
tick={{ fill: "#71717a", fontSize: 10 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: "#71717a", fontSize: 10 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `$${Math.round(v)}`}
width={45}
/>
<Tooltip
contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: "8px" }}
labelStyle={{ color: "#a1a1aa" }}
/>
<Line
type="monotone"
dataKey="amount"
stroke={color}
strokeWidth={2}
dot={{ fill: color, r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* Transaction history */}
<div className="p-5 flex-1">
<p className="text-sm font-medium text-zinc-300 mb-3">
Transactions{" "}
<span className="text-zinc-500 font-normal">({transactions.length})</span>
</p>
{transactions.length === 0 ? (
<p className="text-zinc-500 text-sm">Loading</p>
) : (
<div className="space-y-1">
{transactions.map((tx) => (
<div
key={tx.id}
className="flex items-center justify-between py-2 border-b border-zinc-800 text-sm"
>
<div className="flex-1 min-w-0 pr-3">
<p className="text-zinc-300 truncate">{tx.description}</p>
<p className="text-zinc-500 text-xs mt-0.5">
{fmtDate(tx.transaction_date)} · {tx.bank_name}
</p>
</div>
<p className="text-white font-medium whitespace-nowrap">
{fmtExact(tx.my_amount)}
</p>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
export default function MerchantsPage() {
const [months, setMonths] = useState(12);
const [selected, setSelected] = useState<MerchantRow | null>(null);
const [search, setSearch] = useState("");
const { data, isLoading } = useMerchants(months);
const merchants = data?.merchants ?? [];
// Stats for median lines
const { medianX, medianY } = useMemo(() => {
if (merchants.length === 0) return { medianX: 0, medianY: 0 };
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]?.debit_count ?? 0,
medianY: spends[mid]?.net_spend ?? 0,
};
}, [merchants]);
// Filtered merchants for the table
const filtered = useMemo(() => {
if (!search.trim()) return merchants;
const q = search.toLowerCase();
return merchants.filter((m) => m.merchant.toLowerCase().includes(q));
}, [merchants, search]);
// Top merchants for the scatter (max 150 for performance)
const scatterData = useMemo(() => merchants.slice(0, 150), [merchants]);
if (isLoading) {
return (
<div className="p-8 text-zinc-400">Loading merchant data</div>
);
}
return (
<div className="p-6 max-w-6xl space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-white">Merchants</h1>
<p className="text-zinc-400 text-sm mt-0.5">{merchants.length} merchants · last {months} months</p>
</div>
<select
value={months}
onChange={(e) => setMonths(Number(e.target.value))}
className="bg-zinc-800 border border-zinc-700 rounded-md text-sm text-zinc-200 px-3 py-1.5"
>
<option value={3}>3 months</option>
<option value={6}>6 months</option>
<option value={12}>12 months</option>
<option value={24}>24 months</option>
</select>
</div>
{/* Scatter plot */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h2 className="text-sm font-medium text-zinc-300 mb-1">Spend vs Frequency</h2>
<p className="text-xs text-zinc-500 mb-4">
Each dot = one merchant. Click to open profile.
</p>
<div className="relative">
<QuadrantLabels medianX={medianX} medianY={medianY} />
<ResponsiveContainer width="100%" height={360}>
<ScatterChart margin={{ top: 10, right: 20, bottom: 20, left: 10 }}>
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
<XAxis
dataKey="debit_count"
name="Transactions"
type="number"
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
label={{ value: "Transaction Count", position: "insideBottom", offset: -10, fill: "#52525b", fontSize: 11 }}
/>
<YAxis
dataKey="net_spend"
name="Net Spend"
type="number"
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `$${Math.round(v / 1000)}k`}
width={48}
label={{ value: "Total Spend", angle: -90, position: "insideLeft", offset: 10, fill: "#52525b", fontSize: 11 }}
/>
<Tooltip content={<ScatterTooltip />} cursor={{ strokeDasharray: "3 3", stroke: "#52525b" }} />
<Scatter
data={scatterData}
onClick={(d) => setSelected(d as unknown as MerchantRow)}
style={{ cursor: "pointer" }}
>
{scatterData.map((entry, idx) => (
<Cell
key={idx}
fill={CATEGORY_COLORS[entry.category] || "#6366f1"}
fillOpacity={0.75}
stroke={selected?.merchant === entry.merchant ? "#fff" : "transparent"}
strokeWidth={2}
/>
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
{/* Category legend */}
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1">
{Object.entries(CATEGORY_COLORS)
.filter(([cat]) => merchants.some((m) => m.category === cat))
.slice(0, 12)
.map(([cat, color]) => (
<span key={cat} className="flex items-center gap-1.5 text-xs text-zinc-400">
<span className="w-2 h-2 rounded-full inline-block" style={{ background: color }} />
{formatCategory(cat)}
</span>
))}
</div>
</div>
{/* Merchant table */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
<div className="flex items-center justify-between p-4 border-b border-zinc-800">
<h2 className="text-sm font-medium text-zinc-300">All Merchants</h2>
<input
type="text"
placeholder="Search…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="bg-zinc-800 border border-zinc-700 rounded-md text-sm text-zinc-200 placeholder-zinc-500 px-3 py-1.5 w-48"
/>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-zinc-500 text-xs border-b border-zinc-800">
<th className="text-left px-4 py-2 font-medium">Merchant</th>
<th className="text-left px-4 py-2 font-medium">Category</th>
<th className="text-right px-4 py-2 font-medium">Total</th>
<th className="text-right px-4 py-2 font-medium">Count</th>
<th className="text-right px-4 py-2 font-medium">Avg</th>
<th className="text-right px-4 py-2 font-medium hidden sm:table-cell">Last Seen</th>
</tr>
</thead>
<tbody>
{filtered.map((m) => {
const color = CATEGORY_COLORS[m.category] || "#6366f1";
return (
<tr
key={m.merchant}
onClick={() => setSelected(m)}
className="border-b border-zinc-800 hover:bg-zinc-800 cursor-pointer transition-colors"
>
<td className="px-4 py-2.5 text-zinc-200 max-w-[200px]">
<span className="truncate block">{m.merchant}</span>
</td>
<td className="px-4 py-2.5">
<span
className="px-1.5 py-0.5 rounded text-xs"
style={{ background: color + "22", color }}
>
{formatCategory(m.category)}
</span>
</td>
<td className="px-4 py-2.5 text-right text-white font-medium">
{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.debit_count}</td>
<td className="px-4 py-2.5 text-right text-zinc-400">
{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)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Merchant profile drawer */}
{selected && (
<MerchantProfile merchant={selected} onClose={() => setSelected(null)} />
)}
</div>
);
}