SaaS Starter
Platform admin

Billing admin

Cross-tenant subscription management — change plan, extend trial, cancel, list invoices.

/admin/billing lists every org with a link into /admin/billing/[orgId] where the per-org subscription summary, action panel, and invoice history live.

Routes

ActionRouteAudit
Get subscriptionGET /api/v1/platform/organizations/{id}/subscription(read)
Change planPATCH /api/v1/platform/organizations/{id}/subscription/planplatform.org.plan_changed
Extend trialPOST /api/v1/platform/organizations/{id}/subscription/extend-trialplatform.org.trial_extended
CancelPOST /api/v1/platform/organizations/{id}/subscription/cancelplatform.org.subscription_cancelled
List invoicesGET /api/v1/platform/organizations/{id}/invoices(read)

All mutations chain through authMiddleware → requirePlatformAdmin → writeLimiter.

Adapter support matrix

The five admin operations are added to IPaymentProvider in apps/server/src/modules/billing/application/ports/payment-provider.ts. Out of the box:

AdapterStatusNotes
Fake✅ Fully implementedIn-memory state with auto-seeding so admin smoke tests work without a checkout.
Null⚠️ PartialgetSubscriptionSummary returns null, listInvoices returns empty. Mutations error.
Stripe❌ StubsWire each method to the Stripe SDK (stripe.subscriptions.update, stripe.invoices.list).
Polar❌ StubsWire to Polar's subscriptions API.
MercadoPago❌ StubsWire to /preapproval/{id} + /v1/payments/search.

Stubs return InfrastructureError("X is not implemented by the Y adapter; ...") via the shared helper at apps/server/src/modules/billing/infrastructure/providers/admin-unsupported.ts. Replace each stub with a real SDK call before going to production with that provider.

Org → customer resolution

The boilerplate's billing model is per-user: Subscription.customerInternalId === userId. Every admin operation resolves the target via org → owner-member's userId in apps/server/src/modules/billing/application/use-cases/admin/resolve-org-customer.ts. When you migrate to per-org billing, that file is the only place that has to change.

Trial extension caps

Trial extension is bounded to 1–90 days server-side (ExtendTrialUseCase) and the Fake adapter (extendTrial method). The Fake provider uses the existing trialEndsAt as the base if set, otherwise Date.now(), then adds N days.

Cancellation modes

  • mode: 'immediate' → ends access right away. status flips to canceled and canceledAt is set.
  • mode: 'at_period_end' → keeps the subscription active but flags cancelAtPeriodEnd: true. The provider's webhook eventually delivers a subscription.canceled event when the period rolls over.

On this page