feat(payments): manual payout tracking for single-account providers (#493) - #503
Merged
Merged
Conversation
…493) 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>
Owner
Author
guillermoscript
marked this pull request as ready for review
July 24, 2026 14:38
This was referenced Jul 24, 2026
fix(payments): time-slice revenue split so plan changes don't reprice historical payouts (#496)
#504
Merged
This was referenced Jul 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Description
Part of epic #493 (Payout & non-payment enforcement integrity). This is the foundational piece the epic's Phase 2/3 sub-issues (#496–#500) build on top of — it had been implemented and live-verified locally but never actually committed anywhere, so nothing in the epic's Phase 2/3 list could be worked on until it existed on a branch.
PayPal, Binance Pay (merchant/hosted), and Lemon Squeezy all settle 100% of every sale into the platform's own account — unlike Stripe Connect (per-tenant connected account, automatic
application_fee_amountsplit) or Solana (on-chain split in the same transaction). For those three providers, the school's revenue share has to be paid out manually, and thepayoutstable existed in the schema but nothing had ever inserted into it.Changes made
lib/payments/types.ts— newProviderCapabilities.settlesToPlatformAccountflag, set per-provider (trueforpaypal,lemonsqueezy,binance;falseforstripe,solana,solana_subs,manual,binance_personal) — the single source of truth for which providers route through this manual-payout path.lib/payments/payouts-owed.ts— purecomputeOwedBalances(): gross collected per platform-settled provider × the tenant'srevenue_splits.school_percentage, minus manual payouts already recorded, per tenant. No DB access, fully unit-tested in isolation.app/actions/platform/payouts.ts—getPayoutsOwed()(super-admin-gated, wires the DB reads to the pure function) andmarkPayoutPaid()(inserts apayoutsrow withpayout_method: 'manual').app/[locale]/platform/payouts/page.tsx+components/platform/mark-payout-paid-dialog.tsx— metric cards (currently owed / collected / paid out all-time) + a by-school table with a "Mark as Paid" action, mirroring the visual pattern already used by the other/platform/**admin pages.components/platform-sidebar.tsx— new "Payouts" nav item.supabase/migrations/20260724120000_manual_payouts.sql— extends the previously-deadpayoutstable withpayout_method(stripe_connect|manual),note,recorded_by, and makesperiod_start/period_endnullable (manual payouts are a running balance, not tied to a Connect payout period).Type of change
Part of #493 (not closing it — the epic's Phase 2 money-accuracy issues #496–#499 and Phase 3 deployment issue #500 are follow-up work building on this).
Testing
npx vitest run tests/unit/payouts-owed.test.ts— 10/10 passing.npm run test:unit— 269/269 passing, no regressions.npm run typecheck— clean (also fixed one pre-existinganyinlib/payments/types.tsthat lint-staged surfaced because the file was touched, unrelated to this feature's logic).npm run lint— no errors on any changed file.npm run build— succeeds;/platform/payoutsregistered as a route.db:reset, logged in as a super admin (owner@e2etest.com), loaded/platform/payouts— metric cards, empty-state table, and the disabled "Mark as Paid" state (at $0 owed) all render correctly. A full PayPal-sale-to-payout flow was live-verified in an earlier local session (see epic EPIC: Payout & non-payment enforcement integrity — revenue-split accuracy and edge cases #493's description: $10 sale → $8.00 owed at 80% split → Mark as Paid → $0.00,payoutsrow inserted correctly) — not re-run here since the computation logic is what changed shape, and that's covered by the unit tests.Known gaps, tracked as follow-up issues, not fixed here: revenue split isn't time-sliced (#496), amounts aren't grouped by currency (#497), no refund/chargeback clawback (#498), no overpayment guardrail or tenant-facing visibility (#499). This PR intentionally ships the feature as originally built so those can be reviewed and fixed as independent, reviewable diffs rather than one large change.
QA verification steps
owner@e2etest.com/password123) and open/platform/payouts.amount × school_percentage.Screenshot
A screenshot of the empty-state
/platform/payoutspage is attached as a PR comment.