Skip to content

Commit 86d60fa

Browse files
fix(payouts): stop the school payout history summing across currencies (#531) (#534)
The school-facing "Total paid out" card added by #499 reduced every paid payout into one number and rendered it with a hardcoded 'USD', so a school selling in more than one currency read an invented figure — the arithmetic sum of its dollar and euro payouts, denominated in dollars — sitting above per-row amounts that were each correct in their own currency. The super-admin page had already been fixed for exactly this in #497, but its `money()` and `formatByCurrency()` helpers were private to that page module. The invariant lived in one file's local scope, so the next payout screen had nothing to inherit and re-derived the bug. Move both helpers into lib/payments/format-money.ts alongside a `sumByCurrency()` that groups rows by their own currency (normalizing the code, so 'usd' and 'USD' share a bucket) and point both pages at it. The school card now renders one figure per currency and no longer names a currency the data didn't supply; the platform page keeps its existing output, now from shared code. Covered by tests/unit/format-money.test.ts, including the two-currency case from the issue. Display only — no query, schema, or RLS change. Claude-Session: https://claude.ai/code/session_01Kgmz9Co2tW8KNfCFKLGEfN Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent cbc64e8 commit 86d60fa

4 files changed

Lines changed: 193 additions & 31 deletions

File tree

app/[locale]/dashboard/admin/payouts/page.tsx

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getTranslations } from 'next-intl/server'
44
import { format } from 'date-fns'
55
import { es, enUS } from 'date-fns/locale'
66
import { getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
7+
import { formatByCurrency, formatMoney, sumByCurrency } from '@/lib/payments/format-money'
78
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
89
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
910
import { Badge } from '@/components/ui/badge'
@@ -72,19 +73,13 @@ export default async function AdminPayoutsPage({
7273

7374
const rows = payouts || []
7475

75-
// Summary calculations
76-
const totalPaid = rows
77-
.filter((p) => p.status === 'paid')
78-
.reduce((sum, p) => sum + (p.amount || 0), 0)
76+
// Summary calculations. Paid payouts are totalled per currency and rendered as
77+
// one figure each — a school selling in USD and EUR reads two figures, not
78+
// their sum in dollars (#531, same invariant #497 fixed on the platform page).
79+
const totalPaidByCurrency = sumByCurrency(rows.filter((p) => p.status === 'paid'))
7980

8081
const pendingCount = rows.filter((p) => p.status === 'pending' || p.status === 'processing').length
8182

82-
const fmt = (amount: number, currency: string) =>
83-
new Intl.NumberFormat(locale, {
84-
style: 'currency',
85-
currency: (currency || 'USD').toUpperCase(),
86-
}).format(amount)
87-
8883
const statusBadge = (status: string) => {
8984
switch (status) {
9085
case 'paid':
@@ -149,8 +144,11 @@ export default async function AdminPayoutsPage({
149144
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
150145
{t('stats.totalPaid')}
151146
</p>
152-
<p className="mt-2 text-2xl font-bold tracking-tight tabular-nums">
153-
{fmt(totalPaid, 'USD')}
147+
<p
148+
className="mt-2 text-2xl font-bold tracking-tight tabular-nums break-words"
149+
data-testid="total-paid-value"
150+
>
151+
{formatByCurrency(totalPaidByCurrency, locale)}
154152
</p>
155153
<p className="mt-1 text-[11px] text-muted-foreground/70">
156154
{t('stats.totalPaidDesc')}
@@ -232,7 +230,7 @@ export default async function AdminPayoutsPage({
232230
: '—'}
233231
</TableCell>
234232
<TableCell className="text-right font-semibold tabular-nums">
235-
{fmt(payout.amount || 0, payout.currency || 'USD')}
233+
{formatMoney(payout.amount, payout.currency, locale)}
236234
</TableCell>
237235
<TableCell>
238236
{statusBadge(payout.status || '')}

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

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getTranslations } from 'next-intl/server'
33
import { getPayoutsOwed } from '@/app/actions/platform/payouts'
44
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
55
import { MarkPayoutPaidDialog } from '@/components/platform/mark-payout-paid-dialog'
6+
import { formatByCurrency, formatMoney } from '@/lib/payments/format-money'
67
import {
78
IconArrowBackUp,
89
IconCoin,
@@ -11,19 +12,9 @@ import {
1112
IconWalletOff,
1213
} from '@tabler/icons-react'
1314

14-
/**
15-
* Formatted in the reader's locale; the currency code always comes from the row
16-
* itself — balances are grouped per currency and never converted (#497).
17-
*/
18-
const money = (n: number, currency: string, locale: string) =>
19-
new Intl.NumberFormat(locale, { style: 'currency', currency: currency.toUpperCase() }).format(n ?? 0)
20-
21-
/** Renders one line per currency — amounts in different currencies are never summed into one number (#497). */
22-
function formatByCurrency(byCurrency: Record<string, number>, locale: string) {
23-
const entries = Object.entries(byCurrency).filter(([, amount]) => amount !== 0)
24-
if (entries.length === 0) return money(0, 'usd', locale)
25-
return entries.map(([currency, amount]) => money(amount, currency, locale)).join(' · ')
26-
}
15+
// `formatMoney` / `formatByCurrency` live in lib/payments/format-money.ts — the
16+
// no-cross-currency-sums rule this page established in #497 is shared with the
17+
// school-facing payout history rather than re-derived there (#531).
2718

2819
const PROVIDER_LABEL: Record<string, string> = {
2920
paypal: 'PayPal',
@@ -291,24 +282,24 @@ export default async function PlatformPayoutsPage({
291282
? '—'
292283
: Object.keys(r.byProvider).map((p) => PROVIDER_LABEL[p] ?? p).join(', ')}
293284
</td>
294-
<td className="py-2.5 text-right tabular-nums">{money(r.grossCollected, r.currency, locale)}</td>
285+
<td className="py-2.5 text-right tabular-nums">{formatMoney(r.grossCollected, r.currency, locale)}</td>
295286
<td className="py-2.5 text-right tabular-nums text-muted-foreground">
296287
{r.schoolPercentage}%
297288
</td>
298289
<td className="py-2.5 text-right tabular-nums text-muted-foreground">
299-
{money(r.alreadyPaid, r.currency, locale)}
290+
{formatMoney(r.alreadyPaid, r.currency, locale)}
300291
</td>
301292
<ClawbackCell
302293
amount={r.clawback}
303-
formatted={money(r.clawback, r.currency, locale)}
294+
formatted={formatMoney(r.clawback, r.currency, locale)}
304295
hint={t('table.clawbackHint')}
305296
/>
306297
<td className="py-2.5 text-right tabular-nums font-medium text-amber-600 dark:text-amber-400">
307-
{money(r.netOwed, r.currency, locale)}
298+
{formatMoney(r.netOwed, r.currency, locale)}
308299
</td>
309300
<OverpaidCell
310301
amount={r.overpaid}
311-
formatted={money(r.overpaid, r.currency, locale)}
302+
formatted={formatMoney(r.overpaid, r.currency, locale)}
312303
hint={t('table.overpaidHint')}
313304
/>
314305
<td className="py-2.5 text-right">

lib/payments/format-money.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Money formatting for the payout screens.
3+
*
4+
* The invariant these helpers exist to hold: **amounts in different currencies
5+
* are never added together, and a currency code is never hardcoded at the
6+
* render site.** #497 fixed that on the super-admin payouts page, but the fix
7+
* lived in that page's local scope, so the school-facing payout history added
8+
* later re-introduced the same bug (#531). Keeping the grouping and the
9+
* formatting here — tested, in one place — is what stops the next payout screen
10+
* from re-deriving it a third time.
11+
*
12+
* No conversion happens anywhere in this module. A USD figure and a EUR figure
13+
* stay two figures.
14+
*/
15+
16+
/** The minimum shape a row needs to be groupable: an amount and the currency it is denominated in. */
17+
export interface MoneyRow {
18+
amount: number | null
19+
currency: string | null
20+
}
21+
22+
/** Totals keyed by upper-cased ISO currency code, e.g. `{ USD: 1240.5, EUR: 860 }`. */
23+
export type AmountsByCurrency = Record<string, number>
24+
25+
const DEFAULT_CURRENCY = 'USD'
26+
27+
/** Upper-cases the code and falls back to USD, so `'usd'`, `'USD'` and a null column share one bucket. */
28+
const normalizeCurrency = (currency: string | null | undefined) =>
29+
(currency || DEFAULT_CURRENCY).toUpperCase()
30+
31+
/**
32+
* Formatted in the reader's locale; the currency code always comes from the
33+
* data, never from the call site.
34+
*/
35+
export function formatMoney(amount: number | null | undefined, currency: string | null | undefined, locale: string) {
36+
return new Intl.NumberFormat(locale, {
37+
style: 'currency',
38+
currency: normalizeCurrency(currency),
39+
}).format(amount ?? 0)
40+
}
41+
42+
/**
43+
* Groups rows into one total per currency. Callers filter first (e.g. to `paid`
44+
* payouts) and pass what survives — this only ever adds an amount to the bucket
45+
* for its own currency, so there is no arrangement of inputs that produces a
46+
* cross-currency sum.
47+
*/
48+
export function sumByCurrency(rows: readonly MoneyRow[]): AmountsByCurrency {
49+
const totals: AmountsByCurrency = {}
50+
for (const row of rows) {
51+
const currency = normalizeCurrency(row.currency)
52+
totals[currency] = (totals[currency] ?? 0) + (row.amount ?? 0)
53+
}
54+
return totals
55+
}
56+
57+
/**
58+
* Renders one figure per currency, joined — `"$1,240.50 · €860.00"`. Currencies
59+
* that net to zero are dropped so the common single-currency school still reads
60+
* as a single number; an empty set renders one zero rather than an empty card.
61+
*/
62+
export function formatByCurrency(byCurrency: AmountsByCurrency, locale: string) {
63+
const entries = Object.entries(byCurrency).filter(([, amount]) => amount !== 0)
64+
if (entries.length === 0) return formatMoney(0, DEFAULT_CURRENCY, locale)
65+
return entries.map(([currency, amount]) => formatMoney(amount, currency, locale)).join(' · ')
66+
}

tests/unit/format-money.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { formatByCurrency, formatMoney, sumByCurrency } from '@/lib/payments/format-money'
3+
4+
/**
5+
* The rule under test is the one #497 established and #531 found broken on the
6+
* school-facing payout history: payout amounts are grouped per currency and
7+
* never added across currencies, and the currency code always comes from the
8+
* data. Formatting assertions use `toContain` on the digits plus a separate
9+
* symbol check, so a change in ICU's spacing or grouping doesn't produce a false
10+
* failure about arithmetic.
11+
*/
12+
13+
const rows = (...pairs: Array<[number | null, string | null]>) =>
14+
pairs.map(([amount, currency]) => ({ amount, currency }))
15+
16+
describe('sumByCurrency', () => {
17+
it('keeps each currency in its own bucket', () => {
18+
expect(sumByCurrency(rows([1240.5, 'usd'], [860, 'eur']))).toEqual({ USD: 1240.5, EUR: 860 })
19+
})
20+
21+
it('sums multiple rows within one currency', () => {
22+
expect(sumByCurrency(rows([100, 'usd'], [25.5, 'usd'], [10, 'eur']))).toEqual({
23+
USD: 125.5,
24+
EUR: 10,
25+
})
26+
})
27+
28+
it('treats currency codes case-insensitively so one currency never splits into two lines', () => {
29+
expect(sumByCurrency(rows([100, 'usd'], [50, 'USD'], [1, 'Usd']))).toEqual({ USD: 151 })
30+
})
31+
32+
it('buckets a null currency as USD (the payouts.currency column default) rather than dropping the row', () => {
33+
expect(sumByCurrency(rows([40, null], [60, 'usd']))).toEqual({ USD: 100 })
34+
})
35+
36+
it('treats a null amount as zero', () => {
37+
expect(sumByCurrency(rows([null, 'usd'], [10, 'usd']))).toEqual({ USD: 10 })
38+
})
39+
40+
it('returns an empty object for no rows', () => {
41+
expect(sumByCurrency([])).toEqual({})
42+
})
43+
44+
it('never produces a total larger than any single currency bucket (the #531 regression)', () => {
45+
const totals = sumByCurrency(rows([1240.5, 'usd'], [860, 'eur']))
46+
// The bug summed these to 2100.5 and labelled it USD. No bucket may hold that.
47+
expect(Object.values(totals)).not.toContain(2100.5)
48+
expect(totals.USD).toBe(1240.5)
49+
})
50+
})
51+
52+
describe('formatMoney', () => {
53+
it('formats with the currency it is given, not a hardcoded one', () => {
54+
expect(formatMoney(860, 'eur', 'en')).toContain('860')
55+
expect(formatMoney(860, 'eur', 'en')).toContain('€')
56+
expect(formatMoney(1240.5, 'usd', 'en')).toContain('$')
57+
})
58+
59+
it('accepts lower-case codes', () => {
60+
expect(formatMoney(10, 'gbp', 'en')).toContain('£')
61+
})
62+
63+
it('falls back to USD for a missing currency and to zero for a missing amount', () => {
64+
expect(formatMoney(10, null, 'en')).toContain('$')
65+
expect(formatMoney(null, 'usd', 'en')).toContain('0')
66+
})
67+
68+
it('respects the reader locale', () => {
69+
// es-ES puts the symbol after the number; the point is that locale is honoured,
70+
// not which side it lands on.
71+
expect(formatMoney(1240.5, 'eur', 'es')).toContain('€')
72+
})
73+
})
74+
75+
describe('formatByCurrency', () => {
76+
it('renders one figure per currency instead of a single mixed number', () => {
77+
const rendered = formatByCurrency({ USD: 1240.5, EUR: 860 }, 'en')
78+
expect(rendered).toContain('$')
79+
expect(rendered).toContain('€')
80+
expect(rendered).toContain('1,240.50')
81+
expect(rendered).toContain('860')
82+
expect(rendered).not.toContain('2,100.50')
83+
})
84+
85+
it('renders a single-currency school as one plain figure', () => {
86+
const rendered = formatByCurrency({ USD: 1240.5 }, 'en')
87+
expect(rendered).toContain('$1,240.50')
88+
expect(rendered).not.toContain('·')
89+
})
90+
91+
it('drops currencies that net to zero', () => {
92+
const rendered = formatByCurrency({ USD: 100, EUR: 0 }, 'en')
93+
expect(rendered).toContain('$100')
94+
expect(rendered).not.toContain('€')
95+
})
96+
97+
it('renders one zero rather than an empty card when there is nothing to show', () => {
98+
expect(formatByCurrency({}, 'en')).toContain('0')
99+
expect(formatByCurrency({ USD: 0, EUR: 0 }, 'en')).toContain('0')
100+
})
101+
102+
it('composes with sumByCurrency end to end for the #531 case', () => {
103+
const paid = rows([1240.5, 'usd'], [860, 'eur'])
104+
const rendered = formatByCurrency(sumByCurrency(paid), 'en')
105+
expect(rendered).toBe('$1,240.50 · €860.00')
106+
})
107+
})

0 commit comments

Comments
 (0)