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
| Action | Route | Audit |
|---|---|---|
| Get subscription | GET /api/v1/platform/organizations/{id}/subscription | (read) |
| Change plan | PATCH /api/v1/platform/organizations/{id}/subscription/plan | platform.org.plan_changed |
| Extend trial | POST /api/v1/platform/organizations/{id}/subscription/extend-trial | platform.org.trial_extended |
| Cancel | POST /api/v1/platform/organizations/{id}/subscription/cancel | platform.org.subscription_cancelled |
| List invoices | GET /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:
| Adapter | Status | Notes |
|---|---|---|
| Fake | ✅ Fully implemented | In-memory state with auto-seeding so admin smoke tests work without a checkout. |
| Null | ⚠️ Partial | getSubscriptionSummary returns null, listInvoices returns empty. Mutations error. |
| Stripe | ❌ Stubs | Wire each method to the Stripe SDK (stripe.subscriptions.update, stripe.invoices.list). |
| Polar | ❌ Stubs | Wire to Polar's subscriptions API. |
| MercadoPago | ❌ Stubs | Wire 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.statusflips tocanceledandcanceledAtis set.mode: 'at_period_end'→ keeps the subscription active but flagscancelAtPeriodEnd: true. The provider's webhook eventually delivers asubscription.canceledevent when the period rolls over.