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:
283
docs/billing.md
Normal file
283
docs/billing.md
Normal 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
|
||||
```
|
||||
Reference in New Issue
Block a user