feat(billing): implement billing module with Stripe and Mercado Pago integration

- 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.
This commit is contained in:
Jose Selesan
2026-06-29 15:55:20 -03:00
parent f490eecd11
commit e06bc12097
45 changed files with 2553 additions and 30 deletions

283
docs/billing.md Normal file
View File

@@ -0,0 +1,283 @@
# 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)`
```typescript
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
1. **Frontend**`POST /api/billing/checkout` con `{ planCode: "BASIC" }`
2. **Backend** verifica:
- Usuario tiene acceso al complex (cookie)
- No existe suscripción activa
3. **Backend** resuelve provider según `Complex.country`
4. **Provider** genera URL de checkout (Stripe Checkout Session / Mercado Pago Preapproval)
5. **Backend** guarda/actualiza `ComplexBilling` con status `TRIAL`
6. **Frontend** redirige al usuario a `checkoutUrl`
7. **Usuario** completa pago en el provider
8. **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:**
1. Buscar `BillingEvent` por `providerEventId`
2. Si existe → responder 200 (ya procesado)
3. Si no existe → procesar y crear `BillingEvent` dentro de una transacción
### Stripe
```sh
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:
```sh
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)
1. Crear `providers/paypal.provider.ts` implementando `BillingProvider`
2. Agregar `PAYPAL` al enum `BillingProvider` en Prisma
3. Agregar case en `provider-resolver.service.ts` si aplica
4. Agregar webhook handler en `features/paypal-webhook/`
5. 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=sandbox` con `MERCADOPAGO_ACCESS_TOKEN=TEST-...`.
### Stripe CLI (webhooks locales)
```sh
# 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)
```sh
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
```sh
# 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
```