# 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).
