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