Skip to content

Commit f14aace

Browse files
feat(payments): manual payout tracking for single-account providers (#493) (#503)
PayPal, Binance Pay (merchant), and Lemon Squeezy settle 100% of every sale into the platform's own account with no automatic split (unlike Stripe Connect or the on-chain Solana split) — the school's share has to be paid out manually, and the previously dead `payouts` table never recorded that. Adds /platform/payouts (metric cards + by-school table, mirroring the existing platform admin page pattern) backed by a pure computeOwedBalances() function: gross collected per platform-settled provider, minus manual payouts already recorded, per tenant. `ProviderCapabilities.settlesToPlatformAccount` is the single source of truth for which providers route through this path. Live-verified against a real PayPal sale locally (issue #493 epic notes: $10 sale -> $8.00 owed at 80% split -> Mark as Paid -> $0.00, payouts row inserted correctly). Known gaps in the money math (revenue split not time-sliced, no currency grouping, no refund clawback, no overpayment guardrail) are tracked as epic #493's Phase 2 sub-issues (#496-#499) and addressed in follow-up commits; deploying the migration to cloud is #500. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent d2184a2 commit f14aace

16 files changed

Lines changed: 595 additions & 1 deletion
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { getPayoutsOwed } from '@/app/actions/platform/payouts'
2+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
3+
import { MarkPayoutPaidDialog } from '@/components/platform/mark-payout-paid-dialog'
4+
import {
5+
IconCoin,
6+
IconReportMoney,
7+
IconWalletOff,
8+
} from '@tabler/icons-react'
9+
10+
const usd = (n: number) =>
11+
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n ?? 0)
12+
13+
const PROVIDER_LABEL: Record<string, string> = {
14+
paypal: 'PayPal',
15+
binance: 'Binance Pay',
16+
lemonsqueezy: 'Lemon Squeezy',
17+
}
18+
19+
export default async function PlatformPayoutsPage() {
20+
const owed = await getPayoutsOwed()
21+
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
26+
27+
const metricCards = [
28+
{
29+
title: 'Currently Owed',
30+
value: usd(totalOwed),
31+
sub: `${schoolsOwed} school${schoolsOwed === 1 ? '' : 's'} awaiting payout`,
32+
icon: IconWalletOff,
33+
bg: 'bg-amber-50 dark:bg-amber-950/40',
34+
iconColor: 'text-amber-600 dark:text-amber-400',
35+
},
36+
{
37+
title: 'Collected (single-account providers)',
38+
value: usd(totalCollected),
39+
sub: 'PayPal, Binance Pay, Lemon Squeezy — 100% lands in your account',
40+
icon: IconCoin,
41+
bg: 'bg-blue-50 dark:bg-blue-950/40',
42+
iconColor: 'text-blue-600 dark:text-blue-400',
43+
},
44+
{
45+
title: 'Paid Out (all time)',
46+
value: usd(totalPaidOut),
47+
sub: 'Manually recorded payouts to schools',
48+
icon: IconReportMoney,
49+
bg: 'bg-emerald-50 dark:bg-emerald-950/40',
50+
iconColor: 'text-emerald-600 dark:text-emerald-400',
51+
},
52+
]
53+
54+
return (
55+
<main className="flex-1 px-4 py-6 sm:px-6 lg:px-8" data-testid="platform-payouts">
56+
<div className="mb-8">
57+
<h1 className="text-2xl font-bold tracking-tight">Payouts</h1>
58+
<p className="text-sm text-muted-foreground mt-1">
59+
PayPal, Binance Pay, and Lemon Squeezy don&apos;t split automatically — 100% of every sale
60+
lands in your account. This is what you owe each school back, based on their revenue split.
61+
</p>
62+
</div>
63+
64+
<div className="mb-8 grid gap-3 sm:grid-cols-3" data-testid="payouts-metrics">
65+
{metricCards.map((card) => (
66+
<Card key={card.title} className="relative overflow-hidden">
67+
<CardContent className="p-5">
68+
<div className="flex items-start justify-between">
69+
<div className="min-w-0 flex-1">
70+
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
71+
{card.title}
72+
</p>
73+
<p className="mt-2 text-2xl font-bold tracking-tight tabular-nums" data-testid="metric-value">
74+
{card.value}
75+
</p>
76+
<p className="mt-1 text-[11px] text-muted-foreground/70">{card.sub}</p>
77+
</div>
78+
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${card.bg}`}>
79+
<card.icon className={`h-[18px] w-[18px] ${card.iconColor}`} strokeWidth={1.75} />
80+
</div>
81+
</div>
82+
</CardContent>
83+
</Card>
84+
))}
85+
</div>
86+
87+
<Card data-testid="payouts-by-tenant">
88+
<CardHeader>
89+
<CardTitle>By school</CardTitle>
90+
</CardHeader>
91+
<CardContent>
92+
{owed.length === 0 ? (
93+
<p className="text-muted-foreground text-sm">No schools yet.</p>
94+
) : (
95+
<div className="overflow-x-auto">
96+
<table className="w-full text-sm">
97+
<thead>
98+
<tr className="border-b text-left text-[11px] uppercase tracking-wider text-muted-foreground">
99+
<th className="pb-2 font-medium">School</th>
100+
<th className="pb-2 font-medium">Providers</th>
101+
<th className="pb-2 text-right font-medium">Collected</th>
102+
<th className="pb-2 text-right font-medium">School %</th>
103+
<th className="pb-2 text-right font-medium">Paid so far</th>
104+
<th className="pb-2 text-right font-medium">Owed</th>
105+
<th className="pb-2 text-right font-medium"></th>
106+
</tr>
107+
</thead>
108+
<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>
112+
<td className="py-2.5 text-muted-foreground">
113+
{Object.keys(t.byProvider).length === 0
114+
? '—'
115+
: Object.keys(t.byProvider).map((p) => PROVIDER_LABEL[p] ?? p).join(', ')}
116+
</td>
117+
<td className="py-2.5 text-right tabular-nums">{usd(t.grossCollected)}</td>
118+
<td className="py-2.5 text-right tabular-nums text-muted-foreground">
119+
{t.schoolPercentage}%
120+
</td>
121+
<td className="py-2.5 text-right tabular-nums text-muted-foreground">
122+
{usd(t.alreadyPaid)}
123+
</td>
124+
<td className="py-2.5 text-right tabular-nums font-medium text-amber-600 dark:text-amber-400">
125+
{usd(t.netOwed)}
126+
</td>
127+
<td className="py-2.5 text-right">
128+
<MarkPayoutPaidDialog
129+
tenantId={t.tenantId}
130+
tenantName={t.tenantName}
131+
netOwed={t.netOwed}
132+
/>
133+
</td>
134+
</tr>
135+
))}
136+
</tbody>
137+
</table>
138+
</div>
139+
)}
140+
</CardContent>
141+
</Card>
142+
143+
<p className="mt-6 text-[11px] text-muted-foreground/70">
144+
Stripe and Solana sales already split automatically and never appear here. Binance Pay
145+
(personal account) and manual/offline sales settle straight to the school and also never
146+
appear here — only PayPal, Binance Pay (merchant), and Lemon Squeezy do, since those settle
147+
100% into your account today.
148+
</p>
149+
</main>
150+
)
151+
}

app/actions/platform/payouts.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
'use server'
2+
3+
import { createClient } from '@/lib/supabase/server'
4+
import { createAdminClient } from '@/lib/supabase/admin'
5+
import { isSuperAdmin } from '@/lib/supabase/get-user-role'
6+
import { revalidatePath } from 'next/cache'
7+
import { getCurrentUserId } from '@/lib/supabase/tenant'
8+
import { PROVIDER_CAPABILITIES, type PaymentProvider } from '@/lib/payments/types'
9+
import { computeOwedBalances, type TenantOwed } from '@/lib/payments/payouts-owed'
10+
11+
async function verifySuperAdmin() {
12+
const supabase = await createClient()
13+
const userId = await getCurrentUserId()
14+
if (!userId) throw new Error('Not authenticated')
15+
if (!(await isSuperAdmin())) throw new Error('Super admin only')
16+
return userId
17+
}
18+
19+
const PLATFORM_SETTLED_PROVIDERS = (Object.keys(PROVIDER_CAPABILITIES) as PaymentProvider[]).filter(
20+
(provider) => PROVIDER_CAPABILITIES[provider].settlesToPlatformAccount
21+
)
22+
23+
const DEFAULT_SCHOOL_PERCENTAGE = 80
24+
25+
export async function getPayoutsOwed(): Promise<TenantOwed[]> {
26+
await verifySuperAdmin()
27+
const admin = createAdminClient()
28+
29+
const [{ data: tenants }, { data: splits }, { data: txns }, { data: paid }] = await Promise.all([
30+
admin.from('tenants').select('id, name'),
31+
admin.from('revenue_splits').select('tenant_id, school_percentage'),
32+
admin
33+
.from('transactions')
34+
.select('tenant_id, payment_provider, amount')
35+
.eq('status', 'successful')
36+
.in('payment_provider', PLATFORM_SETTLED_PROVIDERS),
37+
admin.from('payouts').select('tenant_id, amount').eq('payout_method', 'manual').eq('status', 'paid'),
38+
])
39+
40+
const schoolPercentageByTenant = new Map(
41+
(splits || []).map((s) => [s.tenant_id, s.school_percentage as number])
42+
)
43+
44+
return computeOwedBalances(
45+
(tenants || []).map((t) => ({
46+
tenantId: t.id,
47+
tenantName: t.name,
48+
schoolPercentage: schoolPercentageByTenant.get(t.id) ?? DEFAULT_SCHOOL_PERCENTAGE,
49+
})),
50+
(txns || [])
51+
.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 })),
53+
(paid || []).map((p) => ({ tenantId: p.tenant_id, amount: p.amount }))
54+
)
55+
}
56+
57+
export async function markPayoutPaid(tenantId: string, amount: number, note?: string) {
58+
const userId = await verifySuperAdmin()
59+
if (!(amount > 0)) throw new Error('Amount must be positive')
60+
61+
const admin = createAdminClient()
62+
const { error } = await admin.from('payouts').insert({
63+
tenant_id: tenantId,
64+
amount,
65+
currency: 'usd',
66+
status: 'paid',
67+
payout_method: 'manual',
68+
paid_at: new Date().toISOString(),
69+
recorded_by: userId,
70+
note: note || null,
71+
})
72+
if (error) throw new Error(error.message)
73+
74+
revalidatePath('/platform/payouts')
75+
}

components/platform-sidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
IconSchool,
1515
IconSettings,
1616
IconUsers,
17+
IconWallet,
1718
} from "@tabler/icons-react"
1819
import {
1920
Sidebar,
@@ -49,6 +50,7 @@ export function PlatformSidebar({ pendingBillingCount = 0, atRiskCount = 0, ...p
4950
{ title: "Overview", href: "/platform", icon: IconChartBar },
5051
{ title: "Tenants", href: "/platform/tenants", icon: IconSchool },
5152
{ title: "Revenue", href: "/platform/revenue", icon: IconReportMoney },
53+
{ title: "Payouts", href: "/platform/payouts", icon: IconWallet },
5254
{ title: "Billing", href: "/platform/billing", icon: IconCreditCard, badge: pendingBillingCount },
5355
{ title: "Billing Health", href: "/platform/billing-health", icon: IconAlertTriangle, badge: atRiskCount },
5456
{ title: "Plans", href: "/platform/plans", icon: IconBuildingStore },
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"use client"
2+
3+
import { useState } from "react"
4+
import { useRouter } from "next/navigation"
5+
import { toast } from "sonner"
6+
import { Button } from "@/components/ui/button"
7+
import {
8+
Dialog,
9+
DialogContent,
10+
DialogFooter,
11+
DialogHeader,
12+
DialogTitle,
13+
} from "@/components/ui/dialog"
14+
import { Input } from "@/components/ui/input"
15+
import { Label } from "@/components/ui/label"
16+
import { Textarea } from "@/components/ui/textarea"
17+
import { markPayoutPaid } from "@/app/actions/platform/payouts"
18+
19+
interface Props {
20+
tenantId: string
21+
tenantName: string
22+
netOwed: number
23+
}
24+
25+
export function MarkPayoutPaidDialog({ tenantId, tenantName, netOwed }: Props) {
26+
const router = useRouter()
27+
const [open, setOpen] = useState(false)
28+
const [amount, setAmount] = useState(netOwed.toFixed(2))
29+
const [note, setNote] = useState('')
30+
const [loading, setLoading] = useState(false)
31+
32+
async function handleConfirm() {
33+
const parsed = Number(amount)
34+
if (!(parsed > 0)) {
35+
toast.error('Enter a positive amount')
36+
return
37+
}
38+
setLoading(true)
39+
try {
40+
await markPayoutPaid(tenantId, parsed, note.trim() || undefined)
41+
toast.success(`Recorded $${parsed.toFixed(2)} paid to ${tenantName}`)
42+
setOpen(false)
43+
setNote('')
44+
router.refresh()
45+
} catch (e) {
46+
toast.error(e instanceof Error ? e.message : 'Failed to record payout')
47+
} finally {
48+
setLoading(false)
49+
}
50+
}
51+
52+
return (
53+
<>
54+
<Button
55+
size="sm"
56+
variant="outline"
57+
onClick={() => setOpen(true)}
58+
disabled={netOwed <= 0}
59+
data-testid="mark-paid-btn"
60+
>
61+
Mark as Paid
62+
</Button>
63+
64+
<Dialog open={open} onOpenChange={setOpen}>
65+
<DialogContent data-testid="mark-paid-dialog">
66+
<DialogHeader>
67+
<DialogTitle>Record payout to {tenantName}</DialogTitle>
68+
</DialogHeader>
69+
<div className="space-y-3 py-2">
70+
<div className="space-y-1.5">
71+
<Label htmlFor="payout-amount">Amount paid (USD)</Label>
72+
<Input
73+
id="payout-amount"
74+
type="number"
75+
step="0.01"
76+
min="0"
77+
value={amount}
78+
onChange={(e) => setAmount(e.target.value)}
79+
data-testid="mark-paid-amount-input"
80+
/>
81+
</div>
82+
<div className="space-y-1.5">
83+
<Label htmlFor="payout-note">Note (optional)</Label>
84+
<Textarea
85+
id="payout-note"
86+
placeholder="e.g. wire reference, date sent…"
87+
value={note}
88+
onChange={(e) => setNote(e.target.value)}
89+
rows={2}
90+
data-testid="mark-paid-note-input"
91+
/>
92+
</div>
93+
</div>
94+
<DialogFooter>
95+
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
96+
<Button onClick={handleConfirm} disabled={loading} data-testid="confirm-mark-paid-btn">
97+
{loading ? 'Recording…' : 'Record Payout'}
98+
</Button>
99+
</DialogFooter>
100+
</DialogContent>
101+
</Dialog>
102+
</>
103+
)
104+
}

lib/payments/binance-personal-provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export class BinancePersonalProvider implements IPaymentProvider {
9999
selfManagedPeriod: true,
100100
createsCatalog: false,
101101
supportsPlanChange: false,
102+
settlesToPlatformAccount: false,
102103
}
103104

104105
private readonly apiKey?: string

lib/payments/binance-provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export class BinancePayProvider implements IPaymentProvider {
6868
selfManagedPeriod: true,
6969
createsCatalog: false,
7070
supportsPlanChange: false,
71+
settlesToPlatformAccount: true,
7172
}
7273

7374
private readonly apiKey: string

lib/payments/lemonsqueezy-provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export class LemonSqueezyProvider implements IPaymentProvider {
4949
selfManagedPeriod: false,
5050
createsCatalog: false,
5151
supportsPlanChange: true,
52+
settlesToPlatformAccount: true,
5253
}
5354

5455
private readonly apiKey: string

lib/payments/manual-provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export class ManualPaymentProvider implements IPaymentProvider {
3030
selfManagedPeriod: true,
3131
createsCatalog: false,
3232
supportsPlanChange: false,
33+
settlesToPlatformAccount: false,
3334
}
3435

3536
convertAmount(amount: number, fromUnit: 'base' | 'major'): number {

0 commit comments

Comments
 (0)