- Add Stripe and Mercado Pago providers for handling subscriptions. - Create billing repository and access services for managing billing records. - Implement webhook handlers for processing events from Stripe and Mercado Pago. - Add routes for checkout, canceling subscriptions, and retrieving billing status. - Introduce billing status management with appropriate state transitions. - Create tests for billing functionalities including webhook idempotency and provider resolution. - Document the billing module architecture, environment variables, and API endpoints.
9.3 KiB
Billing / Subscriptions Module
Architecture
modules/billing/
├── billing.routes.ts # Rutas Hono
├── features/
│ ├── create-checkout/ # POST /api/billing/checkout
│ │ ├── create-checkout.handler.ts
│ │ └── create-checkout.business.ts
│ ├── cancel-subscription/ # POST /api/billing/cancel
│ │ ├── cancel-subscription.handler.ts
│ │ └── cancel-subscription.business.ts
│ ├── get-billing-status/ # GET /api/billing/status
│ │ ├── get-billing-status.handler.ts
│ │ └── get-billing-status.business.ts
│ ├── stripe-webhook/ # POST /api/billing/webhooks/stripe
│ │ ├── stripe-webhook.handler.ts
│ │ └── stripe-webhook.business.ts
│ └── mercadopago-webhook/ # POST /api/billing/webhooks/mercadopago
│ ├── mercadopago-webhook.handler.ts
│ └── mercadopago-webhook.business.ts
├── providers/
│ ├── billing-provider.interface.ts # BillingProvider abstraction
│ ├── stripe.provider.ts # Stripe implementation
│ └── mercadopago.provider.ts # Mercado Pago implementation
└── services/
├── provider-resolver.service.ts # Resuelve Stripe/MP por país
├── billing-repository.service.ts # DB access (Prisma)
└── billing-access.service.ts # canUseApp() helper
Resolución de provider
| Country | Provider |
|---|---|
AR |
Mercado Pago |
| Otro / null | Stripe |
La resolución usa el campo country del Complex (ISO 3166-1 alpha-2).
Variables de entorno
# Billing
BILLING_ENV=sandbox|production
FRONTEND_BASE_URL=http://localhost:5173
# Stripe
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
STRIPE_PRICE_ID_USD_BASIC=price_basic_usd
STRIPE_PRICE_ID_USD_ADVANCED=price_advanced_usd
STRIPE_PRICE_ID_USD_ENTERPRISE=price_enterprise_usd
# Mercado Pago (Argentina)
MERCADOPAGO_ACCESS_TOKEN=TEST-xxx
MERCADOPAGO_WEBHOOK_SECRET=xxx
MERCADOPAGO_PLAN_ID_ARS_BASIC=2c938084...
MERCADOPAGO_PLAN_ID_ARS_ADVANCED=2c938084...
MERCADOPAGO_PLAN_ID_ARS_ENTERPRISE=2c938084...
Naming de plans
Los IDs de prices/plans en Stripe y Mercado Pago se nombran con el código del Plan interno (BASIC, ADVANCED, ENTERPRISE) y la moneda (USD, ARS):
STRIPE_PRICE_ID_{CURRENCY}_{PLAN_CODE}
MERCADOPAGO_PLAN_ID_{CURRENCY}_{PLAN_CODE}
Endpoints
| Método | Ruta | Auth | Descripción |
|---|---|---|---|
| POST | /api/billing/checkout |
requireAuth + cookie |
Inicia checkout de suscripción |
| POST | /api/billing/cancel |
requireAuth + cookie |
Cancela suscripción activa |
| GET | /api/billing/status |
requireAuth + cookie |
Estado actual del billing |
| POST | /api/billing/webhooks/stripe |
none (firma) | Webhook de Stripe |
| POST | /api/billing/webhooks/mercadopago |
none (firma) | Webhook de Mercado Pago |
El complexId se obtiene de la cookie selected-complex-id.
Estados internos
TRIAL ──(checkout completed)──► ACTIVE
TRIAL ──(user cancels)────────► CANCELED
ACTIVE ──(payment fails)──────► PAST_DUE
PAST_DUE ──(retry ok)─────────► ACTIVE
PAST_DUE ──(user cancels)─────► CANCELED
ACTIVE ──(user cancels)───────► CANCELED
CANCELED ──(grace ends)───────► SUSPENDED
| Estado | Significado |
|---|---|
TRIAL |
Período de prueba, puede usar la app |
ACTIVE |
Suscripción activa, puede usar la app |
PAST_DUE |
Pago fallido, acceso restringido |
CANCELED |
Cancelado por usuario, acceso restringido |
SUSPENDED |
Suspendido, acceso restringido |
Helper: canUseApp(complexId)
import { canUseApp } from '@/modules/billing/services/billing-access.service';
const result = await canUseApp(complexId);
if (!result.ok) {
// Rechazar acceso con mensaje según el estado
}
Retorna ok(true) si el estado es TRIAL o ACTIVE. Caso contrario retorna un AppError tipo forbidden con un mensaje específico según el estado.
Flujo de Checkout
- Frontend →
POST /api/billing/checkoutcon{ planCode: "BASIC" } - Backend verifica:
- Usuario tiene acceso al complex (cookie)
- No existe suscripción activa
- Backend resuelve provider según
Complex.country - Provider genera URL de checkout (Stripe Checkout Session / Mercado Pago Preapproval)
- Backend guarda/actualiza
ComplexBillingcon statusTRIAL - Frontend redirige al usuario a
checkoutUrl - Usuario completa pago en el provider
- Provider envía webhook → backend procesa y pasa a
ACTIVE
Flujo de Webhooks
Idempotencia
Cada webhook tiene un providerEventId único (Stripe: event.id, MP: mp-{data.id}-{action}). El campo providerEventId en BillingEvent tiene @unique.
Antes de procesar:
- Buscar
BillingEventporproviderEventId - Si existe → responder 200 (ya procesado)
- Si no existe → procesar y crear
BillingEventdentro de una transacción
Stripe
stripe listen --forward-to localhost:3000/api/billing/webhooks/stripe
Stripe envía los eventos al endpoint con la firma en el header stripe-signature.
Eventos manejados:
| Evento | Acción |
|---|---|
checkout.session.completed |
Activar suscripción (ACTIVE) |
customer.subscription.updated (active) |
Actualizar período |
customer.subscription.updated (past_due) |
Marcar como PAST_DUE |
customer.subscription.deleted |
Cancelar (CANCELED) |
invoice.payment_failed |
Marcar como PAST_DUE |
Mercado Pago
Usar ngrok para exponer el servidor local:
ngrok http 3000
# Configurar la URL del webhook en el dashboard de MP:
# https://www.mercadopago.com.ar/developers/panel/webhooks
# Apuntar a: https://{ngrok-id}.ngrok.app/api/billing/webhooks/mercadopago
Eventos manejados:
| Acción | Tipo |
|---|---|
subscription_authorized_payment |
activated → ACTIVE |
subscription_cancelled |
cancelled → CANCELED |
subscription_updated |
updated → ACTIVE |
subscription_charge_payment |
payment_failed → PAST_DUE |
Nota: Los nombres exactos de eventos de Mercado Pago pueden variar. Verificar en el dashboard de desarrolladores de MP durante integración.
Modelo de datos (Prisma)
ComplexBilling
| Columna | Tipo | Descripción |
|---|---|---|
complexId |
String @id |
FK a Complex |
status |
BillingStatus |
TRIAL, ACTIVE, PAST_DUE, CANCELED, SUSPENDED |
planCode |
String |
Código del Plan (BASIC, ADVANCED, ENTERPRISE) |
currency |
String |
ARS o USD |
provider |
BillingProvider? |
STRIPE, MERCADOPAGO, PAYPAL |
providerCustomerId |
String? |
ID del customer en el provider |
providerSubscriptionId |
String? |
ID de la suscripción en Stripe |
providerPreapprovalId |
String? |
ID del preapproval en MP |
currentPeriodStart |
DateTime? |
Inicio del período actual |
currentPeriodEnd |
DateTime? |
Fin del período actual |
trialEndsAt |
DateTime? |
Fin del trial |
canceledAt |
DateTime? |
Fecha de cancelación |
suspendedAt |
DateTime? |
Fecha de suspensión |
BillingEvent
| Columna | Tipo | Descripción |
|---|---|---|
id |
String @id |
UUID |
complexId |
String |
FK a ComplexBilling |
eventType |
String |
Tipo de evento del provider |
provider |
BillingProvider |
Provider que originó el evento |
providerEventId |
String? @unique |
ID único del evento en el provider (idempotencia) |
providerData |
Json? |
Payload completo del webhook |
previousStatus |
BillingStatus? |
Estado anterior |
newStatus |
BillingStatus? |
Nuevo estado |
Agregar un nuevo provider (ej: PayPal)
- Crear
providers/paypal.provider.tsimplementandoBillingProvider - Agregar
PAYPALal enumBillingProvideren Prisma - Agregar case en
provider-resolver.service.tssi aplica - Agregar webhook handler en
features/paypal-webhook/ - Agregar ruta en
billing.routes.ts
Pruebas en local
Sin cobrar dinero
- Stripe: Usar claves
sk_test_.... Stripe automáticamente usa el entorno de prueba con tarjetas de prueba (4242 4242 4242 4242). - Mercado Pago: Usar
BILLING_ENV=sandboxconMERCADOPAGO_ACCESS_TOKEN=TEST-....
Stripe CLI (webhooks locales)
# Instalar: https://stripe.com/docs/stripe-cli
stripe login
stripe listen --forward-to localhost:3000/api/billing/webhooks/stripe
# Copiar el webhook secret que muestra y ponerlo en STRIPE_WEBHOOK_SECRET
Mercado Pago + ngrok (webhooks locales)
ngrok http 3000
# Configurar webhook en: https://www.mercadopago.com.ar/developers/panel/webhooks
# URL: https://{ngrok-id}.ngrok.app/api/billing/webhooks/mercadopago
Comandos
# Instalar dependencias (ya instalado: stripe, mercadopago)
bun add stripe mercadopago
# Generar cliente Prisma
bun --filter backend prisma:generate
# Crear migración
bun --filter backend prisma:migrate
# Tests
bun run test
# Dev server
bun run dev