SaaS Starter

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.create with externalCustomerId keyed on the boilerplate's user/org id (Polar lazy-creates the customer)
  • createCustomerPortalLink()polar.customerSessions.create returns a hosted portal URL
  • parseWebhook()validateEvent from @polar-sh/sdk/webhooks, maps subscription.created/updated/active/uncanceled/canceled/revoked and order.paid to the boilerplate's normalized BillingEvent
  • listPlans() — paginates polar.products.list({ isArchived: false }), maps to the Plan DTO. Recognizes metadata.features (newline-separated) and metadata.highlight === 'true'
  • getSubscriptionSummary / changePlan / cancelSubscription / listInvoices — wired to polar.subscriptions.* and polar.orders.list
  • extendTrial — returns adminUnsupported. 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 successUrl for 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 ignores cancelUrl and uses successUrl exclusively.

Model

Subscription ─→ Plan ─→ Feature

     └──→ Organization

Subscription.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/webhook

For 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.

MethodDescription
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 — extendTrial returns adminUnsupported because Polar trials are configured at the product level, not the subscription level, so there's no per-subscription extension API. Stripe and MercadoPago still return adminUnsupported for 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.

SurfaceEndpoint
Browser → start checkoutPOST /api/v1/billing/checkout (cookie session)
Browser → list my purchasesGET /api/v1/billing/my-purchases
Browser → open per-user portalPOST /api/v1/billing/portal/me (per-user, distinct from the org-scoped /portal)
Provider → webhookPOST /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.

On this page