SaaS Starter

Billing

Stripe, Mercado Pago o Polar — agnóstico al proveedor.

Última actualización 19 jul 2026

Estrategia

PAYMENT_PROVIDER en env decide. La interface PaymentProvider abstrae checkout, webhooks, subscriptions. Cambiar de provider = un commit.

interface PaymentProvider {
  createCheckout(input): Promise<Result<CheckoutSession, BillingError>>;
  handleWebhook(req): Promise<Result<DomainEvent, BillingError>>;
  cancelSubscription(id): Promise<Result<void, BillingError>>;
}

Providers soportados

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. Pensado para LATAM — soporta Pix, wallet de Mercado Pago, cuotas.

Polar

PAYMENT_PROVIDER=polar. Vars: POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, POLAR_SERVER (sandbox | production; default sandbox, pero obligatoria con NODE_ENV=production). Ideal para developer tools — integra con GitHub.

Modelo

Subscription ─→ Plan ─→ Feature
     │
     └──→ Organization

Subscription.status es la SSOT (active, past_due, canceled, trialing). Los webhooks actualizan; el frontend nunca asume.

Fake (solo E2E / testing)

PAYMENT_PROVIDER=fake. Var: FAKE_WEBHOOK_SECRET (cualquier string). Devuelve planes estáticos, URLs falsas de checkout/portal y acepta webhooks firmados con HMAC-SHA256 (x-fake-signature: sha256=<hex>). Usado por la suite E2E — nunca desplegar en producción.

Test de webhooks local

stripe listen --forward-to localhost:3005/api/v1/billing/webhook

Para Mercado Pago / Polar usá ngrok. Para tests E2E: PAYMENT_PROVIDER=fake FAKE_WEBHOOK_SECRET=<secret>.

Operaciones de admin de plataforma

El Plan 05 agrega cinco métodos cross-tenant de suscripciones a IPaymentProvider. Soportan la página /admin/billing/[orgId] y los endpoints /api/v1/platform/organizations/:id/subscription/*.

MétodoDescripción
getSubscriptionSummary(customerInternalId)Devuelve la suscripción actual (null si no existe).
changePlan(customerInternalId, newPriceId)Cambia la suscripción a otro precio.
extendTrial(customerInternalId, days)Extiende trialEndsAt por [1, 90] días.
cancelSubscription(customerInternalId, { immediate })Cancela ahora o al final del período.
listInvoices(customerInternalId, { page, pageSize })Historial paginado de facturas.

[!NOTE] Solo el adapter Fake los implementa de fábrica (estado in-memory, usado por E2E y smoke tests del admin). Stripe / Polar / MercadoPago devuelven InfrastructureError("X is not implemented by the Y adapter; ...") vía el helper compartido adminUnsupported en apps/server/src/modules/billing/infrastructure/providers/admin-unsupported.ts. Para producción, reemplazá cada stub con la llamada al SDK correspondiente (ej. stripe.subscriptions.update, stripe.invoices.list).

Resolución org → customer

Subscription.customerInternalId es un userId (billing personal y suscripciones de org legacy keyeadas al owner) o org:<organizationId> (checkouts org-scoped). Toda lectura org-scoped resuelve el target vía application/resolve-org-customer.ts: primero el customer org:<id> y, si no tiene suscripción viva, el fallback legacy org → userId del owner-member. Tanto las operaciones cross-tenant de admin como el port de suscripción activa del módulo usage pasan por ahí. Los customers del provider no se pueden migrar, así que las suscripciones existentes keyeadas al owner siguen funcionando por el fallback.

[!NOTE] subscriptions.customerInternalId / purchases.customerInternalId ya no tienen FK a users (una key org:<id> no es un usuario). Un trigger de DB sobre users conserva el cascade del purge GDPR.

Eventos de auditoría

Cada mutación emite una entrada de audit con el userId real del actor (impersonation-aware):

  • platform.org.plan_changed — { previousPriceId, newPriceId }
  • platform.org.trial_extended — { days, newTrialEndsAt }
  • platform.org.subscription_cancelled — { mode: 'immediate' | 'at_period_end' }

Cancelación, orden de eventos y claves de cliente

  • Polar, cancelar al fin del período. subscription.canceled se mapea a un subscription.updated que setea canceledAt (cancelación pendiente) y mantiene status = active; solo subscription.revoked termina el acceso. subscription.uncanceled limpia canceledAt. cancelSubscription({ immediate: false }) del admin usa cancel_at_period_end de Polar; immediate: true revoca.
  • Eventos fuera de orden. Subscription.providerUpdatedAt guarda la fecha de modificación del provider; los eventos más viejos que llegan tarde se ignoran. Un status desconocido cae en incomplete y subscription.updated nunca borra metadata.
  • Renovaciones de Polar (order.paid con billing_reason distinto de purchase) no se registran como Purchase.
  • Clave de cliente. El checkout desde un contexto de organización (x-organization-id) requiere billing:update y se atribuye al customer propio de la org org:<organizationId>; sin contexto es personal (userId). El portal de la org (POST /billing/portal) abre el customer donde está la suscripción viva de la org: para una suscripción org:<id> puede abrirlo cualquier miembro con billing:update; para una suscripción legacy keyeada al owner queda limitado al billing owner (403 en otro caso).
  • Mercado Pago. Precio, moneda y producto del checkout salen del catálogo BillingPlan (priceId = id del plan), nunca de metadata del cliente. Se rechazan firmas de webhook con más de 5 minutos.

En esta página