Skip to content

EPIC: Provider-agnostic platform billing — let a school pay the platform through any provider, not just Stripe #600

Description

@guillermoscript

EPIC: Provider-agnostic platform billing — let a school pay the platform through any provider, not just Stripe

Summary

The student → school loop is provider-agnostic: 8 providers behind IPaymentProvider (lib/payments/types.ts:434), a static PROVIDER_CAPABILITIES matrix so the app branches on ability never provider identity, one normalized event type (NormalizedBillingEvent, lib/payments/types.ts:378), one applier (dispatchBillingEvent, lib/payments/webhook-dispatch.ts:60), and one unified endpoint (app/api/payments/webhook/[provider]/route.ts).

The school → platform loop (school billing / platform_subscriptions) never got that treatment. It is hard-wired to Stripe at every layer: schema, routes, and server actions. Two consequences, both code-verified 2026-08-05:

  1. A school that cannot get a Stripe account cannot pay us at all, except by offline bank transfer plus a manual super-admin confirmation (confirmManualPayment, app/actions/admin/billing.ts:277). For LATAM creators — an explicitly targeted segment (currency_type carries mxn/cop/clp/pen/ars/brl, docs/MONETIZATION.md) — that is the common case, not the edge case.
  2. The card path is currently unreachable for every plan anyway (see sub-issue on plan prices): nothing in the repo ever writes platform_plans.stripe_price_id_monthly/yearly, so every card upgrade dies on "Stripe price not configured for this plan". The Stripe-shaped schema is what hid this — a provider-agnostic price table with an admin UI makes the same class of bug impossible to ship silently.

This epic makes the platform-billing loop use the machinery the student loop already has. It is a refactor plus one migration, not a new abstraction: no new interface is introduced, lib/payments stays the single contract.

Design note: PayKit normalizes the same domains (checkouts / customers / subscriptions / payments / refunds / invoices + per-provider typed metadata + unified webhooks) and was reviewed as prior art. It is not adopted as the contract — it has no model for platform fee, connected-account settlement, or marketplace split, all of which ProviderCapabilities already encodes (bearsPlatformFee, settlesToPlatformAccount). Its adapters (Mercado Pago, Paystack, Razorpay, Xendit) are worth evaluating later as implementations behind IPaymentProvider, which this epic is a prerequisite for.


Standing context: pre-launch

No deployed app, no real users — the same note #540 opens with. Breaking changes are preferred over migration-safe ones: drop and recreate, no backfills, no compatibility shims, no phased rollouts. The goal is a correct end state for an MVP that can take money, not continuity for rows that do not exist.

What is already built (audited 2026-08-05, do not rebuild)

Verified in source before writing any of the sub-issues, because the gap is narrower than it looks:

The signup → pay funnel is complete end-to-end and dead-ends on one unset column.
/platform-pricing/create-school?plan=X&interval=Y (app/[locale]/create-school/page.tsx:16-27) → carried through signup and OAuth callback (components/tenant/create-school-flow.tsx:37,77) → redirects to /dashboard/admin/billing/upgrade with the plan preselected (line 195-197) → POST /api/stripe/checkout-session (upgrade-page-client.tsx:66). Every step exists. The last one 400s because nothing ever wrote the plan's price id (#602). This epic is finishing a funnel, not building one.

Upgrade / downgrade / cancel / reactivate all existpreviewPlanChange, changePlan, cancelSubscription, reactivateSubscription, requestManualPlanUpgrade, uploadPaymentProof, requestManualRenewal (app/actions/admin/billing.ts), wired into billing-dashboard-client.tsx and upgrade-page-client.tsx, with checkDowngradeLimits pre-flight. Shipped by #458 / #465. They are Stripe-only in their internals (#604), not missing.

The student → school half is already provider-agnostic#280 closed 2026-06-15 and covered plans / subscriptions / the student webhook, not platform billing, which is why this epic exists. components/public/checkout-form.tsx handles inline Stripe, redirect (Lemon Squeezy / PayPal / Binance), QR + Phantom (Solana, solana_subs), instructions (binance_personal) and offline manual; per-tenant enablement lives in tenant_settings via getEnabledPaymentProviders (app/actions/admin/settings.ts:455) and gates the admin product/plan forms. Payout ledger, partial refunds and per-currency balances landed in #493 / #547. That half needs verification (#605) and one gate fixed (#606), not construction.

The cron gap already has an open PR — merge it, don't rewrite it. PR #519 (draft, CONFLICTING) closes #513. It established via the Dokploy API that schedule.list returns [] at every scope, so all seven /api/cron/* routes have never run in production; it schedules them from .github/workflows/cron.yml and adds reconcileAccessCutoff at the two usage-crossing sites. It needs a rebase, a merge, and two repo settings (CRON_SECRET, CRON_BASE_URL). Without it the lapse → grace → downgrade → access-cutoff half of the loop cannot be exercised at all. Tracked as a prerequisite on #605.

Checked against every PR merged and issue closed since 2026-07-05, and against #458 in full. The last month was almost entirely payments work — #458 (plan lifecycle), #493 (payout integrity), #540 (correctness pass) and their ~45 sub-PRs. What they delivered, and why none of it is this epic:

Shipped What it covered Why #600 still stands
#458#474 (#465) in-app school downgrade / cancel / interval switch Replaced "use the Stripe portal" with real in-app plan change + pre-flight limit check Built Stripe-only internallyapp/actions/admin/billing.ts:445,482,537,616,687 still call stripe.subscriptions.* / invoices.createPreview directly on HEAD 43938af2. That is #604, and it exists because #465 shipped the UX first
#458#475/#476 (#463/#464) Student plan-change supersession + self-cancel Student half. Provider-agnostic already
#458#478 (#466), #484 (#482) PayPal implemented in full, Binance Pay, binance_personal Student → school providers only. None of them can be used to pay the platform — that is the gap
#458#471 (#462) Auto-expire lapsed manual platform subscriptions The cron exists; it has never run in production (PR #519 / #513)
#458#480 (#468) Platform billing cleanup: instructions_sent, forceTenantPlanChange drift, hardcoded free split, hardcoded /en/ locale, manual-request price snapshot Touched checkout-session/route.ts (locale) but did not set a single price id. Its item 5 "price snapshot" is the manual-transfer amount, unrelated to stripe_price_id_*#602 is not a duplicate of it
#493#503-#508, #521-#525, #534, #536 Payout ledger, per-currency balances, refund clawback, split snapshot trigger, pagination Student-half money accuracy. Settled
#540#551-#565 Correctness pass incl. #544 (platform webhook read dead field paths), #546/#557 (billing lifecycle reversible), #545/#558 (cancel_at contract) Repaired the Stripe platform webhook and lifecycle in place; never questioned that it is Stripe-shaped
#432#442 (#435), #445 (#434), #448 (#439), #443 (#433) Signup→plan funnel, Settings→Payments, Connect Express, Connect GET entry The funnel this epic finishes. Already built

Nothing merged in the last month touches platform-billing provider-agnosticism. #280 (closed 2026-06-15) did the student half and explicitly scoped platform billing out. So #600 is new work, and #602 is a bug that has survived every one of those passes.

Every money-facing screen already exists. Full sweep of app/[locale]/platform/ and app/[locale]/dashboard/admin/ (2026-08-05):

Surface Route State
Platform revenue platform/revenueget_platform_revenue() RPC built
Payouts owed to schools platform/payouts built (#493)
School-facing payout history dashboard/admin/payouts built — the #493 §2.4 item logged as "deferred" did ship
Billing health / at-risk schools platform/billing-healthgetAtRiskTenants() built (#495/#514)
Manual platform payments platform/billing built
Plan editor platform/plans/plan-editor.tsx built, but has no price-id field (#602)
Tenants / impersonate / referrals platform/tenants, platform/referrals built
School: monetization, revenue, transactions, subscriptions, invoices, payment-requests, billing dashboard/admin/* all built
Connect setup Settings → Payments (StripeConnectCard, #434) built

So this epic adds no new screens beyond a price-id field. It changes what sits behind them.

So the actual remaining gap is one half of one loop: school → platform is Stripe-shaped in schema, routes and actions, and unbuyable today. That is #600.


Where Stripe is hard-wired today

Schema — supabase/migrations/20260217040000_platform_billing.sql

  • platform_plans.stripe_price_id_monthly / stripe_price_id_yearly (lines 14-15) — one provider's price ids as first-class columns, and no plan can hold a second provider's price.
  • platform_subscriptions.stripe_subscription_id / stripe_customer_id (lines 32-33), plus payment_method VARCHAR(50) ... CHECK (payment_method IN ('stripe','manual_transfer')) (lines 36-37) — a two-value enum where the student side has an 8-value provider slug. idx_platform_subscriptions_stripe (line 86) indexes the Stripe column only.
  • tenants.stripe_customer_id (line 75) — one customer id per tenant, provider implied.

Routes

  • app/api/stripe/checkout-session/route.ts — Stripe Checkout only; picks stripe_price_id_yearly/_monthly at lines 79-81 and 400s at 83-88 when unset.
  • app/api/stripe/platform-webhook/route.ts — Stripe event names as the control flow (checkout.session.completed, customer.subscription.updated/deleted, invoice.paid, invoice.payment_failed), writing stripe_subscription_id/stripe_customer_id/payment_method: 'stripe' directly (lines 198-201, 218, 367, 445). Does not go through NormalizedBillingEvent or dispatchBillingEvent, unlike the student webhook.
  • app/api/stripe/billing-portal/route.tsstripe.billingPortal.sessions.create (line 55) is the only "manage my subscription" surface; no provider without a hosted portal has an answer.

Server actions

  • app/actions/admin/billing.tsstripe.subscriptions.retrieve (445), stripe.invoices.createPreview (482), stripe.subscriptions.update (537, 616, 687). previewPlanChange, changePlan, cancelSubscription, reactivateSubscription all assume a Stripe subscription object exists.
  • lib/payments/platform-plan-change.ts:79,148-153 — resolves a plan by matching stripe_price_id_monthly/yearly and calls stripe.subscriptions.update.
  • app/actions/platform/plans.ts:28updatePlatformPlan cannot write price ids at all (its updates type has no such field), so there is no supported way to configure a plan's price for any provider.

Beyond the plumbing: what a school owner experiences today

The schema/route coupling is the cause; this is the effect, and it is what the epic has to actually fix. Verified in the UI layer, not just the backend:

1. There is no provider choice — the UX is binary. components/admin/plan-comparison-table.tsx:25-26 exposes exactly two callbacks: onSelectPlan (→ Stripe Checkout) and onManualTransfer (→ bank-transfer form). A school owner sees "pay by card" or "pay by bank transfer", nothing else. Adding providers is therefore a real UX change — a payment-method step — not just backend plumbing. Any sub-issue that swaps the backend without this leaves the new providers unreachable.

2. Choosing Stripe once locks the school out of every other method, permanently. upgrade-page-client.tsx:131:

onManualTransfer={activeStripeSub ? undefined : handleManualTransfer}

Once a tenant has an active Stripe subscription the bank-transfer path is removed from the UI. A school whose card starts failing — the single most likely reason to want bank transfer — has no way to switch. The prop name activeStripeSub (line 28-29) encodes the assumption at the component boundary.

3. platform_payment_requests is provider-blind. 20260217040000_platform_billing.sql:52-69 — no provider column; the table implicitly means "bank transfer". Its status ladder (pending → instructions_sent → payment_received → confirmed) is genuinely good and fully implemented (sendPaymentInstructions, app/actions/platform/plans.ts:129, shipped in #480 — do not re-file that as missing). The improvement is to make the ladder describe any out-of-band settlement, not only a bank wire.

4. Downgrading to free is a dead end. plan-comparison-table.tsx:241 renders a disabled Cancel to downgrade button for the free plan. Pro→Starter works in-app; Pro→Free tells you to do something and gives you nothing to click. Small, but it is on the path a lapsing school takes.

Target architecture

Concrete end state, so the sub-issues aren't interpreted as "make it abstract somehow":

Admin picks plan + provider
        ↓
POST /api/billing/checkout  ──► getPaymentProvider(slug).createCheckout()
        │                        (gated on supportsHostedCheckout)
        │   price from platform_plan_prices(plan, provider, interval)
        │   customer from tenant_billing_customers(tenant, provider)
        ↓
provider hosted page ──► POST /api/billing/webhook/[provider]
                              ↓ verifyWebhook  (signature)
                              ↓ webhook_events (idempotency)
                              ↓ normalizeWebhookEvent → NormalizedBillingEvent
                              ↓ dispatchPlatformBillingEvent()
                                   ├─ platform_subscriptions (provider-neutral columns)
                                   ├─ tenants.plan / billing_status / billing_period_end
                                   ├─ revenue_splits  ← from the new plan
                                   └─ reconcileAccessCutoff()

This mirrors app/api/payments/webhook/[provider]/route.ts deliberately: same five steps, same idempotency table, same allowlist discipline. One applier, two callers — the student loop's dispatchBillingEvent and platform billing's dispatchPlatformBillingEvent — instead of today's one applier plus 495 lines of parallel Stripe-shaped webhook.

Capabilities carry every branch. Existing: supportsHostedCheckout, supportsNativeSubscriptions, emitsRenewalWebhooks, selfManagedPeriod, supportsPlanChange, isMerchantOfRecord, createsCatalog. Added by #604: supportsCustomerPortal, supportsProrationPreview. Added by #606: requiresConnectedAccount. No === 'stripe' comparison should survive the epic.

Which providers should be able to pay the platform, and why

Not "all eight". Platform billing is us selling SaaS to schools — a different problem from a school selling a course to a student, and only some rails fit:

Provider Take it for platform billing? Why
Stripe Yes — default Native subscriptions, proration, portal, dunning. Best experience where the school can get an account
Lemon Squeezy Yes — highest value after Stripe isMerchantOfRecord: true: LS becomes the legal seller and remits VAT/sales tax for us. Selling SaaS internationally otherwise means us handling tax registration in every market. createsCatalog: false, so the variant id is pasted into platform_plan_prices — exactly what #602's UI is for
Manual transfer Yes — already works, must keep working The only path for a school with no card rail at all. Needs to stay reachable after a Stripe subscription exists (gap 2 above)
Mercado Pago Not in this epic — but this epic is what unblocks it The dominant LATAM rail, and currency_type already carries mxn/cop/clp/pen/ars/brl. Not yet a provider in lib/payments. A PayKit adapter wrapped behind IPaymentProvider is the cheapest route
PayPal Only after #479 Implemented (#478) but never run against real credentials
Solana / solana_subs / binance / binance_personal No Crypto rails suit one-off course sales, not a recurring SaaS subscription with dunning and proration. Keep them student-side

Related improvement, tracked but out of scope

manual earns the platform 0% commission by design. PROVIDER_CAPABILITIES.manual.bearsPlatformFee: false (lib/payments/types.ts:252) — "money never reaches a platform account, so there is no mechanism by which it could take a fee." Correct as implemented, and it means a LATAM school selling entirely by bank transfer pays us only its SaaS subscription, never a transaction fee. That may be the right call for an MVP, or it may be the reason to bill accrued commission on the platform subscription instead. A product decision, not a bug — flagging it here so it is chosen rather than inherited. Do not "fix" it inside this epic.


Non-goals

Definition of done — the MVP money loop closes

  • A school owner signs up, picks a plan, and pays the platform through their choice of provider.
  • They can upgrade, downgrade, cancel and reactivate on any of those providers, and lapsing has consequences (grace → downgrade → access cutoff) that actually fire on a real deploy.
  • Their students pay the school, through any provider the school enables, and the platform's cut lands — Connect fee, on-chain split, or manual payout ledger.
  • No file outside lib/payments/*-provider.ts imports getStripe for platform-billing purposes.
  • A plan can carry prices for more than one provider, configurable by a super admin in the UI, and seeded for local dev.
  • Every claim above walked end-to-end and recorded on MVP acceptance: the complete money loop, both halves, every provider #605 — not inferred from green unit tests (EPIC: Correctness pass over the three shipped epics — #458, #388, #493 #540's finding: "the tests pin the modules; nothing tests the callers").

Phasing — sub-issues

Ordered; each is independently shippable behind the previous one.

  1. Schema: make platform-billing tables provider-agnostic #601 — Schema: make platform-billing tables provider-agnostic (blocks everything below)

  2. Nothing in the repo ever writes a platform plan's price id — every card upgrade 400s #602 — Nothing ever writes a platform plan's price id; every card upgrade 400s 🔴 (the revenue blocker; needs Schema: make platform-billing tables provider-agnostic #601)

  3. Unified checkout + webhook routes for platform billing #603 — Unified checkout + webhook routes for platform billing (needs Schema: make platform-billing tables provider-agnostic #601)

  4. Capability-gate the Stripe-only billing operations (customer portal, proration preview) #604 — Capability-gate the Stripe-only billing operations: portal, proration preview (needs Schema: make platform-billing tables provider-agnostic #601, Unified checkout + webhook routes for platform billing #603)

  5. MVP acceptance: the complete money loop, both halves, every provider #605 — Enable and QA non-Stripe platform billing end-to-end (needs all; also needs Access-cutoff reconciliation depends on a cron that may never run on Dokploy (#494 follow-up) #513 for the lapse/downgrade half)

  6. Student checkout accepts a school whose Stripe onboarding was abandoned #606 — Student checkout accepts a school whose Stripe onboarding was abandoned (independent; student half)

Dependency shape: #601 → {#602, #603} → #604#605, with #606 parallel to all of it.

Ship #602 first. It is the one that turns an already-complete funnel into a working purchase, and it does not need the rest of the epic to be worth doing.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    EPICThis are father issuesenhancementNew feature or requestpaymentsPayment provider / billing related

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions