You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
We just shipped manual payout tracking (/platform/payouts — tracks what the platform owes each school for PayPal/Binance Pay/Lemon Squeezy sales, since those providers settle 100% into the platform's own account with no automatic split) and live-verified it end-to-end (PayPal $10 sale → $8.00 owed at 80% school split → Mark as Paid → $0.00, payouts row inserted correctly).
Auditing what surrounds it — specifically "what happens if a school just stops paying us" — surfaced two separate classes of real gaps, code-verified 2026-07-24:
Non-payment has no functional consequence. A school that stops paying keeps 100% of its existing students/courses/access forever — there is no lockout, no grandfathering decision, just... nothing happens to existing usage. Only new signups/course creation get blocked.
The payout math itself has money-accuracy bugs — revenue split isn't time-sliced (plan changes retroactively reprice historical payouts), transaction amounts get summed across currencies with no grouping, and there's no clawback path if a paid-out transaction is later refunded.
None of this blocks what's already shipped — it's an accounting/enforcement hardening pass before this is trusted for real money at scale.
Part 1 — Non-payment has no real consequence
1.1 Downgrade-to-free never touches existing courses/students/entitlements 🔴
downgradeTenantToFree() (lib/billing/downgrade-tenant.ts:33-59) — triggered by Stripe's customer.subscription.deleted webhook (app/api/stripe/platform-webhook/route.ts:171) or the expire-platform-subscriptions cron (app/api/cron/expire-platform-subscriptions/route.ts:184,205) — only ever writes platform_subscriptions.status, tenants.plan/billing_status/billing_period_end, and revenue_splits. It never queries or writes courses, enrollments, or entitlements.
Course/lesson access is gated exclusively by has_course_access() (supabase/migrations/20260516120000_entitlements_table.sql:64-78), which checks only entitlements.status='active' + expiry — no reference to tenants.plan or any limit, ever.
checkPlanLimits (lib/billing/plan-limits.ts:110-137) is only called from billing-flow actions (app/actions/admin/billing.ts:178,246,434,488) — never from the enrollment or course-creation path. The only creation-time soft caps are new-student-join (app/actions/join-school.ts:36-66) and new-course-create (app/actions/teacher/courses.ts:71-101) — both block only new records, never touch existing ones.
The downgrade notification email (lib/email/templates/plan-downgraded.ts:16) says "your courses and data are safe" — true only because nothing acts on them at all, not because of deliberate grandfather logic. No code decides which students/courses stay active past the new limit; a school with 10,000 students on free plan (50-student limit) keeps all 10,000 fully functional, indefinitely.
Wanted: a deliberate decision, not an accident — either (a) explicitly grandfather existing usage forever (document it, remove the misleading "over limit" UI urgency), or (b) build real enforcement (e.g., read-only mode past N days over limit, or a hard student-count cutoff with clear admin-facing warning before it bites).
1.2 No super-admin visibility into past-due / at-risk schools 🟠
app/[locale]/platform/billing/page.tsx:7,26-40 only lists platform_payment_requests (manual bank-transfer requests) — a Stripe subscriber that goes past_due or gets canceled produces no row there at all.
app/[locale]/platform/tenants/page.tsx:27 selects plan/status but not billing_status — a super admin browsing tenants cannot see who's past-due.
No page/query anywhere aggregates platform_subscriptions.status='past_due' or tenants.billing_status='past_due' for admin visibility (confirmed via full grep of app/[locale]/platform/**).
Wanted: a "Billing health" panel (mirrors the payouts-owed page pattern) — schools currently past_due, days into grace, at risk of imminent downgrade.
computeOwedBalances (lib/payments/payouts-owed.ts:48-83) takes one currentschoolPercentage per tenant and applies it to the entire all-time sum of that tenant's platform-settled transactions. It has no concept of "what split was in effect on the day of each sale."
Every plan change (upgrade, downgrade, the free-plan auto-downgrade from Part 1, or a super-admin forceTenantPlanChange) rewrites revenue_splits for that tenant (lib/billing/downgrade-tenant.ts:52-59, app/api/stripe/platform-webhook/route.ts:87-103, etc.) — and the next payout calculation silently reprices every historical transaction, not just new ones, at the new percentage.
Concrete failure: a school does $10k on the business plan (0% platform fee) then gets auto-downgraded to free (10% fee per supabase/migrations/20260221020000_subscription_expiry_cron.sql:71-72) — the payouts page will then show ~$1k less owed on that already-settled $10k than was actually agreed at the time of sale.
Wanted: snapshot the split percentage onto each transactions row at creation time (or a revenue_splits history table keyed by effective date), and compute owed amounts per-transaction using the split that was live when that transaction happened — not today's split applied retroactively.
2.2 Payouts-owed sums transaction amounts across currencies with no grouping or conversion 🔴
transactions.currency is a real, populated currency_type enum column (confirmed in lib/database.types.ts), but getPayoutsOwed() (app/actions/platform/payouts.ts:32-36) selects tenant_id, payment_provider, amount — currency is dropped entirely — and computeOwedBalances sums raw amount with no currency grouping.
A tenant with both USD and EUR PayPal sales gets a single meaningless summed total (e.g. "$18.00" that's actually $10 + €8).
markPayoutPaid() (app/actions/platform/payouts.ts:65) then hardcodes currency: 'usd' on the inserted payouts row regardless of what was actually collected — the record itself is wrong the moment any non-USD sale is involved.
Wanted: group grossCollected/netOwed by currency (a tenant can owe you $40 AND €15 — those are two numbers, not one), and record the correct currency on each manual payout row. Decide whether to support/display multi-currency conversion or just keep totals strictly separated per currency — either is fine, silent mixing is not.
2.3 No refund/chargeback clawback path 🟡
markPayoutPaid is a one-way ratchet — once a payout is recorded, there is no code path that re-checks whether the underlying transactions it was based on later flip to refunded/failed.
If a PayPal sale gets refunded after you've already paid the school their 80%, the payouts page has no way to reflect that you're now owed back from a future payout (it'll just show $0 owed going forward, silently absorbing the loss).
Wanted: when computing netOwed, subtract refunded amounts from grossCollected (or a dedicated refund-clawback line), so a refund after payout reduces what's owed on the next payout cycle instead of vanishing.
2.4 No overpayment guardrail; schools can't see their own payout status 🟡
markPayoutPaid (app/actions/platform/payouts.ts:57-75) validates only amount > 0 — no check against netOwed, so a typo (e.g. an extra zero) records silently with no confirmation step beyond the dialog's pre-filled suggestion.
There is no tenant-admin-facing view of payout status — school admins have no way to see "the platform owes us $X" or "we were paid $Y on this date" themselves; only the super admin sees this today (deliberately deferred when this feature shipped, flagging here so it's a tracked decision rather than forgotten scope).
Wanted: soft-warn (not block) when recording an amount that differs significantly from netOwed; add a read-only tenant-admin payout-history view.
Part 3 — Deployment gap
3.1 Manual-payouts migration is local-only 🟡
supabase/migrations/20260724120000_manual_payouts.sql has been applied and verified locally but not yet pushed to the cloud project; lib/database.types.ts reflects the local schema only.
Wanted: apply to cloud, regenerate types against cloud, confirm RLS policies on the extended payouts columns behave identically.
Phasing — sub-issues
Phase 1 — non-payment enforcement (decide the actual business rule):
A documented, deliberate answer exists for "what happens to existing students/courses when a school stops paying" — whether that's explicit grandfathering or real enforcement.
A super admin can see, at a glance, which schools are past-due/at-risk without querying the DB directly.
Payout calculations use the revenue split that was in effect at the time of each transaction, not the tenant's current split.
Audit performed 2026-07-24 (code-verified via two independent Explore sweeps + live browser verification of the payouts page against real local data). Related: #458 (plan-change lifecycle epic — shares the downgrade/webhook code paths touched here).
Phase 4 — gaps found verifying the delivered state (added 2026-07-24)
All seven sub-issues above are merged to master and live on the cloud project. A verification pass over the shipped code (see the close-out comment for method) surfaced edge cases the original audit did not cover. All eight are attached as native sub-issues of this epic.
Acceptance criterion "a refund on an already-paid-out transaction is reflected in a future payout" is now met: #511 (PR #521) corrected the arithmetic and #515 (PR #522) made subscription refunds reach it.
Phase 4 closed except #513, whose fix is open as PR #519 and needs repo secret CRON_SECRET + repo variable CRON_BASE_URL before it can run.
supabase/migrations/20260725110000_transaction_split_snapshot_backstop.sql (#512, PR #525) is on master but not applied to the cloud project — cloud schema_migrations stops at 20260725100000. Until it is pushed, transactions.school_percentage_snapshot remains caller-supplied in production and #512's fix is inert there. Same class as §3.1/#500.
Verified sound in this pass
Student page gating (requireCourseAccess + requireRowInCourse across all ten student course routes), the AI chat routes (user-scoped clients, so #509's RLS backstop covers them), computeOwedBalances arithmetic including the #511 and #516 reasoning, the #516 mismatch-retry interaction between the dialog and markPayoutPaid, and billing-health's three-population union (#514). 56/56 unit tests pass across payouts-owed, access-cutoff, billing-health.
EPIC: Payout & non-payment enforcement integrity — revenue-split accuracy and edge cases
Summary
We just shipped manual payout tracking (
/platform/payouts— tracks what the platform owes each school for PayPal/Binance Pay/Lemon Squeezy sales, since those providers settle 100% into the platform's own account with no automatic split) and live-verified it end-to-end (PayPal $10 sale → $8.00 owed at 80% school split → Mark as Paid → $0.00,payoutsrow inserted correctly).Auditing what surrounds it — specifically "what happens if a school just stops paying us" — surfaced two separate classes of real gaps, code-verified 2026-07-24:
None of this blocks what's already shipped — it's an accounting/enforcement hardening pass before this is trusted for real money at scale.
Part 1 — Non-payment has no real consequence
1.1 Downgrade-to-free never touches existing courses/students/entitlements 🔴
downgradeTenantToFree()(lib/billing/downgrade-tenant.ts:33-59) — triggered by Stripe'scustomer.subscription.deletedwebhook (app/api/stripe/platform-webhook/route.ts:171) or theexpire-platform-subscriptionscron (app/api/cron/expire-platform-subscriptions/route.ts:184,205) — only ever writesplatform_subscriptions.status,tenants.plan/billing_status/billing_period_end, andrevenue_splits. It never queries or writescourses,enrollments, orentitlements.has_course_access()(supabase/migrations/20260516120000_entitlements_table.sql:64-78), which checks onlyentitlements.status='active'+ expiry — no reference totenants.planor any limit, ever.checkPlanLimits(lib/billing/plan-limits.ts:110-137) is only called from billing-flow actions (app/actions/admin/billing.ts:178,246,434,488) — never from the enrollment or course-creation path. The only creation-time soft caps are new-student-join (app/actions/join-school.ts:36-66) and new-course-create (app/actions/teacher/courses.ts:71-101) — both block only new records, never touch existing ones.lib/email/templates/plan-downgraded.ts:16) says "your courses and data are safe" — true only because nothing acts on them at all, not because of deliberate grandfather logic. No code decides which students/courses stay active past the new limit; a school with 10,000 students on free plan (50-student limit) keeps all 10,000 fully functional, indefinitely.1.2 No super-admin visibility into past-due / at-risk schools 🟠
app/[locale]/platform/billing/page.tsx:7,26-40only listsplatform_payment_requests(manual bank-transfer requests) — a Stripe subscriber that goespast_dueor gets canceled produces no row there at all.app/[locale]/platform/tenants/page.tsx:27selectsplan/statusbut notbilling_status— a super admin browsing tenants cannot see who's past-due.platform_subscriptions.status='past_due'ortenants.billing_status='past_due'for admin visibility (confirmed via full grep ofapp/[locale]/platform/**).past_due, days into grace, at risk of imminent downgrade.Part 2 — Payout math accuracy
2.1 Revenue split percentage isn't time-sliced — plan changes retroactively reprice historical payouts 🔴
computeOwedBalances(lib/payments/payouts-owed.ts:48-83) takes one currentschoolPercentageper tenant and applies it to the entire all-time sum of that tenant's platform-settled transactions. It has no concept of "what split was in effect on the day of each sale."forceTenantPlanChange) rewritesrevenue_splitsfor that tenant (lib/billing/downgrade-tenant.ts:52-59,app/api/stripe/platform-webhook/route.ts:87-103, etc.) — and the next payout calculation silently reprices every historical transaction, not just new ones, at the new percentage.supabase/migrations/20260221020000_subscription_expiry_cron.sql:71-72) — the payouts page will then show ~$1k less owed on that already-settled $10k than was actually agreed at the time of sale.transactionsrow at creation time (or arevenue_splitshistory table keyed by effective date), and compute owed amounts per-transaction using the split that was live when that transaction happened — not today's split applied retroactively.2.2 Payouts-owed sums transaction amounts across currencies with no grouping or conversion 🔴
transactions.currencyis a real, populatedcurrency_typeenum column (confirmed inlib/database.types.ts), butgetPayoutsOwed()(app/actions/platform/payouts.ts:32-36) selectstenant_id, payment_provider, amount— currency is dropped entirely — andcomputeOwedBalancessums rawamountwith no currency grouping.markPayoutPaid()(app/actions/platform/payouts.ts:65) then hardcodescurrency: 'usd'on the insertedpayoutsrow regardless of what was actually collected — the record itself is wrong the moment any non-USD sale is involved.grossCollected/netOwedby currency (a tenant can owe you $40 AND €15 — those are two numbers, not one), and record the correct currency on each manual payout row. Decide whether to support/display multi-currency conversion or just keep totals strictly separated per currency — either is fine, silent mixing is not.2.3 No refund/chargeback clawback path 🟡
markPayoutPaidis a one-way ratchet — once a payout is recorded, there is no code path that re-checks whether the underlyingtransactionsit was based on later flip torefunded/failed.netOwed, subtract refunded amounts fromgrossCollected(or a dedicated refund-clawback line), so a refund after payout reduces what's owed on the next payout cycle instead of vanishing.2.4 No overpayment guardrail; schools can't see their own payout status 🟡
markPayoutPaid(app/actions/platform/payouts.ts:57-75) validates onlyamount > 0— no check againstnetOwed, so a typo (e.g. an extra zero) records silently with no confirmation step beyond the dialog's pre-filled suggestion.netOwed; add a read-only tenant-admin payout-history view.Part 3 — Deployment gap
3.1 Manual-payouts migration is local-only 🟡
supabase/migrations/20260724120000_manual_payouts.sqlhas been applied and verified locally but not yet pushed to the cloud project;lib/database.types.tsreflects the local schema only.payoutscolumns behave identically.Phasing — sub-issues
Phase 1 — non-payment enforcement (decide the actual business rule):
Phase 2 — payout money-accuracy (real accounting bugs):
Phase 3 — deployment:
Acceptance criteria
Key files
lib/billing/downgrade-tenant.ts·lib/billing/plan-limits.ts·lib/payments/payouts-owed.ts·app/actions/platform/payouts.ts·app/[locale]/platform/payouts/page.tsx·app/[locale]/platform/billing/page.tsx·app/[locale]/platform/tenants/page.tsx·app/api/stripe/platform-webhook/route.ts·app/api/cron/expire-platform-subscriptions/route.ts·supabase/migrations/20260724120000_manual_payouts.sql·supabase/migrations/20260216212440_create_revenue_infrastructure.sqlAudit performed 2026-07-24 (code-verified via two independent Explore sweeps + live browser verification of the payouts page against real local data). Related: #458 (plan-change lifecycle epic — shares the downgrade/webhook code paths touched here).
Phase 4 — gaps found verifying the delivered state (added 2026-07-24)
All seven sub-issues above are merged to
masterand live on the cloud project. A verification pass over the shipped code (see the close-out comment for method) surfaced edge cases the original audit did not cover. All eight are attached as native sub-issues of this epic.Enforcement (§1 follow-ups)
securityPayout accuracy (§2 follow-ups)
Suggested order
/api/cron/*?). If the answer is no, Non-payment produces no access enforcement — over-limit tenants keep full access indefinitely #494 has never fired in production and neither has platform-subscription expiry from Auto-expire lapsed manual-transfer platform subscriptions (cron) #462.Acceptance criterion "a refund on an already-paid-out transaction is reflected in a future payout" is now met: #511 (PR #521) corrected the arithmetic and #515 (PR #522) made subscription refunds reach it.
Phase 4 closed except #513, whose fix is open as PR #519 and needs repo secret
CRON_SECRET+ repo variableCRON_BASE_URLbefore it can run.Phase 5 — gaps found reviewing Phase 4's shipped code (added 2026-07-25)
A second pass, this time reading the delivered code rather than the checkboxes, found three more. All attached as sub-issues.
enrollmentsrow — bypasses entitlements andaccess_cutoff_at🔴security— Lesson/exercise/exam pages have no access gate — #494 cutoff is a no-op there and any tenant member can read paid content #509 and Non-payment produces no access enforcement — over-limit tenants keep full access indefinitely #494 follow-up:createAdminClient()means the20260724150000RLS backstop cannot apply, andenrollmentsINSERT RLS carries no entitlement predicate, so the gate is self-issuablegetPayoutsOwedreads the ledger unpaginated — a PostgREST row cap silently misstates balances 🟡 — latent (cloud holds 0 transactions today), but truncation is indistinguishable from a complete read and moves the balance in either directionAlso adopted into this epic:
transactionsRLS restricts rows but not columns — a student can self-insert asuccessfulsale 🔴security— filed separately; it forges both free enrollment and payout liability, so it belongs to the payout-integrity work hereDeployment gap
supabase/migrations/20260725110000_transaction_split_snapshot_backstop.sql(#512, PR #525) is onmasterbut not applied to the cloud project — cloudschema_migrationsstops at20260725100000. Until it is pushed,transactions.school_percentage_snapshotremains caller-supplied in production and #512's fix is inert there. Same class as §3.1/#500.Verified sound in this pass
Student page gating (
requireCourseAccess+requireRowInCourseacross all ten student course routes), the AI chat routes (user-scoped clients, so #509's RLS backstop covers them),computeOwedBalancesarithmetic including the #511 and #516 reasoning, the #516 mismatch-retry interaction between the dialog andmarkPayoutPaid, and billing-health's three-population union (#514). 56/56 unit tests pass acrosspayouts-owed,access-cutoff,billing-health.