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
│
└──→ OrganizationSubscription.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/webhookPara 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étodo | Descripció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 compartidoadminUnsupportedenapps/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.customerInternalIdya no tienen FK ausers(una keyorg:<id>no es un usuario). Un trigger de DB sobreusersconserva 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.canceledse mapea a unsubscription.updatedque seteacanceledAt(cancelación pendiente) y mantienestatus = active; solosubscription.revokedtermina el acceso.subscription.uncanceledlimpiacanceledAt.cancelSubscription({ immediate: false })del admin usacancel_at_period_endde Polar;immediate: truerevoca. - Eventos fuera de orden.
Subscription.providerUpdatedAtguarda la fecha de modificación del provider; los eventos más viejos que llegan tarde se ignoran. Un status desconocido cae enincompleteysubscription.updatednunca borrametadata. - Renovaciones de Polar (
order.paidconbilling_reasondistinto depurchase) no se registran comoPurchase. - Clave de cliente. El checkout desde un contexto de organización (
x-organization-id) requierebilling:updatey se atribuye al customer propio de la orgorg:<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ónorg:<id>puede abrirlo cualquier miembro conbilling: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.