Skip to content

Commit dbbfd9c

Browse files
DPS0340guillermoscriptclaude
authored
fix(payments): report payout overpayments instead of clamping them away (#516) (#524)
* fix(payments): report payout overpayments instead of clamping them away (#516) `netOwed = Math.max(grossOwed - alreadyPaid, 0)` hid the size of an overpayment, and `disabled={netOwed <= 0}` on the Mark-as-Paid button removed the only control that writes a payout row. The platform could neither see nor act on "we paid this school more than we owed". Add `overpaid` (max(alreadyPaid - grossOwed, 0)) alongside `netOwed`, which keeps its floor so the metric cards, the mismatch guard and the school-facing view are unaffected. Surface it as a table column and a banner. On the issue's suggested fix: a negative/adjusting payout row is not available — `payouts.amount` carries CHECK (amount > 0) from the original revenue migration, untouched by the manual-payouts migration, and `markPayoutPaid` rejects non-positive amounts. Recording one means a migration plus a sign convention running through `alreadyPaid`, `clawback` and the school-facing history. The other half of the ask — carry the overpayment forward — already works: `alreadyPaid` is an all-time sum, so the next cycle starts in the hole and absorbs the excess with no operator action. What was missing was any way to see that happening, which is what this change fixes. Tests pin the carry-forward across three successive states. §2 of the issue (mismatch guard skipped on retry) does not reproduce: the amount input has cleared `mismatch` on every change since the dialog was written in 3a36763, so an edited amount re-submits with confirmMismatch=false and is re-validated. Documented both sides of that contract in place, since reading `mismatch !== null` in isolation is what made it look bypass-prone. * fix(payments): translate the platform payouts screen and correct the overpaid copy (#516) Review follow-up on top of the overpayment fix. Four changes, none of them to the arithmetic: - Copy. The banner promised that an overpayment "recovers itself". That only holds while the school keeps selling: a dormant or departed school never generates the sales that absorb the excess, and neither does a mis-keyed payout row, so the page was telling the operator to do nothing about real money. It now says the excess comes off the next payout automatically and that a school with no further sales has to be settled outside the platform. Same caveat added to the module doc in payouts-owed.ts. - Overlap. A refund drops a sale out of grossOwed while its payout stays inside alreadyPaid, so the same money can show up as both clawback and overpaid, in adjacent columns, under two banners that each said "nothing to do". The overpaid banner now names that overlap when both are present. - i18n. /platform was English-only, unlike the school-facing payouts page. The page and the Mark-as-Paid dialog now read from a new platform.payouts namespace in en.json and es.json, and amounts format in the request locale rather than a hardcoded en-US. A unit test asserts en/es parity for that namespace (missing keys fall back silently, so nothing else would catch it). - Composition. The Overpaid cell was a near-verbatim copy of the clawback cell. Both are now explicit variants over one ReportedAmountCell, and each carries an icon so the two columns aren't distinguished by colour alone (WCAG 1.4.1). Overpaid moves off purple, which is the brand primary hue, onto sky. Verified against local Supabase with a seeded overpaid tenant: both banners, both cells, the ICU plural and the mismatch guard render correctly in English and Spanish. Spanish copy needs a native review pass before merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TAaTqrkXy9kdWn5yk9eu5F --------- Co-authored-by: Guillermo Marin <52298929+guillermoscript@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7cab2ed commit dbbfd9c

7 files changed

Lines changed: 469 additions & 74 deletions

File tree

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

Lines changed: 160 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
1+
import type { ComponentType } from 'react'
2+
import { getTranslations } from 'next-intl/server'
13
import { getPayoutsOwed } from '@/app/actions/platform/payouts'
24
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
35
import { MarkPayoutPaidDialog } from '@/components/platform/mark-payout-paid-dialog'
46
import {
7+
IconArrowBackUp,
58
IconCoin,
9+
IconReceiptRefund,
610
IconReportMoney,
711
IconWalletOff,
812
} from '@tabler/icons-react'
913

10-
const money = (n: number, currency: string) =>
11-
new Intl.NumberFormat('en-US', { style: 'currency', currency: currency.toUpperCase() }).format(n ?? 0)
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)
1220

1321
/** Renders one line per currency — amounts in different currencies are never summed into one number (#497). */
14-
function formatByCurrency(byCurrency: Record<string, number>) {
22+
function formatByCurrency(byCurrency: Record<string, number>, locale: string) {
1523
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(' · ')
24+
if (entries.length === 0) return money(0, 'usd', locale)
25+
return entries.map(([currency, amount]) => money(amount, currency, locale)).join(' · ')
1826
}
1927

2028
const PROVIDER_LABEL: Record<string, string> = {
@@ -23,7 +31,89 @@ const PROVIDER_LABEL: Record<string, string> = {
2331
lemonsqueezy: 'Lemon Squeezy',
2432
}
2533

26-
export default async function PlatformPayoutsPage() {
34+
interface ReportedAmountProps {
35+
amount: number
36+
formatted: string
37+
hint: string
38+
}
39+
40+
/**
41+
* Shared shape of the two reporting-only money columns ("Of which refunded" and
42+
* "Overpaid"). Both name a figure that `Owed` already accounts for, both need an
43+
* explanation attached, and neither is ever negative — so they render the same
44+
* way and differ only in wording, icon and tone.
45+
*
46+
* The icon is not decoration: colour alone can't separate these two columns for
47+
* a colour-blind operator (WCAG 1.4.1), and they sit in a table of otherwise
48+
* neutral numbers.
49+
*/
50+
function ReportedAmountCell({
51+
amount,
52+
formatted,
53+
hint,
54+
className,
55+
icon: Icon,
56+
testId,
57+
}: ReportedAmountProps & {
58+
className: string
59+
icon: ComponentType<{ className?: string; strokeWidth?: number }>
60+
testId: string
61+
}) {
62+
return (
63+
<td className="py-2.5 text-right tabular-nums" data-testid={testId}>
64+
{amount > 0 ? (
65+
<span
66+
className={`inline-flex items-center justify-end gap-1 font-medium ${className}`}
67+
title={hint}
68+
>
69+
<Icon className="h-3.5 w-3.5 shrink-0" strokeWidth={1.75} />
70+
{formatted}
71+
</span>
72+
) : (
73+
<span className="text-muted-foreground"></span>
74+
)}
75+
</td>
76+
)
77+
}
78+
79+
/**
80+
* The slice of "Paid so far" that covered sales since refunded. Not a deduction
81+
* column: "Owed" already accounts for it (#511), so it carries no minus sign.
82+
*/
83+
function ClawbackCell(props: ReportedAmountProps) {
84+
return (
85+
<ReportedAmountCell
86+
{...props}
87+
className="text-red-600 dark:text-red-400"
88+
icon={IconReceiptRefund}
89+
testId="payout-clawback-cell"
90+
/>
91+
)
92+
}
93+
94+
/**
95+
* Paid past the outstanding balance (#516). Recovered by carry-forward, not by a
96+
* reverse payout row — `payouts.amount` is CHECK (amount > 0) — so it shrinks on
97+
* its own as new sales land, but only for a school that keeps selling.
98+
*/
99+
function OverpaidCell(props: ReportedAmountProps) {
100+
return (
101+
<ReportedAmountCell
102+
{...props}
103+
className="text-sky-600 dark:text-sky-400"
104+
icon={IconArrowBackUp}
105+
testId="payout-overpaid-cell"
106+
/>
107+
)
108+
}
109+
110+
export default async function PlatformPayoutsPage({
111+
params,
112+
}: {
113+
params: Promise<{ locale: string }>
114+
}) {
115+
const { locale } = await params
116+
const t = await getTranslations('platform.payouts')
27117
const owed = await getPayoutsOwed()
28118

29119
// Per-currency totals across all tenants — kept separate, never summed together.
@@ -43,10 +133,12 @@ export default async function PlatformPayoutsPage() {
43133
alreadyPaid: number
44134
clawback: number
45135
netOwed: number
136+
overpaid: number
46137
byProvider: Record<string, number>
47138
}
48139
const rows: Row[] = []
49140
const totalClawbackByCurrency: Record<string, number> = {}
141+
const totalOverpaidByCurrency: Record<string, number> = {}
50142

51143
for (const tenant of owed) {
52144
let tenantHasOwed = false
@@ -57,6 +149,9 @@ export default async function PlatformPayoutsPage() {
57149
if (balance.clawback > 0) {
58150
totalClawbackByCurrency[balance.currency] = (totalClawbackByCurrency[balance.currency] ?? 0) + balance.clawback
59151
}
152+
if (balance.overpaid > 0) {
153+
totalOverpaidByCurrency[balance.currency] = (totalOverpaidByCurrency[balance.currency] ?? 0) + balance.overpaid
154+
}
60155
if (balance.netOwed > 0) tenantHasOwed = true
61156
rows.push({
62157
tenantId: tenant.tenantId,
@@ -67,34 +162,36 @@ export default async function PlatformPayoutsPage() {
67162
alreadyPaid: balance.alreadyPaid,
68163
clawback: balance.clawback,
69164
netOwed: balance.netOwed,
165+
overpaid: balance.overpaid,
70166
byProvider: balance.byProvider,
71167
})
72168
}
73169
if (tenantHasOwed) schoolsOwed++
74170
}
75171
const hasClawbacks = Object.values(totalClawbackByCurrency).some((amount) => amount > 0)
172+
const hasOverpayments = Object.values(totalOverpaidByCurrency).some((amount) => amount > 0)
76173

77174
const metricCards = [
78175
{
79-
title: 'Currently Owed',
80-
value: formatByCurrency(totalOwedByCurrency),
81-
sub: `${schoolsOwed} school${schoolsOwed === 1 ? '' : 's'} awaiting payout`,
176+
title: t('metrics.owed'),
177+
value: formatByCurrency(totalOwedByCurrency, locale),
178+
sub: t('metrics.owedSub', { count: schoolsOwed }),
82179
icon: IconWalletOff,
83180
bg: 'bg-amber-50 dark:bg-amber-950/40',
84181
iconColor: 'text-amber-600 dark:text-amber-400',
85182
},
86183
{
87-
title: 'Collected (single-account providers)',
88-
value: formatByCurrency(totalCollectedByCurrency),
89-
sub: 'PayPal, Binance Pay, Lemon Squeezy — 100% lands in your account',
184+
title: t('metrics.collected'),
185+
value: formatByCurrency(totalCollectedByCurrency, locale),
186+
sub: t('metrics.collectedSub'),
90187
icon: IconCoin,
91188
bg: 'bg-blue-50 dark:bg-blue-950/40',
92189
iconColor: 'text-blue-600 dark:text-blue-400',
93190
},
94191
{
95-
title: 'Paid Out (all time)',
96-
value: formatByCurrency(totalPaidOutByCurrency),
97-
sub: 'Manually recorded payouts to schools',
192+
title: t('metrics.paidOut'),
193+
value: formatByCurrency(totalPaidOutByCurrency, locale),
194+
sub: t('metrics.paidOutSub'),
98195
icon: IconReportMoney,
99196
bg: 'bg-emerald-50 dark:bg-emerald-950/40',
100197
iconColor: 'text-emerald-600 dark:text-emerald-400',
@@ -104,11 +201,8 @@ export default async function PlatformPayoutsPage() {
104201
return (
105202
<main className="flex-1 px-4 py-6 sm:px-6 lg:px-8" data-testid="platform-payouts">
106203
<div className="mb-8">
107-
<h1 className="text-2xl font-bold tracking-tight">Payouts</h1>
108-
<p className="text-sm text-muted-foreground mt-1">
109-
PayPal, Binance Pay, and Lemon Squeezy don&apos;t split automatically — 100% of every sale
110-
lands in your account. This is what you owe each school back, based on their revenue split.
111-
</p>
204+
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
205+
<p className="text-sm text-muted-foreground mt-1">{t('description')}</p>
112206
</div>
113207

114208
<div className="mb-8 grid gap-3 sm:grid-cols-3" data-testid="payouts-metrics">
@@ -139,33 +233,51 @@ export default async function PlatformPayoutsPage() {
139233
className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300"
140234
data-testid="payouts-clawback-banner"
141235
>
142-
<span className="font-medium">Refund clawback: {formatByCurrency(totalClawbackByCurrency)}.</span>{' '}
143-
That much of what you already paid out was for sales that have since been refunded. It is
144-
already netted out of the balances below, so don&apos;t collect it again — nothing extra to
145-
do here.
236+
<span className="font-medium">
237+
{t('clawbackBanner.label', { total: formatByCurrency(totalClawbackByCurrency, locale) })}
238+
</span>{' '}
239+
{t('clawbackBanner.body')}
240+
</div>
241+
)}
242+
243+
{hasOverpayments && (
244+
<div
245+
className="mb-6 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900 dark:border-sky-900/50 dark:bg-sky-950/30 dark:text-sky-300"
246+
data-testid="payouts-overpaid-banner"
247+
>
248+
<span className="font-medium">
249+
{t('overpaidBanner.label', { total: formatByCurrency(totalOverpaidByCurrency, locale) })}
250+
</span>{' '}
251+
{t('overpaidBanner.body')}
252+
{/* Both banners can be describing the same money: a refund drops the
253+
sale out of `grossOwed`, which turns an already-recorded payout
254+
into an overpayment. Say so, rather than letting the two figures
255+
read as two separate problems. */}
256+
{hasClawbacks ? <> {t('overpaidBanner.refundOverlap')}</> : null}
146257
</div>
147258
)}
148259

149260
<Card data-testid="payouts-by-tenant">
150261
<CardHeader>
151-
<CardTitle>By school</CardTitle>
262+
<CardTitle>{t('table.title')}</CardTitle>
152263
</CardHeader>
153264
<CardContent>
154265
{rows.length === 0 ? (
155-
<p className="text-muted-foreground text-sm">No platform-settled sales yet.</p>
266+
<p className="text-muted-foreground text-sm">{t('table.empty')}</p>
156267
) : (
157268
<div className="overflow-x-auto">
158269
<table className="w-full text-sm">
159270
<thead>
160271
<tr className="border-b text-left text-[11px] uppercase tracking-wider text-muted-foreground">
161-
<th className="pb-2 font-medium">School</th>
162-
<th className="pb-2 font-medium">Currency</th>
163-
<th className="pb-2 font-medium">Providers</th>
164-
<th className="pb-2 text-right font-medium">Collected</th>
165-
<th className="pb-2 text-right font-medium">School %</th>
166-
<th className="pb-2 text-right font-medium">Paid so far</th>
167-
<th className="pb-2 text-right font-medium">Of which refunded</th>
168-
<th className="pb-2 text-right font-medium">Owed</th>
272+
<th className="pb-2 font-medium">{t('table.headers.school')}</th>
273+
<th className="pb-2 font-medium">{t('table.headers.currency')}</th>
274+
<th className="pb-2 font-medium">{t('table.headers.providers')}</th>
275+
<th className="pb-2 text-right font-medium">{t('table.headers.collected')}</th>
276+
<th className="pb-2 text-right font-medium">{t('table.headers.schoolShare')}</th>
277+
<th className="pb-2 text-right font-medium">{t('table.headers.paidSoFar')}</th>
278+
<th className="pb-2 text-right font-medium">{t('table.headers.refunded')}</th>
279+
<th className="pb-2 text-right font-medium">{t('table.headers.owed')}</th>
280+
<th className="pb-2 text-right font-medium">{t('table.headers.overpaid')}</th>
169281
<th className="pb-2 text-right font-medium"></th>
170282
</tr>
171283
</thead>
@@ -179,31 +291,26 @@ export default async function PlatformPayoutsPage() {
179291
? '—'
180292
: Object.keys(r.byProvider).map((p) => PROVIDER_LABEL[p] ?? p).join(', ')}
181293
</td>
182-
<td className="py-2.5 text-right tabular-nums">{money(r.grossCollected, r.currency)}</td>
294+
<td className="py-2.5 text-right tabular-nums">{money(r.grossCollected, r.currency, locale)}</td>
183295
<td className="py-2.5 text-right tabular-nums text-muted-foreground">
184296
{r.schoolPercentage}%
185297
</td>
186298
<td className="py-2.5 text-right tabular-nums text-muted-foreground">
187-
{money(r.alreadyPaid, r.currency)}
188-
</td>
189-
<td className="py-2.5 text-right tabular-nums">
190-
{r.clawback > 0 ? (
191-
// Not a deduction column: this is the slice of "Paid so far"
192-
// that covered sales later refunded. "Owed" already accounts
193-
// for it (#511), so it carries no minus sign.
194-
<span
195-
className="font-medium text-red-600 dark:text-red-400"
196-
title="Part of what was already paid out, for sales later refunded. Already reflected in Owed."
197-
>
198-
{money(r.clawback, r.currency)}
199-
</span>
200-
) : (
201-
<span className="text-muted-foreground"></span>
202-
)}
299+
{money(r.alreadyPaid, r.currency, locale)}
203300
</td>
301+
<ClawbackCell
302+
amount={r.clawback}
303+
formatted={money(r.clawback, r.currency, locale)}
304+
hint={t('table.clawbackHint')}
305+
/>
204306
<td className="py-2.5 text-right tabular-nums font-medium text-amber-600 dark:text-amber-400">
205-
{money(r.netOwed, r.currency)}
307+
{money(r.netOwed, r.currency, locale)}
206308
</td>
309+
<OverpaidCell
310+
amount={r.overpaid}
311+
formatted={money(r.overpaid, r.currency, locale)}
312+
hint={t('table.overpaidHint')}
313+
/>
207314
<td className="py-2.5 text-right">
208315
<MarkPayoutPaidDialog
209316
tenantId={r.tenantId}
@@ -221,13 +328,7 @@ export default async function PlatformPayoutsPage() {
221328
</CardContent>
222329
</Card>
223330

224-
<p className="mt-6 text-[11px] text-muted-foreground/70">
225-
Stripe and Solana sales already split automatically and never appear here. Binance Pay
226-
(personal account) and manual/offline sales settle straight to the school and also never
227-
appear here — only PayPal, Binance Pay (merchant), and Lemon Squeezy do, since those settle
228-
100% into your account today. Amounts in different currencies are shown and paid out
229-
separately — they&apos;re never added together into one number.
230-
</p>
331+
<p className="mt-6 text-[11px] text-muted-foreground/70">{t('footnote')}</p>
231332
</main>
232333
)
233334
}

0 commit comments

Comments
 (0)