Files
HoruxDespachosNuevo/docs/plans/2026-04-13-subscriptions-self-serve.md

317 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).