Skip to content

Commit 155a883

Browse files
fix(payments): stop double-subtracting refunds from payout balances (#511) (#521)
`computeOwedBalances` excluded refunded sales from `grossOwed` and then subtracted their scaled share again as `clawback`, so a school's balance was understated by the refund. The second subtraction was never needed: `alreadyPaid` is the all-time payout sum, so it still holds whatever was paid out for a sale that later flipped to `refunded`. Dropping the sale from `grossOwed` recovers that overpayment on its own. `netOwed` is now `max(grossOwed - alreadyPaid, 0)` — one representation, one subtraction. The issue's worked example (1000 successful + 100 refunded at 80%, 80 paid) goes from 640 to the correct 720. `clawback` survives as a reporting-only figure and is gated on plausibility: a refund counts only when a payout in the same currency settled up to a point at or after the sale, so refunds that were never paid out no longer reduce anything or appear as recoverable. The dates come from `transactions.transaction_date` (that table has no `created_at`) and from `payouts.period_end ?? paid_at ?? created_at` — `period_end` is already the exact "covered through" the issue asked for, so no migration is needed. The four `#498` unit tests encoded the old behavior as intended and are rewritten with asymmetric amounts, so the `Math.max(…, 0)` floor can no longer mask a sign error. Adds coverage for undated refunds, undated payouts, cross-currency payouts, latest-payout-wins, and mixed ISO offset formats. 26 tests in the file, 313 across the suite. The `/platform/payouts` clawback banner and column are reworded, since that number is no longer a deduction the reader should apply themselves. Claude-Session: https://claude.ai/code/session_012nmbfroRHjSNrkGbRqTozs Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c0f0b7f commit 155a883

4 files changed

Lines changed: 284 additions & 70 deletions

File tree

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,9 @@ export default async function PlatformPayoutsPage() {
140140
data-testid="payouts-clawback-banner"
141141
>
142142
<span className="font-medium">Refund clawback: {formatByCurrency(totalClawbackByCurrency)}.</span>{' '}
143-
Some already-paid-out transactions were later refunded — this amount reduces what&apos;s
144-
currently owed rather than being paid again.
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.
145146
</div>
146147
)}
147148

@@ -163,7 +164,7 @@ export default async function PlatformPayoutsPage() {
163164
<th className="pb-2 text-right font-medium">Collected</th>
164165
<th className="pb-2 text-right font-medium">School %</th>
165166
<th className="pb-2 text-right font-medium">Paid so far</th>
166-
<th className="pb-2 text-right font-medium">Clawback</th>
167+
<th className="pb-2 text-right font-medium">Of which refunded</th>
167168
<th className="pb-2 text-right font-medium">Owed</th>
168169
<th className="pb-2 text-right font-medium"></th>
169170
</tr>
@@ -187,8 +188,14 @@ export default async function PlatformPayoutsPage() {
187188
</td>
188189
<td className="py-2.5 text-right tabular-nums">
189190
{r.clawback > 0 ? (
190-
<span className="font-medium text-red-600 dark:text-red-400">
191-
{money(r.clawback, r.currency)}
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)}
192199
</span>
193200
) : (
194201
<span className="text-muted-foreground"></span>

app/actions/platform/payouts.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,14 @@ 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, currency, school_percentage_snapshot, status')
32+
.select('tenant_id, payment_provider, amount, currency, school_percentage_snapshot, status, transaction_date')
3333
.in('status', ['successful', 'refunded'])
3434
.in('payment_provider', PLATFORM_SETTLED_PROVIDERS),
35-
admin.from('payouts').select('tenant_id, amount, currency').eq('payout_method', 'manual').eq('status', 'paid'),
35+
admin
36+
.from('payouts')
37+
.select('tenant_id, amount, currency, period_end, paid_at, created_at')
38+
.eq('payout_method', 'manual')
39+
.eq('status', 'paid'),
3640
])
3741

3842
const schoolPercentageByTenant = new Map(
@@ -54,8 +58,17 @@ export async function getPayoutsOwed(): Promise<TenantOwed[]> {
5458
currency: t.currency || 'usd',
5559
schoolPercentageSnapshot: t.school_percentage_snapshot as number | null,
5660
status: t.status as 'successful' | 'refunded',
61+
transactionDate: t.transaction_date as string | null,
5762
})),
58-
(paid || []).map((p) => ({ tenantId: p.tenant_id, amount: p.amount, currency: p.currency || 'usd' }))
63+
// `period_end` is the exact "covered through" when a payout recorded a period;
64+
// manually recorded ones leave it null today, so fall back to when the money
65+
// actually moved (issue #511).
66+
(paid || []).map((p) => ({
67+
tenantId: p.tenant_id,
68+
amount: p.amount,
69+
currency: p.currency || 'usd',
70+
coveredThrough: p.period_end ?? p.paid_at ?? p.created_at,
71+
}))
5972
)
6073
}
6174

lib/payments/payouts-owed.ts

Lines changed: 111 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,40 @@
2424
* `transactions.currency` and `payouts.currency` are trusted as given; this
2525
* module does no currency conversion, only grouping.
2626
*
27-
* Refunds/chargebacks (issue #498): a transaction can flip from `successful`
28-
* to `refunded` after its payout was already recorded. Callers pass refunded
29-
* transactions through alongside successful ones (tagged via `status`); this
30-
* module scales each refund by the same split percentage a sale would've
31-
* used and surfaces it as a distinct `clawback` figure, subtracted from
32-
* `netOwed` on the next cycle rather than silently vanishing from
33-
* `grossCollected`/`grossOwed` with no trace.
27+
* Refunds/chargebacks (issues #498, #511): a transaction can flip from
28+
* `successful` to `refunded` after its payout was already recorded. Callers
29+
* pass refunded transactions through alongside successful ones (tagged via
30+
* `status`), and refunded sales are simply left out of
31+
* `grossCollected`/`grossOwed`.
32+
*
33+
* That exclusion is the ONLY place a refund moves `netOwed`, and it is
34+
* sufficient on its own: `alreadyPaid` is the all-time sum of recorded
35+
* payouts, so it still contains whatever was paid out for a sale that later
36+
* flipped to `refunded`. Dropping the sale from `grossOwed` while leaving its
37+
* payout inside `alreadyPaid` recovers the overpayment automatically —
38+
* 1000 successful plus a refunded 100 at an 80% split with 80 already paid
39+
* gives `800 - 80 = 720`, the correct figure. Subtracting the refund a second
40+
* time as a `clawback` term (as this module did before #511) understated the
41+
* balance and also penalised refunds that had never been paid out at all.
42+
*
43+
* `clawback` therefore survives as a REPORTING-ONLY figure and is not part of
44+
* the `netOwed` arithmetic. It answers "how much of what we already paid this
45+
* school was for sales that have since been refunded?", so an operator can
46+
* see why a balance dropped. Because it is already netted out of `netOwed` via
47+
* `alreadyPaid`, it must never be collected a second time.
48+
*
49+
* A refund only counts toward `clawback` when a payout plausibly covered it:
50+
* its `transactionDate` must fall at or before the latest `coveredThrough` of
51+
* that tenant's payouts IN THE SAME CURRENCY. This is a heuristic — the module
52+
* has no record of which transactions a given payout settled — so it can
53+
* overstate `clawback` when a payout deliberately skipped a sale. That
54+
* inaccuracy cannot move `netOwed`; making it exact needs real payout-to-
55+
* transaction linkage on `payouts`.
56+
*
57+
* `netOwed` keeps its `Math.max(…, 0)` floor, so a school overpaid beyond its
58+
* outstanding balance reads 0 rather than a negative — only a payout row moves
59+
* money, and this view never invents a reverse one. The unrecovered remainder
60+
* in that situation is visible through `clawback`.
3461
*/
3562

3663
/** Used when a tenant has no `revenue_splits` row yet (shouldn't normally happen, but keeps callers from dividing by an absent value). */
@@ -52,15 +79,30 @@ export interface PlatformSettledTxn {
5279
currency: string
5380
/** revenue_splits.school_percentage in effect when this transaction was created (0–100), or null for pre-#496 rows. */
5481
schoolPercentageSnapshot: number | null
55-
/** 'successful' contributes to grossCollected/grossOwed as normal; 'refunded' contributes its scaled amount to `clawback` instead (issue #498). */
82+
/** 'successful' contributes to grossCollected/grossOwed as normal; 'refunded' is left out of both (issues #498, #511). */
5683
status: 'successful' | 'refunded'
84+
/**
85+
* `transactions.transaction_date` (ISO 8601) — that table has no `created_at`.
86+
* Only read for refunded rows, to decide whether a payout plausibly covered
87+
* this sale before it was refunded (issue #511). Null means "unknown", which
88+
* is treated as not covered rather than assumed covered.
89+
*/
90+
transactionDate: string | null
5791
}
5892

5993
export interface ManualPayoutRecord {
6094
tenantId: string
6195
amount: number
6296
/** payouts.currency — the currency this payout was actually recorded in. */
6397
currency: string
98+
/**
99+
* The point in time this payout is understood to settle up to (ISO 8601) —
100+
* `payouts.period_end` when the payout recorded one, else `paid_at`, else
101+
* `created_at`. Refunded sales dated at or before it are counted as having
102+
* been paid out already (issue #511). Null means "unknown", and covers
103+
* nothing.
104+
*/
105+
coveredThrough: string | null
64106
}
65107

66108
export interface CurrencyBalance {
@@ -71,9 +113,15 @@ export interface CurrencyBalance {
71113
grossOwed: number
72114
/** Sum of manual payouts already recorded as paid in this currency. */
73115
alreadyPaid: number
74-
/** Sum over refunded transactions of amount × (that transaction's snapshotted split, or the tenant's current split) — money already paid out for sales that later got refunded (issue #498). */
116+
/**
117+
* REPORTING ONLY — not a term in `netOwed` (issue #511). Sum, over refunded
118+
* transactions a payout plausibly covered, of amount × (that transaction's
119+
* snapshotted split, or the tenant's current split): how much of what was
120+
* already paid to this school was for sales that have since been refunded.
121+
* `alreadyPaid` already accounts for it, so it must not be recovered again.
122+
*/
75123
clawback: number
76-
/** max(grossOwed - alreadyPaid - clawback, 0) — what's currently owed in this currency. */
124+
/** max(grossOwed - alreadyPaid, 0) — what's currently owed in this currency. */
77125
netOwed: number
78126
/** Per-provider breakdown of grossCollected in this currency. */
79127
byProvider: Record<string, number>
@@ -111,29 +159,66 @@ export function computeOwedBalances(
111159
return entry
112160
}
113161

162+
const paidByTenantCurrency = new Map<string, Map<string, number>>()
163+
// tenantId -> currency -> latest `coveredThrough` across that currency's payouts.
164+
// Payouts are cumulative and all-time, so the latest one bounds everything any
165+
// payout could have settled.
166+
const coveredThroughByTenantCurrency = new Map<string, Map<string, number>>()
167+
for (const payout of paidPayouts) {
168+
let byCurrency = paidByTenantCurrency.get(payout.tenantId)
169+
if (!byCurrency) {
170+
byCurrency = new Map()
171+
paidByTenantCurrency.set(payout.tenantId, byCurrency)
172+
}
173+
byCurrency.set(payout.currency, (byCurrency.get(payout.currency) ?? 0) + payout.amount)
174+
175+
if (payout.coveredThrough) {
176+
// Parsed rather than string-compared: `paid_at` and `period_end` reach us
177+
// as whatever offset format their source used ('…Z' vs '…+00:00'), which
178+
// lexicographic comparison gets wrong across formats.
179+
const covered = Date.parse(payout.coveredThrough)
180+
if (Number.isNaN(covered)) continue
181+
let coveredByCurrency = coveredThroughByTenantCurrency.get(payout.tenantId)
182+
if (!coveredByCurrency) {
183+
coveredByCurrency = new Map()
184+
coveredThroughByTenantCurrency.set(payout.tenantId, coveredByCurrency)
185+
}
186+
const previous = coveredByCurrency.get(payout.currency)
187+
if (previous == null || covered > previous) {
188+
coveredByCurrency.set(payout.currency, covered)
189+
}
190+
}
191+
}
192+
193+
/**
194+
* True when a payout in the same tenant AND currency settled up to a point at
195+
* or after this sale, so its share plausibly went out before the refund. A
196+
* missing date on either side answers false — an unjustifiable clawback is
197+
* worse than a missing one (issue #511).
198+
*/
199+
function wasPlausiblyPaidOut(txn: PlatformSettledTxn) {
200+
if (!txn.transactionDate) return false
201+
const soldAt = Date.parse(txn.transactionDate)
202+
if (Number.isNaN(soldAt)) return false
203+
const coveredThrough = coveredThroughByTenantCurrency.get(txn.tenantId)?.get(txn.currency)
204+
return coveredThrough != null && soldAt <= coveredThrough
205+
}
206+
114207
for (const txn of txns) {
115208
const entry = bucket(txn.tenantId, txn.currency)
116209
const effectivePercentage = txn.schoolPercentageSnapshot ?? schoolPercentageByTenant.get(txn.tenantId) ?? 0
117210
const scaledAmount = (txn.amount * effectivePercentage) / 100
118211
if (txn.status === 'refunded') {
119-
entry.clawback += scaledAmount
212+
// Reporting only. Leaving the sale out of grossOwed below is what actually
213+
// corrects the balance; this just names the amount for the operator.
214+
if (wasPlausiblyPaidOut(txn)) entry.clawback += scaledAmount
120215
continue
121216
}
122217
entry.grossCollected += txn.amount
123218
entry.grossOwed += scaledAmount
124219
entry.byProvider[txn.paymentProvider] = (entry.byProvider[txn.paymentProvider] ?? 0) + txn.amount
125220
}
126221

127-
const paidByTenantCurrency = new Map<string, Map<string, number>>()
128-
for (const payout of paidPayouts) {
129-
let byCurrency = paidByTenantCurrency.get(payout.tenantId)
130-
if (!byCurrency) {
131-
byCurrency = new Map()
132-
paidByTenantCurrency.set(payout.tenantId, byCurrency)
133-
}
134-
byCurrency.set(payout.currency, (byCurrency.get(payout.currency) ?? 0) + payout.amount)
135-
}
136-
137222
return tenants.map((tenant) => {
138223
const collected = byTenantCurrency.get(tenant.tenantId) ?? new Map()
139224
const paid = paidByTenantCurrency.get(tenant.tenantId) ?? new Map()
@@ -148,7 +233,11 @@ export function computeOwedBalances(
148233
const grossOwed = entry?.grossOwed ?? 0
149234
const clawback = entry?.clawback ?? 0
150235
const alreadyPaid = paid.get(currency) ?? 0
151-
const netOwed = Math.max(grossOwed - alreadyPaid - clawback, 0)
236+
// `clawback` is deliberately absent here — refunded sales are already out
237+
// of `grossOwed`, and their payouts are still inside `alreadyPaid`, so the
238+
// overpayment nets out on its own. Subtracting it again double-counted the
239+
// refund (issue #511).
240+
const netOwed = Math.max(grossOwed - alreadyPaid, 0)
152241
return {
153242
currency,
154243
grossCollected,

0 commit comments

Comments
 (0)