Files
HoruxDespachos/docs/plans/2026-04-13-subscriptions-self-serve.md
2026-04-27 22:09:36 -06:00

16 KiB
Raw Blame History

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.tscreatePreapproval con frequency, cancelPreapproval, updatePreapprovalAmount, createProrationPreference
  • src/services/payment/subscription.service.tsgetPlanPrice, 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:

cd apps/api
pnpm prisma migrate deploy   # agrega PlanPrice + nuevos campos en Subscription y Tenant

Si no hay migrations/ (usa db push):

pnpm prisma db push

Seed de precios (solo una vez)

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