Skip to content

Commit 9646d1a

Browse files
fix(payments): group payouts-owed by currency instead of summing across them (#497) (#505)
transactions.currency is a real, populated currency_type enum column, but computeOwedBalances() dropped it and summed raw amount across currencies — a tenant with both USD and EUR PayPal sales got one meaningless combined total. markPayoutPaid() then hardcoded currency: 'usd' on every inserted payouts row regardless of what was actually collected. TenantOwed.balances is now an array of per-currency CurrencyBalance entries (grossCollected/grossOwed/alreadyPaid/netOwed/byProvider), never summed together. The payouts page renders one table row per (tenant, currency) pair and shows metric cards as one figure per currency (e.g. "$80.00 · €40.00"), and markPayoutPaid takes an explicit currency argument threaded from the row being paid instead of assuming USD. Live-verified locally: seeded a $100 USD PayPal sale and a €50 EUR Lemon Squeezy sale for the same tenant, confirmed the payouts page showed two separate rows with correct 80% splits, recorded a EUR payout, and confirmed the inserted payouts row has currency='eur' (not 'usd') while the USD row's owed amount was unaffected. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent a28e2bf commit 9646d1a

5 files changed

Lines changed: 234 additions & 126 deletions

File tree

app/[locale]/platform/payouts/page.tsx

Lines changed: 71 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,15 @@ import {
77
IconWalletOff,
88
} from '@tabler/icons-react'
99

10-
const usd = (n: number) =>
11-
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n ?? 0)
10+
const money = (n: number, currency: string) =>
11+
new Intl.NumberFormat('en-US', { style: 'currency', currency: currency.toUpperCase() }).format(n ?? 0)
12+
13+
/** Renders one line per currency — amounts in different currencies are never summed into one number (#497). */
14+
function formatByCurrency(byCurrency: Record<string, number>) {
15+
const entries = Object.entries(byCurrency).filter(([, amount]) => amount !== 0)
16+
if (entries.length === 0) return money(0, 'usd')
17+
return entries.map(([currency, amount]) => money(amount, currency)).join(' · ')
18+
}
1219

1320
const PROVIDER_LABEL: Record<string, string> = {
1421
paypal: 'PayPal',
@@ -19,31 +26,67 @@ const PROVIDER_LABEL: Record<string, string> = {
1926
export default async function PlatformPayoutsPage() {
2027
const owed = await getPayoutsOwed()
2128

22-
const totalOwed = owed.reduce((sum, t) => sum + t.netOwed, 0)
23-
const totalCollected = owed.reduce((sum, t) => sum + t.grossCollected, 0)
24-
const totalPaidOut = owed.reduce((sum, t) => sum + t.alreadyPaid, 0)
25-
const schoolsOwed = owed.filter((t) => t.netOwed > 0).length
29+
// Per-currency totals across all tenants — kept separate, never summed together.
30+
const totalOwedByCurrency: Record<string, number> = {}
31+
const totalCollectedByCurrency: Record<string, number> = {}
32+
const totalPaidOutByCurrency: Record<string, number> = {}
33+
let schoolsOwed = 0
34+
35+
// One row per (tenant, currency) balance — a tenant with both USD and EUR
36+
// sales gets two rows, not one summed row.
37+
type Row = {
38+
tenantId: string
39+
tenantName: string
40+
schoolPercentage: number
41+
currency: string
42+
grossCollected: number
43+
alreadyPaid: number
44+
netOwed: number
45+
byProvider: Record<string, number>
46+
}
47+
const rows: Row[] = []
48+
49+
for (const tenant of owed) {
50+
let tenantHasOwed = false
51+
for (const balance of tenant.balances) {
52+
totalOwedByCurrency[balance.currency] = (totalOwedByCurrency[balance.currency] ?? 0) + balance.netOwed
53+
totalCollectedByCurrency[balance.currency] = (totalCollectedByCurrency[balance.currency] ?? 0) + balance.grossCollected
54+
totalPaidOutByCurrency[balance.currency] = (totalPaidOutByCurrency[balance.currency] ?? 0) + balance.alreadyPaid
55+
if (balance.netOwed > 0) tenantHasOwed = true
56+
rows.push({
57+
tenantId: tenant.tenantId,
58+
tenantName: tenant.tenantName,
59+
schoolPercentage: tenant.schoolPercentage,
60+
currency: balance.currency,
61+
grossCollected: balance.grossCollected,
62+
alreadyPaid: balance.alreadyPaid,
63+
netOwed: balance.netOwed,
64+
byProvider: balance.byProvider,
65+
})
66+
}
67+
if (tenantHasOwed) schoolsOwed++
68+
}
2669

2770
const metricCards = [
2871
{
2972
title: 'Currently Owed',
30-
value: usd(totalOwed),
73+
value: formatByCurrency(totalOwedByCurrency),
3174
sub: `${schoolsOwed} school${schoolsOwed === 1 ? '' : 's'} awaiting payout`,
3275
icon: IconWalletOff,
3376
bg: 'bg-amber-50 dark:bg-amber-950/40',
3477
iconColor: 'text-amber-600 dark:text-amber-400',
3578
},
3679
{
3780
title: 'Collected (single-account providers)',
38-
value: usd(totalCollected),
81+
value: formatByCurrency(totalCollectedByCurrency),
3982
sub: 'PayPal, Binance Pay, Lemon Squeezy — 100% lands in your account',
4083
icon: IconCoin,
4184
bg: 'bg-blue-50 dark:bg-blue-950/40',
4285
iconColor: 'text-blue-600 dark:text-blue-400',
4386
},
4487
{
4588
title: 'Paid Out (all time)',
46-
value: usd(totalPaidOut),
89+
value: formatByCurrency(totalPaidOutByCurrency),
4790
sub: 'Manually recorded payouts to schools',
4891
icon: IconReportMoney,
4992
bg: 'bg-emerald-50 dark:bg-emerald-950/40',
@@ -89,14 +132,15 @@ export default async function PlatformPayoutsPage() {
89132
<CardTitle>By school</CardTitle>
90133
</CardHeader>
91134
<CardContent>
92-
{owed.length === 0 ? (
93-
<p className="text-muted-foreground text-sm">No schools yet.</p>
135+
{rows.length === 0 ? (
136+
<p className="text-muted-foreground text-sm">No platform-settled sales yet.</p>
94137
) : (
95138
<div className="overflow-x-auto">
96139
<table className="w-full text-sm">
97140
<thead>
98141
<tr className="border-b text-left text-[11px] uppercase tracking-wider text-muted-foreground">
99142
<th className="pb-2 font-medium">School</th>
143+
<th className="pb-2 font-medium">Currency</th>
100144
<th className="pb-2 font-medium">Providers</th>
101145
<th className="pb-2 text-right font-medium">Collected</th>
102146
<th className="pb-2 text-right font-medium">School %</th>
@@ -106,29 +150,31 @@ export default async function PlatformPayoutsPage() {
106150
</tr>
107151
</thead>
108152
<tbody>
109-
{owed.map((t) => (
110-
<tr key={t.tenantId} className="border-b last:border-0">
111-
<td className="py-2.5 font-medium">{t.tenantName}</td>
153+
{rows.map((r) => (
154+
<tr key={`${r.tenantId}-${r.currency}`} className="border-b last:border-0">
155+
<td className="py-2.5 font-medium">{r.tenantName}</td>
156+
<td className="py-2.5 uppercase text-muted-foreground">{r.currency}</td>
112157
<td className="py-2.5 text-muted-foreground">
113-
{Object.keys(t.byProvider).length === 0
158+
{Object.keys(r.byProvider).length === 0
114159
? '—'
115-
: Object.keys(t.byProvider).map((p) => PROVIDER_LABEL[p] ?? p).join(', ')}
160+
: Object.keys(r.byProvider).map((p) => PROVIDER_LABEL[p] ?? p).join(', ')}
116161
</td>
117-
<td className="py-2.5 text-right tabular-nums">{usd(t.grossCollected)}</td>
162+
<td className="py-2.5 text-right tabular-nums">{money(r.grossCollected, r.currency)}</td>
118163
<td className="py-2.5 text-right tabular-nums text-muted-foreground">
119-
{t.schoolPercentage}%
164+
{r.schoolPercentage}%
120165
</td>
121166
<td className="py-2.5 text-right tabular-nums text-muted-foreground">
122-
{usd(t.alreadyPaid)}
167+
{money(r.alreadyPaid, r.currency)}
123168
</td>
124169
<td className="py-2.5 text-right tabular-nums font-medium text-amber-600 dark:text-amber-400">
125-
{usd(t.netOwed)}
170+
{money(r.netOwed, r.currency)}
126171
</td>
127172
<td className="py-2.5 text-right">
128173
<MarkPayoutPaidDialog
129-
tenantId={t.tenantId}
130-
tenantName={t.tenantName}
131-
netOwed={t.netOwed}
174+
tenantId={r.tenantId}
175+
tenantName={r.tenantName}
176+
netOwed={r.netOwed}
177+
currency={r.currency}
132178
/>
133179
</td>
134180
</tr>
@@ -144,7 +190,8 @@ export default async function PlatformPayoutsPage() {
144190
Stripe and Solana sales already split automatically and never appear here. Binance Pay
145191
(personal account) and manual/offline sales settle straight to the school and also never
146192
appear here — only PayPal, Binance Pay (merchant), and Lemon Squeezy do, since those settle
147-
100% into your account today.
193+
100% into your account today. Amounts in different currencies are shown and paid out
194+
separately — they&apos;re never added together into one number.
148195
</p>
149196
</main>
150197
)

app/actions/platform/payouts.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ export async function getPayoutsOwed(): Promise<TenantOwed[]> {
2929
admin.from('revenue_splits').select('tenant_id, school_percentage'),
3030
admin
3131
.from('transactions')
32-
.select('tenant_id, payment_provider, amount, school_percentage_snapshot')
32+
.select('tenant_id, payment_provider, amount, currency, school_percentage_snapshot')
3333
.eq('status', 'successful')
3434
.in('payment_provider', PLATFORM_SETTLED_PROVIDERS),
35-
admin.from('payouts').select('tenant_id, amount').eq('payout_method', 'manual').eq('status', 'paid'),
35+
admin.from('payouts').select('tenant_id, amount, currency').eq('payout_method', 'manual').eq('status', 'paid'),
3636
])
3737

3838
const schoolPercentageByTenant = new Map(
@@ -51,21 +51,22 @@ export async function getPayoutsOwed(): Promise<TenantOwed[]> {
5151
tenantId: t.tenant_id as string,
5252
paymentProvider: t.payment_provider as string,
5353
amount: t.amount as number,
54+
currency: t.currency || 'usd',
5455
schoolPercentageSnapshot: t.school_percentage_snapshot as number | null,
5556
})),
56-
(paid || []).map((p) => ({ tenantId: p.tenant_id, amount: p.amount }))
57+
(paid || []).map((p) => ({ tenantId: p.tenant_id, amount: p.amount, currency: p.currency || 'usd' }))
5758
)
5859
}
5960

60-
export async function markPayoutPaid(tenantId: string, amount: number, note?: string) {
61+
export async function markPayoutPaid(tenantId: string, amount: number, currency: string, note?: string) {
6162
const userId = await verifySuperAdmin()
6263
if (!(amount > 0)) throw new Error('Amount must be positive')
6364

6465
const admin = createAdminClient()
6566
const { error } = await admin.from('payouts').insert({
6667
tenant_id: tenantId,
6768
amount,
68-
currency: 'usd',
69+
currency,
6970
status: 'paid',
7071
payout_method: 'manual',
7172
paid_at: new Date().toISOString(),

components/platform/mark-payout-paid-dialog.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ interface Props {
2020
tenantId: string
2121
tenantName: string
2222
netOwed: number
23+
currency: string
2324
}
2425

25-
export function MarkPayoutPaidDialog({ tenantId, tenantName, netOwed }: Props) {
26+
export function MarkPayoutPaidDialog({ tenantId, tenantName, netOwed, currency }: Props) {
2627
const router = useRouter()
2728
const [open, setOpen] = useState(false)
2829
const [amount, setAmount] = useState(netOwed.toFixed(2))
@@ -37,8 +38,8 @@ export function MarkPayoutPaidDialog({ tenantId, tenantName, netOwed }: Props) {
3738
}
3839
setLoading(true)
3940
try {
40-
await markPayoutPaid(tenantId, parsed, note.trim() || undefined)
41-
toast.success(`Recorded $${parsed.toFixed(2)} paid to ${tenantName}`)
41+
await markPayoutPaid(tenantId, parsed, currency, note.trim() || undefined)
42+
toast.success(`Recorded ${parsed.toFixed(2)} ${currency.toUpperCase()} paid to ${tenantName}`)
4243
setOpen(false)
4344
setNote('')
4445
router.refresh()
@@ -68,7 +69,7 @@ export function MarkPayoutPaidDialog({ tenantId, tenantName, netOwed }: Props) {
6869
</DialogHeader>
6970
<div className="space-y-3 py-2">
7071
<div className="space-y-1.5">
71-
<Label htmlFor="payout-amount">Amount paid (USD)</Label>
72+
<Label htmlFor="payout-amount">Amount paid ({currency.toUpperCase()})</Label>
7273
<Input
7374
id="payout-amount"
7475
type="number"

lib/payments/payouts-owed.ts

Lines changed: 77 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
* snapshot column (`schoolPercentageSnapshot: null`) fall back to the
1919
* tenant's current `schoolPercentage` — the same behavior this module had
2020
* before #496, so old data isn't retroactively wrong either way.
21+
*
22+
* Balances are grouped PER CURRENCY (issue #497) — a tenant with both USD and
23+
* EUR sales owes two separate numbers, never one meaningless summed total.
24+
* `transactions.currency` and `payouts.currency` are trusted as given; this
25+
* module does no currency conversion, only grouping.
2126
*/
2227

2328
/** Used when a tenant has no `revenue_splits` row yet (shouldn't normally happen, but keeps callers from dividing by an absent value). */
@@ -33,73 +38,114 @@ export interface TenantOwedInput {
3338
export interface PlatformSettledTxn {
3439
tenantId: string
3540
paymentProvider: string
36-
/** Successful transaction amount, major units. */
41+
/** Successful transaction amount, major units, in `currency`. */
3742
amount: number
43+
/** transactions.currency (e.g. 'usd', 'eur'). */
44+
currency: string
3845
/** revenue_splits.school_percentage in effect when this transaction was created (0–100), or null for pre-#496 rows. */
3946
schoolPercentageSnapshot: number | null
4047
}
4148

4249
export interface ManualPayoutRecord {
4350
tenantId: string
4451
amount: number
52+
/** payouts.currency — the currency this payout was actually recorded in. */
53+
currency: string
4554
}
4655

47-
export interface TenantOwed {
48-
tenantId: string
49-
tenantName: string
50-
schoolPercentage: number
51-
/** Sum of platform-settled transaction amounts (100% of what was collected). */
56+
export interface CurrencyBalance {
57+
currency: string
58+
/** Sum of platform-settled transaction amounts in this currency (100% of what was collected). */
5259
grossCollected: number
53-
/** Sum over transactions of amount × (that transaction's snapshotted split, or the tenant's current split if unsnapshotted) — the school's all-time share. */
60+
/** Sum over this currency's transactions of amount × (that transaction's snapshotted split, or the tenant's current split if unsnapshotted). */
5461
grossOwed: number
55-
/** Sum of manual payouts already recorded as paid for this tenant. */
62+
/** Sum of manual payouts already recorded as paid in this currency. */
5663
alreadyPaid: number
57-
/** max(grossOwed - alreadyPaid, 0) — what's currently owed. */
64+
/** max(grossOwed - alreadyPaid, 0) — what's currently owed in this currency. */
5865
netOwed: number
59-
/** Per-provider breakdown of grossCollected. */
66+
/** Per-provider breakdown of grossCollected in this currency. */
6067
byProvider: Record<string, number>
6168
}
6269

70+
export interface TenantOwed {
71+
tenantId: string
72+
tenantName: string
73+
schoolPercentage: number
74+
/** One entry per currency this tenant has platform-settled activity or payouts in. Never summed across currencies. */
75+
balances: CurrencyBalance[]
76+
}
77+
6378
export function computeOwedBalances(
6479
tenants: TenantOwedInput[],
6580
txns: PlatformSettledTxn[],
6681
paidPayouts: ManualPayoutRecord[],
6782
): TenantOwed[] {
6883
const schoolPercentageByTenant = new Map(tenants.map((t) => [t.tenantId, t.schoolPercentage]))
6984

70-
const collectedByTenant = new Map<string, number>()
71-
const owedByTenant = new Map<string, number>()
72-
const byProviderByTenant = new Map<string, Record<string, number>>()
73-
for (const txn of txns) {
74-
collectedByTenant.set(txn.tenantId, (collectedByTenant.get(txn.tenantId) ?? 0) + txn.amount)
85+
// tenantId -> currency -> partial balance
86+
const byTenantCurrency = new Map<string, Map<string, { grossCollected: number; grossOwed: number; byProvider: Record<string, number> }>>()
7587

76-
const effectivePercentage = txn.schoolPercentageSnapshot ?? schoolPercentageByTenant.get(txn.tenantId) ?? 0
77-
owedByTenant.set(txn.tenantId, (owedByTenant.get(txn.tenantId) ?? 0) + (txn.amount * effectivePercentage) / 100)
88+
function bucket(tenantId: string, currency: string) {
89+
let byCurrency = byTenantCurrency.get(tenantId)
90+
if (!byCurrency) {
91+
byCurrency = new Map()
92+
byTenantCurrency.set(tenantId, byCurrency)
93+
}
94+
let entry = byCurrency.get(currency)
95+
if (!entry) {
96+
entry = { grossCollected: 0, grossOwed: 0, byProvider: {} }
97+
byCurrency.set(currency, entry)
98+
}
99+
return entry
100+
}
78101

79-
const byProvider = byProviderByTenant.get(txn.tenantId) ?? {}
80-
byProvider[txn.paymentProvider] = (byProvider[txn.paymentProvider] ?? 0) + txn.amount
81-
byProviderByTenant.set(txn.tenantId, byProvider)
102+
for (const txn of txns) {
103+
const entry = bucket(txn.tenantId, txn.currency)
104+
entry.grossCollected += txn.amount
105+
const effectivePercentage = txn.schoolPercentageSnapshot ?? schoolPercentageByTenant.get(txn.tenantId) ?? 0
106+
entry.grossOwed += (txn.amount * effectivePercentage) / 100
107+
entry.byProvider[txn.paymentProvider] = (entry.byProvider[txn.paymentProvider] ?? 0) + txn.amount
82108
}
83109

84-
const paidByTenant = new Map<string, number>()
110+
const paidByTenantCurrency = new Map<string, Map<string, number>>()
85111
for (const payout of paidPayouts) {
86-
paidByTenant.set(payout.tenantId, (paidByTenant.get(payout.tenantId) ?? 0) + payout.amount)
112+
let byCurrency = paidByTenantCurrency.get(payout.tenantId)
113+
if (!byCurrency) {
114+
byCurrency = new Map()
115+
paidByTenantCurrency.set(payout.tenantId, byCurrency)
116+
}
117+
byCurrency.set(payout.currency, (byCurrency.get(payout.currency) ?? 0) + payout.amount)
87118
}
88119

89120
return tenants.map((tenant) => {
90-
const grossCollected = collectedByTenant.get(tenant.tenantId) ?? 0
91-
const grossOwed = owedByTenant.get(tenant.tenantId) ?? 0
92-
const alreadyPaid = paidByTenant.get(tenant.tenantId) ?? 0
93-
const netOwed = Math.max(grossOwed - alreadyPaid, 0)
121+
const collected = byTenantCurrency.get(tenant.tenantId) ?? new Map()
122+
const paid = paidByTenantCurrency.get(tenant.tenantId) ?? new Map()
123+
124+
// A currency can appear only in payouts (fully paid off, no outstanding
125+
// transactions left in this grouping) — union both key sets so it's not dropped.
126+
const currencies = new Set<string>([...collected.keys(), ...paid.keys()])
127+
128+
const balances: CurrencyBalance[] = Array.from(currencies).map((currency) => {
129+
const entry = collected.get(currency)
130+
const grossCollected = entry?.grossCollected ?? 0
131+
const grossOwed = entry?.grossOwed ?? 0
132+
const alreadyPaid = paid.get(currency) ?? 0
133+
const netOwed = Math.max(grossOwed - alreadyPaid, 0)
134+
return {
135+
currency,
136+
grossCollected,
137+
grossOwed,
138+
alreadyPaid,
139+
netOwed,
140+
byProvider: entry?.byProvider ?? {},
141+
}
142+
})
143+
94144
return {
95145
tenantId: tenant.tenantId,
96146
tenantName: tenant.tenantName,
97147
schoolPercentage: tenant.schoolPercentage,
98-
grossCollected,
99-
grossOwed,
100-
alreadyPaid,
101-
netOwed,
102-
byProvider: byProviderByTenant.get(tenant.tenantId) ?? {},
148+
balances,
103149
}
104150
})
105151
}

0 commit comments

Comments
 (0)