16 KiB
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
- Usuario elige plan + frecuencia en el picker (estado "primera vez")
- Click "Probar 30 días gratis"
- Backend crea
Subscription(status='trial', amount=0, currentPeriodEnd=now+30d)+ seteaTenant.trialEndsAt - El usuario ve toda la app con acceso del plan elegido (feature-gate lee
tenant.plan) - Antes de que venza: puede clickear "Contratar ahora" → flujo de subscribe
- Si vence sin convertir: cron
expireTrialscambia status atrial_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)
- Backend lee precio de
plan_pricespara (plan, frequency) - Crea preapproval en MP con ese monto y frecuencia (
months/1omonths/12) - Marca trials previos como
trial_converted - Crea
Subscription(status='pending', mpPreapprovalId=...) - Retorna
paymentUrl— el frontend lo abre en nueva pestaña - Usuario autoriza en MP → webhook →
status='authorized'
Cancel
- Usuario click "Cancelar suscripción" → modal confirmatorio
- Backend:
status='cancelled', limpia pending*, llamacancelPreapprovalen MP - El middleware
plan-limitssigue permitiendo acceso porque respetacurrentPeriodEnd - 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).
- Usuario elige plan + frecuencia en modal
- Frontend
classifyChangedetermina'scheduled'(caso NO upgrade) - Backend
scheduleChange: guardapendingPlan,pendingFrequency,pendingEffectiveAt = currentPeriodEnd - Banner morado "Tu plan cambiará a X el Y"
- Cron diario 2:30 AM (
applyPendingChanges) revisapendingEffectiveAt <= now:- Cancela preapproval viejo en MP
- Crea preapproval nuevo con nuevo plan/frecuencia/monto
- Actualiza subscription y tenant.plan
- Limpia pending*
- Status queda
pendinghasta 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:
- Usuario elige plan más caro en modal (misma frecuencia)
- Frontend
classifyChangedetermina'upgrade', muestra preview azul - Click "Pagar y activar" →
POST /me/upgrade - 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,upgradeTargetAmounten Subscription - Retorna
{ checkoutUrl, proratedAmount }
- Frontend abre checkoutUrl en nueva pestaña
- Usuario paga en MP → webhook payment aprobado
- Webhook detecta prefijo
proration:, llamaapplyApprovedUpgrade(subscriptionId):updatePreapprovalAmounten MP → próximo cobro recurrente será el nuevo monto- Transacción DB: actualiza subscription.plan/amount, limpia upgrade*, actualiza tenant.plan
- 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 conauto_recurring.frequency_type: 'months',frequency: 1 | 12cancelPreapproval(id)— tolerante a not-foundupdatePreapprovalAmount(id, newAmount)— modificaauto_recurring.transaction_amountexternal_reference = tenantIdpara 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 aapplyApprovedUpgrade
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:
createPreapprovalycreateProrationPreferencelanzan 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— tablaPlanPrice, campos nuevos en Subscription y Tenantprisma/seed.ts— refactor completo: usamigrate()del runner en lugar de CREATE TABLE hardcodeado, siembra 8 filas de preciossrc/services/payment/mercadopago.service.ts—createPreapprovalcon frequency,cancelPreapproval,updatePreapprovalAmount,createProrationPreferencesrc/services/payment/subscription.service.ts—getPlanPrice,startTrial,subscribe,scheduleChange,cancelSubscription,applyPendingChanges,expireTrials,calculateProration,initiateUpgrade,applyApprovedUpgrade,cancelPendingUpgrade. Cambio enupdateSubscriptionStatuspara usar relaciónrol.nombreen includesrc/controllers/subscription.controller.ts— 10+ nuevos handlers + guardrequireOwnTenantOrGlobalAdminsrc/controllers/webhook.controller.ts— rama deproration:*routingsrc/routes/subscription.routes.ts— 7 rutas nuevassrc/jobs/sat-sync.job.ts— cronSUBSCRIPTION_LIFECYCLE_CRONa las 2:30 AM
Frontend
lib/api/subscription.ts— tipos extendidos + 7 funciones nuevaslib/hooks/use-subscription.ts— 7 hooks nuevosapp/(dashboard)/configuracion/suscripcion/page.tsx— reescritura completa (~550 líneas):PlanGrid,FrequencyToggle,PlanPricesSection, clasificadorclassifyChange, 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
subscribeyupgraderetornan 503 con mensaje explícito
Con MP_ACCESS_TOKEN sandbox
- Subscribe: click "Contratar" → abrir checkout MP → autorizar → webhook → status authorized
- Cancel: status cancelled, preapproval cancelled en MP panel, acceso hasta fin de período
- Change (downgrade): banner morado, cron aplica, status vuelve a pending hasta nueva autorización
- Upgrade: click plan más caro misma frecuencia → checkout proration → pagar → subscription actualiza plan + preapproval actualiza monto
- 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 elwherea{ 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/scheduleChangefallan si se les pasaplan: 'custom'con mensaje explícito.
Payment history con marcador de proration
- Cuando se cobra proration, el
paymentMethodse guarda comoproration-${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
- Email de confirmación al aplicar upgrade — actualmente no se envía nada cuando
applyApprovedUpgradetermina. Debería enviar "Tu upgrade a X está activo". - Notificación de trial por vencer — cron adicional que emailee 3 días antes de
trialEndsAt. - Re-intento de subscribe si webhook de preapproval no llega — hoy queda en pending indefinidamente.
- 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. - Frontend para editar precios soportar bulk edit — hoy cada celda se edita individualmente.
- Auditoría de cambios de precio — registrar quién cambió cada precio y cuándo (solo hay
updatedAtahora).