Billing
Stripe, Mercado Pago or Polar — provider-agnostic.
Strategy
PAYMENT_PROVIDER in env decides. The PaymentProvider interface abstracts checkout, webhooks, subscriptions. Switching providers = one commit.
interface PaymentProvider {
createCheckout(input): Promise<Result<CheckoutSession, BillingError>>;
handleWebhook(req): Promise<Result<DomainEvent, BillingError>>;
cancelSubscription(id): Promise<Result<void, BillingError>>;
}Supported providers
Stripe
PAYMENT_PROVIDER=stripe. Vars: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET. Webhook: POST /webhooks/stripe.
Mercado Pago
PAYMENT_PROVIDER=mercado-pago. Vars: MERCADO_PAGO_ACCESS_TOKEN, MERCADO_PAGO_WEBHOOK_SECRET. Built for LATAM — supports Pix, MercadoPago wallet, installments.
Polar
PAYMENT_PROVIDER=polar. Vars: POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, optional POLAR_SERVER (sandbox | production, default production).
The Polar adapter is fully wired against @polar-sh/sdk. Implements:
createCheckoutSession()—polar.checkouts.createwithexternalCustomerIdkeyed on the boilerplate's user/org id (Polar lazy-creates the customer)createCustomerPortalLink()—polar.customerSessions.createreturns a hosted portal URLparseWebhook()—validateEventfrom@polar-sh/sdk/webhooks, mapssubscription.created/updated/active/uncanceled/canceled/revokedandorder.paidto the boilerplate's normalizedBillingEventlistPlans()— paginatespolar.products.list({ isArchived: false }), maps to thePlanDTO. Recognizesmetadata.features(newline-separated) andmetadata.highlight === 'true'getSubscriptionSummary/changePlan/cancelSubscription/listInvoices— wired topolar.subscriptions.*andpolar.orders.listextendTrial— returnsadminUnsupported. Polar trials are configured at the product level, not per-subscription, so there's no API to call
[!NOTE] Polar's checkout flow uses a single
successUrlfor both completed and abandoned sessions — it doesn't accept a separate cancel URL. The IPaymentProvider port still requires both fields for Stripe parity; the Polar adapter ignorescancelUrland usessuccessUrlexclusively.
Model
Subscription ─→ Plan ─→ Feature
│
└──→ OrganizationSubscription.status is the SSOT (active, past_due, canceled, trialing). Webhooks update it; the frontend never assumes.
Fake (E2E / testing only)
PAYMENT_PROVIDER=fake. Var: FAKE_WEBHOOK_SECRET (any string).
Returns static plans, fake checkout/portal URLs, and accepts HMAC-SHA256 signed webhooks
(x-fake-signature: sha256=<hex>). Used by the E2E suite — never deploy to production.
Local webhook testing
stripe listen --forward-to localhost:3005/api/v1/billing/webhookFor Mercado Pago / Polar use ngrok. For E2E tests: PAYMENT_PROVIDER=fake FAKE_WEBHOOK_SECRET=<secret>.
Platform admin operations
Plan 05 adds five cross-tenant subscription methods to IPaymentProvider. They power the /admin/billing/[orgId] page and the /api/v1/platform/organizations/:id/subscription/* endpoints.
| Method | Description |
|---|---|
getSubscriptionSummary(customerInternalId) | Returns the current subscription (null if none). |
changePlan(customerInternalId, newPriceId) | Switches the subscription to a different price. |
extendTrial(customerInternalId, days) | Extends trialEndsAt by [1, 90] days. |
cancelSubscription(customerInternalId, { immediate }) | Cancels now or at period end. |
listInvoices(customerInternalId, { page, pageSize }) | Paginated invoice history. |
[!NOTE] The Fake adapter implements all five (in-memory state for E2E + admin smoke tests). The Polar adapter implements four of five —
extendTrialreturnsadminUnsupportedbecause Polar trials are configured at the product level, not the subscription level, so there's no per-subscription extension API. Stripe and MercadoPago still returnadminUnsupportedfor all five and need their SDK methods wired (e.g.stripe.subscriptions.update,stripe.invoices.list).
Org → customer resolution
The boilerplate's billing model is per-user: Subscription.customerInternalId === userId. Cross-tenant admin operations resolve the target via org → owner-member's userId (see 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.
Audit events
Every mutation emits an audit row with the actor's real userId (impersonation-aware):
platform.org.plan_changed—{ previousPriceId, newPriceId }platform.org.trial_extended—{ days, newTrialEndsAt }platform.org.subscription_cancelled—{ mode: 'immediate' | 'at_period_end' }
One-shot purchases
Beyond recurring Subscriptions, the billing module also tracks one-shot
Purchases — single immutable receipts populated from the same webhook
stream (invoice.paid events). Same IPaymentProvider port; same
adapters; different read model.
| Surface | Endpoint |
|---|---|
| Browser → start checkout | POST /api/v1/billing/checkout (cookie session) |
| Browser → list my purchases | GET /api/v1/billing/my-purchases |
| Browser → open per-user portal | POST /api/v1/billing/portal/me (per-user, distinct from the org-scoped /portal) |
| Provider → webhook | POST /api/v1/billing/webhook |
The dashboard's /downloads page consumes useMyPurchases() to render
the user's purchase history with re-download links. See the
Polar deploy guide for the end-to-end setup.