Skip to content

Commit cac2ebc

Browse files
fix(payments): time-slice revenue split so plan changes don't reprice historical payouts (#496)
computeOwedBalances() applied one CURRENT school_percentage to the entire all-time sum of a tenant's platform-settled transactions. Every plan change rewrites revenue_splits, so the next payout calculation silently repriced every historical transaction at the new percentage, not just new sales (e.g. a school doing $10k at 0% platform fee, then auto-downgraded to free's 10% fee, would show ~$1k less owed on that already-settled $10k than agreed at the time of sale). Adds transactions.school_percentage_snapshot, populated at transaction creation time (app/api/payments/checkout/route.ts — the single insert site that matters for payout math, since Stripe/manual/binance_personal don't settle to the platform account and mock-checkout transactions never carry a payment_provider that matches the payout filter). computeOwedBalances now sums amount * that transaction's own snapshotted split, falling back to the tenant's current split only for transactions with no snapshot (pre-#496 rows) — old data isn't retroactively wrong either way. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent f14aace commit cac2ebc

5 files changed

Lines changed: 120 additions & 24 deletions

File tree

app/actions/platform/payouts.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { isSuperAdmin } from '@/lib/supabase/get-user-role'
66
import { revalidatePath } from 'next/cache'
77
import { getCurrentUserId } from '@/lib/supabase/tenant'
88
import { PROVIDER_CAPABILITIES, type PaymentProvider } from '@/lib/payments/types'
9-
import { computeOwedBalances, type TenantOwed } from '@/lib/payments/payouts-owed'
9+
import { computeOwedBalances, DEFAULT_SCHOOL_PERCENTAGE, type TenantOwed } from '@/lib/payments/payouts-owed'
1010

1111
async function verifySuperAdmin() {
1212
const supabase = await createClient()
@@ -20,8 +20,6 @@ const PLATFORM_SETTLED_PROVIDERS = (Object.keys(PROVIDER_CAPABILITIES) as Paymen
2020
(provider) => PROVIDER_CAPABILITIES[provider].settlesToPlatformAccount
2121
)
2222

23-
const DEFAULT_SCHOOL_PERCENTAGE = 80
24-
2523
export async function getPayoutsOwed(): Promise<TenantOwed[]> {
2624
await verifySuperAdmin()
2725
const admin = createAdminClient()
@@ -31,7 +29,7 @@ export async function getPayoutsOwed(): Promise<TenantOwed[]> {
3129
admin.from('revenue_splits').select('tenant_id, school_percentage'),
3230
admin
3331
.from('transactions')
34-
.select('tenant_id, payment_provider, amount')
32+
.select('tenant_id, payment_provider, amount, school_percentage_snapshot')
3533
.eq('status', 'successful')
3634
.in('payment_provider', PLATFORM_SETTLED_PROVIDERS),
3735
admin.from('payouts').select('tenant_id, amount').eq('payout_method', 'manual').eq('status', 'paid'),
@@ -49,7 +47,12 @@ export async function getPayoutsOwed(): Promise<TenantOwed[]> {
4947
})),
5048
(txns || [])
5149
.filter((t) => t.tenant_id && t.payment_provider && t.amount != null)
52-
.map((t) => ({ tenantId: t.tenant_id as string, paymentProvider: t.payment_provider as string, amount: t.amount as number })),
50+
.map((t) => ({
51+
tenantId: t.tenant_id as string,
52+
paymentProvider: t.payment_provider as string,
53+
amount: t.amount as number,
54+
schoolPercentageSnapshot: t.school_percentage_snapshot as number | null,
55+
})),
5356
(paid || []).map((p) => ({ tenantId: p.tenant_id, amount: p.amount }))
5457
)
5558
}

app/api/payments/checkout/route.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@
2020

2121
import { NextRequest, NextResponse } from 'next/server'
2222
import { createClient } from '@/lib/supabase/server'
23+
import { createAdminClient } from '@/lib/supabase/admin'
2324
import { getCurrentTenantId } from '@/lib/supabase/tenant'
2425
import { getPaymentProvider } from '@/lib/payments'
2526
import type { CreateCheckoutParams, PaymentProvider } from '@/lib/payments/types'
2627
import { getSolUsdPrice, usdToLamports } from '@/lib/payments/sol-price'
2728
import { getSolanaSettlementOptions } from '@/app/actions/admin/settings'
2829
import { paymentAuthLimiter } from '@/lib/rate-limit'
30+
import { DEFAULT_SCHOOL_PERCENTAGE } from '@/lib/payments/payouts-owed'
2931
import {
3032
findConflictingSubscription,
3133
PARALLEL_SUBSCRIPTION_CODE,
@@ -203,6 +205,19 @@ export async function POST(req: NextRequest) {
203205
}
204206
}
205207

208+
// Snapshot the tenant's CURRENT revenue split onto this transaction (#496)
209+
// so a later plan change doesn't retroactively reprice it in the payouts
210+
// computation — revenue_splits is super-admin-only under RLS, so this
211+
// needs the admin client even though the rest of this route uses the
212+
// user-scoped one.
213+
const adminClientForSplit = createAdminClient()
214+
const { data: revenueSplit } = await adminClientForSplit
215+
.from('revenue_splits')
216+
.select('school_percentage')
217+
.eq('tenant_id', tenantId)
218+
.maybeSingle()
219+
const schoolPercentageSnapshot = revenueSplit?.school_percentage ?? DEFAULT_SCHOOL_PERCENTAGE
220+
206221
// 1. Pending transaction — our correlation id (transaction_id) round-trips
207222
// back on the webhook (LS) or the verify endpoint (Solana).
208223
const { data: transaction, error: txError } = await supabase
@@ -216,6 +231,7 @@ export async function POST(req: NextRequest) {
216231
currency,
217232
status: 'pending',
218233
payment_provider: providerSlug,
234+
school_percentage_snapshot: schoolPercentageSnapshot,
219235
...(settlement
220236
? {
221237
settlement_currency: settlement.currency,

lib/payments/payouts-owed.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,29 @@
44
* PLATFORM's own account (see `ProviderCapabilities.settlesToPlatformAccount`
55
* in `lib/payments/types.ts`), with no automatic split like Stripe Connect or
66
* Solana. The platform owner owes each school its share and pays it out
7-
* manually; this computes a running balance: all-time collected × the
8-
* tenant's school_percentage, minus all-time manual payouts already marked
9-
* paid. Callers pre-filter transactions to platform-settled providers only —
10-
* this module has no DB/provider knowledge, just arithmetic.
7+
* manually; this computes a running balance: all-time collected minus
8+
* all-time manual payouts already marked paid. Callers pre-filter
9+
* transactions to platform-settled providers only — this module has no
10+
* DB/provider knowledge, just arithmetic.
11+
*
12+
* Owed amounts are computed PER TRANSACTION using the split percentage that
13+
* was in effect when each transaction happened (`schoolPercentageSnapshot`,
14+
* snapshotted at insert time in `app/api/payments/checkout/route.ts` — see
15+
* issue #496), not the tenant's current split applied retroactively to the
16+
* whole sum. A plan change (which rewrites `revenue_splits`) therefore only
17+
* affects transactions created after the change. Transactions predating the
18+
* snapshot column (`schoolPercentageSnapshot: null`) fall back to the
19+
* tenant's current `schoolPercentage` — the same behavior this module had
20+
* before #496, so old data isn't retroactively wrong either way.
1121
*/
1222

23+
/** Used when a tenant has no `revenue_splits` row yet (shouldn't normally happen, but keeps callers from dividing by an absent value). */
24+
export const DEFAULT_SCHOOL_PERCENTAGE = 80
25+
1326
export interface TenantOwedInput {
1427
tenantId: string
1528
tenantName: string
16-
/** revenue_splits.school_percentage for this tenant (0–100). */
29+
/** revenue_splits.school_percentage for this tenant today (0–100); used as the fallback for transactions with no snapshot. */
1730
schoolPercentage: number
1831
}
1932

@@ -22,6 +35,8 @@ export interface PlatformSettledTxn {
2235
paymentProvider: string
2336
/** Successful transaction amount, major units. */
2437
amount: number
38+
/** revenue_splits.school_percentage in effect when this transaction was created (0–100), or null for pre-#496 rows. */
39+
schoolPercentageSnapshot: number | null
2540
}
2641

2742
export interface ManualPayoutRecord {
@@ -35,7 +50,7 @@ export interface TenantOwed {
3550
schoolPercentage: number
3651
/** Sum of platform-settled transaction amounts (100% of what was collected). */
3752
grossCollected: number
38-
/** grossCollected * schoolPercentage / 100 — the school's all-time share. */
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. */
3954
grossOwed: number
4055
/** Sum of manual payouts already recorded as paid for this tenant. */
4156
alreadyPaid: number
@@ -50,10 +65,17 @@ export function computeOwedBalances(
5065
txns: PlatformSettledTxn[],
5166
paidPayouts: ManualPayoutRecord[],
5267
): TenantOwed[] {
68+
const schoolPercentageByTenant = new Map(tenants.map((t) => [t.tenantId, t.schoolPercentage]))
69+
5370
const collectedByTenant = new Map<string, number>()
71+
const owedByTenant = new Map<string, number>()
5472
const byProviderByTenant = new Map<string, Record<string, number>>()
5573
for (const txn of txns) {
5674
collectedByTenant.set(txn.tenantId, (collectedByTenant.get(txn.tenantId) ?? 0) + txn.amount)
75+
76+
const effectivePercentage = txn.schoolPercentageSnapshot ?? schoolPercentageByTenant.get(txn.tenantId) ?? 0
77+
owedByTenant.set(txn.tenantId, (owedByTenant.get(txn.tenantId) ?? 0) + (txn.amount * effectivePercentage) / 100)
78+
5779
const byProvider = byProviderByTenant.get(txn.tenantId) ?? {}
5880
byProvider[txn.paymentProvider] = (byProvider[txn.paymentProvider] ?? 0) + txn.amount
5981
byProviderByTenant.set(txn.tenantId, byProvider)
@@ -66,7 +88,7 @@ export function computeOwedBalances(
6688

6789
return tenants.map((tenant) => {
6890
const grossCollected = collectedByTenant.get(tenant.tenantId) ?? 0
69-
const grossOwed = (grossCollected * tenant.schoolPercentage) / 100
91+
const grossOwed = owedByTenant.get(tenant.tenantId) ?? 0
7092
const alreadyPaid = paidByTenant.get(tenant.tenantId) ?? 0
7193
const netOwed = Math.max(grossOwed - alreadyPaid, 0)
7294
return {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- #496: revenue_splits.school_percentage isn't time-sliced today — the
2+
-- payouts-owed computation applies the tenant's CURRENT split to the
3+
-- entire all-time sum of platform-settled transactions, so any plan change
4+
-- (which rewrites revenue_splits) silently reprices every historical
5+
-- transaction, not just new ones.
6+
--
7+
-- Snapshot the split percentage in effect at the moment each transaction is
8+
-- created (app/api/payments/checkout/route.ts), so a later plan change only
9+
-- affects future transactions. NULL for transactions created before this
10+
-- column existed; lib/payments/payouts-owed.ts falls back to the tenant's
11+
-- current split for those, matching the pre-#496 behavior for old data.
12+
13+
ALTER TABLE transactions
14+
ADD COLUMN school_percentage_snapshot numeric;
15+
16+
COMMENT ON COLUMN transactions.school_percentage_snapshot IS
17+
'revenue_splits.school_percentage in effect for this tenant when this transaction was created. NULL for transactions predating this column (#496) — payout computation falls back to the tenant''s current split for those.';

tests/unit/payouts-owed.test.ts

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ describe('computeOwedBalances', () => {
55
it('single tenant, single provider, nothing paid yet', () => {
66
const result = computeOwedBalances(
77
[{ tenantId: 't1', tenantName: 'School A', schoolPercentage: 80 }],
8-
[{ tenantId: 't1', paymentProvider: 'paypal', amount: 100 }],
8+
[{ tenantId: 't1', paymentProvider: 'paypal', amount: 100, schoolPercentageSnapshot: null }],
99
[],
1010
)
1111
expect(result).toEqual([
@@ -26,9 +26,9 @@ describe('computeOwedBalances', () => {
2626
const result = computeOwedBalances(
2727
[{ tenantId: 't1', tenantName: 'School A', schoolPercentage: 80 }],
2828
[
29-
{ tenantId: 't1', paymentProvider: 'paypal', amount: 100 },
30-
{ tenantId: 't1', paymentProvider: 'paypal', amount: 50 },
31-
{ tenantId: 't1', paymentProvider: 'binance', amount: 25 },
29+
{ tenantId: 't1', paymentProvider: 'paypal', amount: 100, schoolPercentageSnapshot: null },
30+
{ tenantId: 't1', paymentProvider: 'paypal', amount: 50, schoolPercentageSnapshot: null },
31+
{ tenantId: 't1', paymentProvider: 'binance', amount: 25, schoolPercentageSnapshot: null },
3232
],
3333
[],
3434
)
@@ -40,7 +40,7 @@ describe('computeOwedBalances', () => {
4040
it('subtracts already-paid manual payouts from the gross owed', () => {
4141
const result = computeOwedBalances(
4242
[{ tenantId: 't1', tenantName: 'School A', schoolPercentage: 80 }],
43-
[{ tenantId: 't1', paymentProvider: 'paypal', amount: 100 }],
43+
[{ tenantId: 't1', paymentProvider: 'paypal', amount: 100, schoolPercentageSnapshot: null }],
4444
[{ tenantId: 't1', amount: 30 }],
4545
)
4646
expect(result[0].grossOwed).toBe(80)
@@ -51,7 +51,7 @@ describe('computeOwedBalances', () => {
5151
it('sums multiple manual payouts already paid', () => {
5252
const result = computeOwedBalances(
5353
[{ tenantId: 't1', tenantName: 'School A', schoolPercentage: 80 }],
54-
[{ tenantId: 't1', paymentProvider: 'paypal', amount: 100 }],
54+
[{ tenantId: 't1', paymentProvider: 'paypal', amount: 100, schoolPercentageSnapshot: null }],
5555
[{ tenantId: 't1', amount: 30 }, { tenantId: 't1', amount: 50 }],
5656
)
5757
expect(result[0].alreadyPaid).toBe(80)
@@ -61,7 +61,7 @@ describe('computeOwedBalances', () => {
6161
it('floors netOwed at 0 when payouts exceed what was owed (overpay/rounding)', () => {
6262
const result = computeOwedBalances(
6363
[{ tenantId: 't1', tenantName: 'School A', schoolPercentage: 80 }],
64-
[{ tenantId: 't1', paymentProvider: 'paypal', amount: 100 }],
64+
[{ tenantId: 't1', paymentProvider: 'paypal', amount: 100, schoolPercentageSnapshot: null }],
6565
[{ tenantId: 't1', amount: 500 }],
6666
)
6767
expect(result[0].netOwed).toBe(0)
@@ -85,7 +85,7 @@ describe('computeOwedBalances', () => {
8585
it('boundary schoolPercentage=0 → platform keeps everything, nothing owed', () => {
8686
const result = computeOwedBalances(
8787
[{ tenantId: 't1', tenantName: 'School A', schoolPercentage: 0 }],
88-
[{ tenantId: 't1', paymentProvider: 'paypal', amount: 100 }],
88+
[{ tenantId: 't1', paymentProvider: 'paypal', amount: 100, schoolPercentageSnapshot: null }],
8989
[],
9090
)
9191
expect(result[0].netOwed).toBe(0)
@@ -94,7 +94,7 @@ describe('computeOwedBalances', () => {
9494
it('boundary schoolPercentage=100 → school is owed the full amount collected', () => {
9595
const result = computeOwedBalances(
9696
[{ tenantId: 't1', tenantName: 'School A', schoolPercentage: 100 }],
97-
[{ tenantId: 't1', paymentProvider: 'lemonsqueezy', amount: 100 }],
97+
[{ tenantId: 't1', paymentProvider: 'lemonsqueezy', amount: 100, schoolPercentageSnapshot: null }],
9898
[],
9999
)
100100
expect(result[0].netOwed).toBe(100)
@@ -107,8 +107,8 @@ describe('computeOwedBalances', () => {
107107
{ tenantId: 't2', tenantName: 'School B', schoolPercentage: 90 },
108108
],
109109
[
110-
{ tenantId: 't1', paymentProvider: 'paypal', amount: 100 },
111-
{ tenantId: 't2', paymentProvider: 'paypal', amount: 200 },
110+
{ tenantId: 't1', paymentProvider: 'paypal', amount: 100, schoolPercentageSnapshot: null },
111+
{ tenantId: 't2', paymentProvider: 'paypal', amount: 200, schoolPercentageSnapshot: null },
112112
],
113113
[{ tenantId: 't1', amount: 10 }],
114114
)
@@ -124,9 +124,47 @@ describe('computeOwedBalances', () => {
124124
{ tenantId: 't1', tenantName: 'School A', schoolPercentage: 80 },
125125
{ tenantId: 't2', tenantName: 'School B', schoolPercentage: 80 },
126126
],
127-
[{ tenantId: 't1', paymentProvider: 'paypal', amount: 100 }],
127+
[{ tenantId: 't1', paymentProvider: 'paypal', amount: 100, schoolPercentageSnapshot: null }],
128128
[],
129129
)
130130
expect(result).toHaveLength(2)
131131
})
132+
133+
it('#496: a plan change after a sale does not retroactively reprice it — uses the snapshotted split, not the current one', () => {
134+
// School was on business (0% fee → 100% school_percentage) when this $10k sale
135+
// happened, then got auto-downgraded to free (10% fee → 90% school_percentage
136+
// *today*). The historical sale must still be owed at the 100% that was live
137+
// when it happened, not repriced down to 90% just because the tenant's
138+
// current split changed since.
139+
const result = computeOwedBalances(
140+
[{ tenantId: 't1', tenantName: 'School A', schoolPercentage: 90 }],
141+
[{ tenantId: 't1', paymentProvider: 'paypal', amount: 10000, schoolPercentageSnapshot: 100 }],
142+
[],
143+
)
144+
expect(result[0].grossOwed).toBe(10000)
145+
})
146+
147+
it('#496: transactions predating the snapshot column fall back to the tenant\'s current split', () => {
148+
const result = computeOwedBalances(
149+
[{ tenantId: 't1', tenantName: 'School A', schoolPercentage: 80 }],
150+
[{ tenantId: 't1', paymentProvider: 'paypal', amount: 100, schoolPercentageSnapshot: null }],
151+
[],
152+
)
153+
expect(result[0].grossOwed).toBe(80)
154+
})
155+
156+
it('#496: mixes snapshotted and legacy (null-snapshot) transactions correctly in the same tenant', () => {
157+
const result = computeOwedBalances(
158+
[{ tenantId: 't1', tenantName: 'School A', schoolPercentage: 90 }],
159+
[
160+
// Sold at the old 80% split — snapshotted, unaffected by the later change.
161+
{ tenantId: 't1', paymentProvider: 'paypal', amount: 100, schoolPercentageSnapshot: 80 },
162+
// A pre-#496 legacy row with no snapshot — falls back to the current 90%.
163+
{ tenantId: 't1', paymentProvider: 'paypal', amount: 100, schoolPercentageSnapshot: null },
164+
],
165+
[],
166+
)
167+
expect(result[0].grossCollected).toBe(200)
168+
expect(result[0].grossOwed).toBe(80 + 90) // 100*0.8 + 100*0.9
169+
})
132170
})

0 commit comments

Comments
 (0)