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
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:
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.
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 behindIPaymentProvider, 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 exist — previewPlanChange, 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:
Replaced "use the Stripe portal" with real in-app plan change + pre-flight limit check
Built Stripe-only internally — app/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
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
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):
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.
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.ts — stripe.billingPortal.sessions.create (line 55) is the only "manage my subscription" surface; no provider without a hosted portal has an answer.
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:28 — updatePlatformPlan 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:
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 disabledCancel 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
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
Not touching the student → school loop; it is already agnostic.
Not adding a new provider in this epic. It makes provider choice possible for platform billing; adding Mercado Pago (or wrapping a PayKit adapter) is follow-up work.
Not changing pricing, plan limits, feature gating, or get_plan_features.
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.
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.
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 staticPROVIDER_CAPABILITIESmatrix 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:confirmManualPayment,app/actions/admin/billing.ts:277). For LATAM creators — an explicitly targeted segment (currency_typecarriesmxn/cop/clp/pen/ars/brl,docs/MONETIZATION.md) — that is the common case, not the edge case.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/paymentsstays 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 whichProviderCapabilitiesalready encodes (bearsPlatformFee,settlesToPlatformAccount). Its adapters (Mercado Pago, Paystack, Razorpay, Xendit) are worth evaluating later as implementations behindIPaymentProvider, 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/upgradewith 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 exist —
previewPlanChange,changePlan,cancelSubscription,reactivateSubscription,requestManualPlanUpgrade,uploadPaymentProof,requestManualRenewal(app/actions/admin/billing.ts), wired intobilling-dashboard-client.tsxandupgrade-page-client.tsx, withcheckDowngradeLimitspre-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.tsxhandles inline Stripe, redirect (Lemon Squeezy / PayPal / Binance), QR + Phantom (Solana, solana_subs), instructions (binance_personal) and offline manual; per-tenant enablement lives intenant_settingsviagetEnabledPaymentProviders(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.listreturns[]at every scope, so all seven/api/cron/*routes have never run in production; it schedules them from.github/workflows/cron.ymland addsreconcileAccessCutoffat 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:
app/actions/admin/billing.ts:445,482,537,616,687still callstripe.subscriptions.*/invoices.createPreviewdirectly on HEAD43938af2. That is #604, and it exists because #465 shipped the UX firstinstructions_sent,forceTenantPlanChangedrift, hardcoded free split, hardcoded/en/locale, manual-request price snapshotcheckout-session/route.ts(locale) but did not set a single price id. Its item 5 "price snapshot" is the manual-transfer amount, unrelated tostripe_price_id_*— #602 is not a duplicate of itNothing 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/andapp/[locale]/dashboard/admin/(2026-08-05):platform/revenue→get_platform_revenue()RPCplatform/payoutsdashboard/admin/payoutsplatform/billing-health→getAtRiskTenants()platform/billingplatform/plans/plan-editor.tsxplatform/tenants,platform/referralsdashboard/admin/*StripeConnectCard, #434)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.sqlplatform_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), pluspayment_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; picksstripe_price_id_yearly/_monthlyat 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), writingstripe_subscription_id/stripe_customer_id/payment_method: 'stripe'directly (lines 198-201, 218, 367, 445). Does not go throughNormalizedBillingEventordispatchBillingEvent, unlike the student webhook.app/api/stripe/billing-portal/route.ts—stripe.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.ts—stripe.subscriptions.retrieve(445),stripe.invoices.createPreview(482),stripe.subscriptions.update(537, 616, 687).previewPlanChange,changePlan,cancelSubscription,reactivateSubscriptionall assume a Stripe subscription object exists.lib/payments/platform-plan-change.ts:79,148-153— resolves a plan by matchingstripe_price_id_monthly/yearlyand callsstripe.subscriptions.update.app/actions/platform/plans.ts:28—updatePlatformPlancannot write price ids at all (itsupdatestype 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-26exposes exactly two callbacks:onSelectPlan(→ Stripe Checkout) andonManualTransfer(→ 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: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_requestsis 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:241renders a disabledCancel to downgradebutton 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":
This mirrors
app/api/payments/webhook/[provider]/route.tsdeliberately: same five steps, same idempotency table, same allowlist discipline. One applier, two callers — the student loop'sdispatchBillingEventand platform billing'sdispatchPlatformBillingEvent— 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:
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 intoplatform_plan_prices— exactly what #602's UI is forcurrency_typealready carriesmxn/cop/clp/pen/ars/brl. Not yet a provider inlib/payments. A PayKit adapter wrapped behindIPaymentProvideris the cheapest routeRelated improvement, tracked but out of scope
manualearns 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
get_plan_features.Definition of done — the MVP money loop closes
lib/payments/*-provider.tsimportsgetStripefor platform-billing purposes.Phasing — sub-issues
Ordered; each is independently shippable behind the previous one.
Schema: make platform-billing tables provider-agnostic #601 — Schema: make platform-billing tables provider-agnostic (blocks everything below)
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)
Unified checkout + webhook routes for platform billing #603 — Unified checkout + webhook routes for platform billing (needs Schema: make platform-billing tables provider-agnostic #601)
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)
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)
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.