Billing
Stripe, Mercado Pago or Polar — provider-agnostic.
Last updated Jul 19, 2026
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; defaults to sandbox, but is required when NODE_ENV=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
Subscription.customerInternalId is either a userId (personal billing, and legacy org subscriptions keyed on the org owner) or org:<organizationId> (org-scoped checkouts). Every org-scoped read resolves the target via application/resolve-org-customer.ts: the org:<id> customer first, then — if it has no live subscription — the legacy org → owner-member's userId fallback. Cross-tenant admin operations and the usage module's active-subscription port both go through it. Provider-side customers can't be migrated, so existing owner-keyed subscriptions keep working through the fallback.
[!NOTE]
subscriptions.customerInternalId/purchases.customerInternalIdhave no FK tousersany more (anorg:<id>key isn't a user). A DB trigger onuserspreserves the GDPR purge cascade.
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.
Cancellation, ordering and customer keys
- Polar cancel at period end.
subscription.canceledis mapped to asubscription.updatedthat setscanceledAt(pending cancel) and keepsstatus = active; onlysubscription.revokedends access.subscription.uncanceledclearscanceledAt. AdmincancelSubscription({ immediate: false })uses Polar'scancel_at_period_end;immediate: truerevokes. - Out-of-order events.
Subscription.providerUpdatedAtstores the provider's modification time; older events arriving late are ignored. Unknown statuses default toincomplete, andsubscription.updatednever wipesmetadata. - Polar renewals (
order.paidwithbilling_reasonother thanpurchase) are not recorded asPurchaserows. - Customer key. Checkout from an organization context (
x-organization-id) requiresbilling:updateand is attributed to the org's own customerorg:<organizationId>; without it, checkout is personal (userId). The org portal (POST /billing/portal) opens the customer the org's live subscription is on: for anorg:<id>subscription any member withbilling:updatemay open it; for a legacy owner-keyed subscription it stays limited to the billing owner (403 otherwise). - Mercado Pago. The checkout price, currency and product come from the
BillingPlancatalog (priceId= plan id), never from client metadata. Webhook signatures older than 5 minutes are rejected.