317 lines
16 KiB
Markdown
317 lines
16 KiB
Markdown
# Suscripciones self-serve con MercadoPago
|
||
|
||
## Resumen
|
||
|
||
Sistema completo de gestión de suscripciones donde los tenants pueden elegir plan, activar prueba gratis de 30 días, cambiar de plan, cancelar, y el admin global puede editar precios. Integración con MercadoPago para cobros recurrentes (preapproval) y cobros one-time prorateados (preference) para upgrades inmediatos.
|
||
|
||
## Motivación
|
||
|
||
Antes: las suscripciones solo las creaba el admin global al provisionar tenants. El tenant no tenía UI para elegir plan, y cualquier cambio requería que el admin global lo hiciera manualmente. No había trial gratuito.
|
||
|
||
Ahora: el tenant tiene flujo end-to-end (trial → subscribe → change → cancel) desde `/configuracion/suscripcion`, sin intervención del admin global salvo para plan Custom o ajustes administrativos (mark-paid, listar todos).
|
||
|
||
## Precios
|
||
|
||
Editables en tabla `plan_prices` desde BD central. 8 filas: 4 planes × 2 frecuencias. Custom no está aquí — se fija por tenant al provisionar.
|
||
|
||
| Plan | Mensual | Anual |
|
||
|------|---------|-------|
|
||
| Starter | $199 | $1,990 |
|
||
| Business | $480 | $4,800 |
|
||
| Business + IA | $780 | $7,800 |
|
||
| Enterprise | $900 | $9,000 |
|
||
|
||
El admin global edita vía UI (card "Precios de Planes" en `/configuracion/suscripcion`) o SQL directo. Los cambios aplican **solo a suscripciones nuevas o renovaciones futuras** — suscripciones vigentes conservan el `amount` con el que se crearon.
|
||
|
||
## Schema (BD central)
|
||
|
||
### Tabla `plan_prices`
|
||
```
|
||
id SERIAL PK
|
||
plan Plan (enum)
|
||
frequency String ('monthly' | 'annual')
|
||
amount Decimal(10,2)
|
||
updated_at TIMESTAMP
|
||
UNIQUE (plan, frequency)
|
||
```
|
||
|
||
### Campos nuevos en `Subscription`
|
||
| Campo | Tipo | Propósito |
|
||
|-------|------|-----------|
|
||
| `pendingPlan` | Plan? | Cambio programado al próximo período |
|
||
| `pendingFrequency` | String? | Frecuencia del cambio programado |
|
||
| `pendingEffectiveAt` | DateTime? | Cuándo se aplica (== currentPeriodEnd cuando se schedula) |
|
||
| `upgradePreferenceId` | String? | ID de MP Preference para cobro prorateado en curso |
|
||
| `upgradeTargetPlan` | Plan? | Plan nuevo que activará el upgrade |
|
||
| `upgradeTargetAmount` | Decimal? | Monto recurrente nuevo (snapshot del precio al iniciar upgrade) |
|
||
|
||
### Campo nuevo en `Tenant`
|
||
| Campo | Tipo | Propósito |
|
||
|-------|------|-----------|
|
||
| `trialEndsAt` | DateTime? | Marca fin de trial. Si set → el tenant ya usó su prueba |
|
||
|
||
## Estados de `Subscription.status`
|
||
|
||
| Status | Significado |
|
||
|--------|-------------|
|
||
| `trial` | Trial 30 días activo, sin pago |
|
||
| `trial_converted` | Usuario convirtió trial → subscribe (histórico) |
|
||
| `trial_expired` | Trial venció sin convertir (seteado por cron) |
|
||
| `pending` | Preapproval MP creado, esperando autorización del usuario |
|
||
| `authorized` | MP autorizó, suscripción activa |
|
||
| `paused` | MP pausó (reintento fallido largo) |
|
||
| `cancelled` | Usuario canceló. Acceso continúa hasta `currentPeriodEnd` |
|
||
|
||
## Endpoints
|
||
|
||
### Public-ish (cualquier admin/cfo autenticado)
|
||
```
|
||
GET /api/subscriptions/plans
|
||
→ lista de precios vigentes (para plan picker)
|
||
```
|
||
|
||
### Self-serve (actúan sobre el tenant del JWT)
|
||
```
|
||
POST /api/subscriptions/me/trial { plan, frequency }
|
||
POST /api/subscriptions/me/subscribe { plan, frequency } → { subscription, paymentUrl }
|
||
POST /api/subscriptions/me/change { plan, frequency } → { subscription, effectiveAt }
|
||
POST /api/subscriptions/me/upgrade { plan } → { subscription, checkoutUrl, proratedAmount }
|
||
POST /api/subscriptions/me/upgrade/cancel → { ok: true }
|
||
POST /api/subscriptions/me/cancel → { subscription }
|
||
```
|
||
|
||
### Own-tenant OR global-admin
|
||
```
|
||
GET /api/subscriptions/:tenantId (estado actual)
|
||
GET /api/subscriptions/:tenantId/payments (historial de pagos)
|
||
POST /api/subscriptions/:tenantId/generate-link (regenera paymentUrl si pending)
|
||
```
|
||
|
||
### Solo admin global (HTS240708LJA)
|
||
```
|
||
GET /api/subscriptions/ (todas las suscripciones)
|
||
POST /api/subscriptions/:tenantId/mark-paid { amount } (transferencia manual)
|
||
PUT /api/subscriptions/plans/:id { amount } (editar precio)
|
||
```
|
||
|
||
## Flujos
|
||
|
||
### Trial
|
||
1. Usuario elige plan + frecuencia en el picker (estado "primera vez")
|
||
2. Click "Probar 30 días gratis"
|
||
3. Backend crea `Subscription(status='trial', amount=0, currentPeriodEnd=now+30d)` + setea `Tenant.trialEndsAt`
|
||
4. El usuario ve toda la app con acceso del plan elegido (feature-gate lee `tenant.plan`)
|
||
5. Antes de que venza: puede clickear "Contratar ahora" → flujo de subscribe
|
||
6. Si vence sin convertir: cron `expireTrials` cambia status a `trial_expired`, feature-gate lo degrada
|
||
|
||
Un trial por tenant — validado por la presencia de `trialEndsAt` O por cualquier subscription con status en `('trial','trial_expired','trial_converted')`.
|
||
|
||
### Subscribe (primera contratación)
|
||
1. Backend lee precio de `plan_prices` para (plan, frequency)
|
||
2. Crea preapproval en MP con ese monto y frecuencia (`months/1` o `months/12`)
|
||
3. Marca trials previos como `trial_converted`
|
||
4. Crea `Subscription(status='pending', mpPreapprovalId=...)`
|
||
5. Retorna `paymentUrl` — el frontend lo abre en nueva pestaña
|
||
6. Usuario autoriza en MP → webhook → `status='authorized'`
|
||
|
||
### Cancel
|
||
1. Usuario click "Cancelar suscripción" → modal confirmatorio
|
||
2. Backend: `status='cancelled'`, limpia pending*, llama `cancelPreapproval` en MP
|
||
3. El middleware `plan-limits` sigue permitiendo acceso porque respeta `currentPeriodEnd`
|
||
4. Cuando vence: middleware empieza a degradar
|
||
|
||
### Change de plan / frecuencia (scheduled)
|
||
Cubre **downgrades** y **cambios de frecuencia** (incluso si el precio subiera al cambiar mensual→anual).
|
||
|
||
1. Usuario elige plan + frecuencia en modal
|
||
2. Frontend `classifyChange` determina `'scheduled'` (caso NO upgrade)
|
||
3. Backend `scheduleChange`: guarda `pendingPlan`, `pendingFrequency`, `pendingEffectiveAt = currentPeriodEnd`
|
||
4. Banner morado "Tu plan cambiará a X el Y"
|
||
5. Cron diario 2:30 AM (`applyPendingChanges`) revisa `pendingEffectiveAt <= now`:
|
||
- Cancela preapproval viejo en MP
|
||
- Crea preapproval nuevo con nuevo plan/frecuencia/monto
|
||
- Actualiza subscription y tenant.plan
|
||
- Limpia pending*
|
||
- Status queda `pending` hasta que el usuario autorice el nuevo preapproval
|
||
|
||
### Upgrade inmediato con proration
|
||
Solo se dispara cuando **se mantiene la frecuencia** Y el **plan nuevo es más caro** que el actual.
|
||
|
||
**Fórmula:**
|
||
```
|
||
daysRemaining = ceil((currentPeriodEnd - now) / 1 día)
|
||
periodDays = ceil((currentPeriodEnd - currentPeriodStart) / 1 día)
|
||
fraction = min(1, daysRemaining / periodDays)
|
||
diff = newAmount - currentAmount
|
||
prorated = round(diff × fraction, 2 decimales)
|
||
```
|
||
|
||
**Flujo:**
|
||
1. Usuario elige plan más caro en modal (misma frecuencia)
|
||
2. Frontend `classifyChange` determina `'upgrade'`, muestra preview azul
|
||
3. Click "Pagar y activar" → `POST /me/upgrade`
|
||
4. Backend:
|
||
- Valida que sea upgrade real (precio nuevo > actual)
|
||
- Calcula prorated amount
|
||
- Crea MP Preference one-time con `external_reference = 'proration:${tenantId}:${subscriptionId}'`
|
||
- Guarda `upgradePreferenceId`, `upgradeTargetPlan`, `upgradeTargetAmount` en Subscription
|
||
- Retorna `{ checkoutUrl, proratedAmount }`
|
||
5. Frontend abre checkoutUrl en nueva pestaña
|
||
6. Usuario paga en MP → webhook payment aprobado
|
||
7. Webhook detecta prefijo `proration:`, llama `applyApprovedUpgrade(subscriptionId)`:
|
||
- `updatePreapprovalAmount` en MP → próximo cobro recurrente será el nuevo monto
|
||
- Transacción DB: actualiza subscription.plan/amount, limpia upgrade*, actualiza tenant.plan
|
||
8. Frontend ve el banner "Upgrade pendiente" desaparecer
|
||
|
||
**Abortar upgrade:** botón "Cancelar upgrade" en el banner → `POST /me/upgrade/cancel` → limpia campos. La preference queda huérfana en MP, expirará sola.
|
||
|
||
**Racing:** si el usuario paga antes de que el backend registre la preference, el webhook reintentaría. Si falla `updatePreapprovalAmount`, el webhook re-lanza y MP reintenta — eventualmente converge.
|
||
|
||
## MercadoPago
|
||
|
||
### Preapproval (recurring)
|
||
- `createPreapproval({ amount, frequency: 'monthly'|'annual', payerEmail })` — crea con `auto_recurring.frequency_type: 'months'`, `frequency: 1 | 12`
|
||
- `cancelPreapproval(id)` — tolerante a not-found
|
||
- `updatePreapprovalAmount(id, newAmount)` — modifica `auto_recurring.transaction_amount`
|
||
- `external_reference = tenantId` para que webhook enrute a flujo recurrente
|
||
|
||
### Preference (one-time checkout para proration)
|
||
- `createProrationPreference({ amount, subscriptionId, tenantId, payerEmail, description })` — devuelve `{ preferenceId, checkoutUrl }`
|
||
- `external_reference = proration:${tenantId}:${subscriptionId}` — marcador que el webhook usa para enrutar a `applyApprovedUpgrade`
|
||
|
||
### Webhook routing
|
||
```
|
||
external_reference empieza con 'proration:' → applyApprovedUpgrade
|
||
external_reference == tenantId (UUID) → flujo recurrente existente
|
||
```
|
||
|
||
### Guardrails si MP no está configurado
|
||
Si `MP_ACCESS_TOKEN` falta o es inválido:
|
||
- `createPreapproval` y `createProrationPreference` lanzan error con mensaje explícito
|
||
- Los controllers capturan y retornan 503 al frontend con message legible
|
||
- Trial sigue funcionando sin MP (no llega a MP)
|
||
|
||
## Cron jobs
|
||
|
||
### `applyPendingChanges` — diario 2:30 AM (`30 2 * * *`)
|
||
- Busca subscriptions con `pendingEffectiveAt <= now AND pendingPlan IS NOT NULL`
|
||
- Para cada una:
|
||
- Cancela preapproval viejo en MP
|
||
- Crea preapproval nuevo con pendingPlan/pendingFrequency/priceFromTable
|
||
- Actualiza subscription + tenant
|
||
- Limpia pending*
|
||
|
||
### `expireTrials` — mismo cron
|
||
- Busca subscriptions con `status='trial' AND currentPeriodEnd < now`
|
||
- Cambia a `status='trial_expired'`
|
||
|
||
Registrados en `sat-sync.job.ts` junto con los demás crons.
|
||
|
||
## UI — estados de la página `/configuracion/suscripcion`
|
||
|
||
| Estado del tenant | Lo que ve |
|
||
|-------------------|-----------|
|
||
| Sin suscripción previa | Plan picker con toggle Mensual/Anual, botón "Probar 30 días gratis" + botón "Contratar" |
|
||
| Trial activo | Banner "Te quedan X días", card de sub actual, botón "Contratar ahora" + "Cancelar" |
|
||
| Trial vencido | Banner rojo, plan picker sin trial button |
|
||
| Pago pendiente (pending) | Banner amarillo con botón "Completar pago" (abre MP) |
|
||
| Activa (authorized) | Card de sub, botones "Cambiar plan" + "Cancelar" |
|
||
| Cambio programado (pendingPlan) | Banner morado "Cambiará a X el Y" |
|
||
| Upgrade pendiente (upgradePreferenceId) | Banner azul "Completa el pago", botón "Cancelar upgrade" |
|
||
| Cancelada en período | Banner naranja "Acceso hasta X" |
|
||
| Cancelada + vencida | Banner rojo, plan picker para re-contratar |
|
||
|
||
## Admin global
|
||
|
||
Si el usuario tiene RFC `HTS240708LJA`:
|
||
- Ve vista completa: 4 cards resumen, sección "Precios de Planes" (editable inline), tabla "Todas las Suscripciones"
|
||
- NO ve su propia UI de suscripción personal en esa página (se dedica a vista admin)
|
||
|
||
## Archivos tocados
|
||
|
||
### Backend
|
||
- `prisma/schema.prisma` — tabla `PlanPrice`, campos nuevos en Subscription y Tenant
|
||
- `prisma/seed.ts` — refactor completo: usa `migrate()` del runner en lugar de CREATE TABLE hardcodeado, siembra 8 filas de precios
|
||
- `src/services/payment/mercadopago.service.ts` — `createPreapproval` con frequency, `cancelPreapproval`, `updatePreapprovalAmount`, `createProrationPreference`
|
||
- `src/services/payment/subscription.service.ts` — `getPlanPrice`, `startTrial`, `subscribe`, `scheduleChange`, `cancelSubscription`, `applyPendingChanges`, `expireTrials`, `calculateProration`, `initiateUpgrade`, `applyApprovedUpgrade`, `cancelPendingUpgrade`. Cambio en `updateSubscriptionStatus` para usar relación `rol.nombre` en include
|
||
- `src/controllers/subscription.controller.ts` — 10+ nuevos handlers + guard `requireOwnTenantOrGlobalAdmin`
|
||
- `src/controllers/webhook.controller.ts` — rama de `proration:*` routing
|
||
- `src/routes/subscription.routes.ts` — 7 rutas nuevas
|
||
- `src/jobs/sat-sync.job.ts` — cron `SUBSCRIPTION_LIFECYCLE_CRON` a las 2:30 AM
|
||
|
||
### Frontend
|
||
- `lib/api/subscription.ts` — tipos extendidos + 7 funciones nuevas
|
||
- `lib/hooks/use-subscription.ts` — 7 hooks nuevos
|
||
- `app/(dashboard)/configuracion/suscripcion/page.tsx` — reescritura completa (~550 líneas): `PlanGrid`, `FrequencyToggle`, `PlanPricesSection`, clasificador `classifyChange`, 8 estados visuales, 2 modales
|
||
|
||
## Testing
|
||
|
||
### Sin MP credentials (todo lo no-MP)
|
||
- Trial funciona end-to-end
|
||
- Cancel, change (scheduled), admin editar precios funcionan sin tocar MP
|
||
- `subscribe` y `upgrade` retornan 503 con mensaje explícito
|
||
|
||
### Con `MP_ACCESS_TOKEN` sandbox
|
||
1. Subscribe: click "Contratar" → abrir checkout MP → autorizar → webhook → status authorized
|
||
2. Cancel: status cancelled, preapproval cancelled en MP panel, acceso hasta fin de período
|
||
3. Change (downgrade): banner morado, cron aplica, status vuelve a pending hasta nueva autorización
|
||
4. Upgrade: click plan más caro misma frecuencia → checkout proration → pagar → subscription actualiza plan + preapproval actualiza monto
|
||
5. Upgrade abortado: iniciar upgrade, cerrar checkout, cancelar → campos upgrade* se limpian
|
||
|
||
## Decisiones descartadas (y por qué)
|
||
|
||
### Proration entre frecuencias (mensual → anual)
|
||
- ¿Cuánto "vale" un mes dentro de un período anual? Ambiguo.
|
||
- Decisión: cualquier cambio de frecuencia se scheduluea al próximo período, sin proration.
|
||
|
||
### Emails de pago a CFO también
|
||
- CFO tiene mismo nivel de acceso que admin, técnicamente podría recibir notificaciones.
|
||
- Pero cambiar a quién llegan emails de pago es una decisión UX, no un fix de tipos.
|
||
- Decisión: sigue emailando solo a `rol.nombre = 'admin'`. Si se quiere incluir CFO en el futuro, cambiar el `where` a `{ in: ['admin', 'cfo'] }`.
|
||
|
||
### Plan Custom en self-serve
|
||
- Custom se reserva para clientes especiales activados por admin global con monto negociado.
|
||
- Decisión: el picker no muestra Custom. `subscribe`/`upgrade`/`scheduleChange` fallan si se les pasa `plan: 'custom'` con mensaje explícito.
|
||
|
||
### Payment history con marcador de proration
|
||
- Cuando se cobra proration, el `paymentMethod` se guarda como `proration-${mpPaymentMethod}` (ej: `proration-credit_card`)
|
||
- Esto permite distinguir en la tabla de historial pagos recurrentes vs upgrades prorateados sin agregar una columna nueva.
|
||
|
||
## Deploy
|
||
|
||
### Migración Prisma
|
||
Requerido antes de arrancar en prod:
|
||
```bash
|
||
cd apps/api
|
||
pnpm prisma migrate deploy # agrega PlanPrice + nuevos campos en Subscription y Tenant
|
||
```
|
||
|
||
Si no hay `migrations/` (usa `db push`):
|
||
```bash
|
||
pnpm prisma db push
|
||
```
|
||
|
||
### Seed de precios (solo una vez)
|
||
```bash
|
||
pnpm db:seed # idempotente — upsert de las 8 filas de plan_prices
|
||
```
|
||
|
||
Si el seed ya corrió antes (demo tenant ya existe), solo agrega los precios sin tocar el tenant.
|
||
|
||
### Variables de entorno (apps/api/.env)
|
||
```
|
||
MP_ACCESS_TOKEN=APP_USR-xxxxxxxx # live key para producción
|
||
MP_WEBHOOK_SECRET=tu-secret-de-webhooks # configurado en el panel MP
|
||
MP_NOTIFICATION_URL=https://horuxfin.com/api/webhooks/mercadopago
|
||
FRONTEND_URL=https://horuxfin.com # usado en back_url de preapproval y preference
|
||
```
|
||
|
||
## Pendientes / mejoras posibles
|
||
|
||
1. **Email de confirmación al aplicar upgrade** — actualmente no se envía nada cuando `applyApprovedUpgrade` termina. Debería enviar "Tu upgrade a X está activo".
|
||
2. **Notificación de trial por vencer** — cron adicional que emailee 3 días antes de `trialEndsAt`.
|
||
3. **Re-intento de subscribe si webhook de preapproval no llega** — hoy queda en pending indefinidamente.
|
||
4. **Permitir múltiples upgrades consecutivos sin esperar período completo** — actualmente si ya hay `upgradePreferenceId`, el segundo intento falla. Correcto para MVP, pero podría relajarse.
|
||
5. **Frontend para editar precios soportar bulk edit** — hoy cada celda se edita individualmente.
|
||
6. **Auditoría de cambios de precio** — registrar quién cambió cada precio y cuándo (solo hay `updatedAt` ahora).
|