import { prisma, tenantDb } from '../../config/database.js'; import * as mpService from './mercadopago.service.js'; import { emailService } from '../email/email.service.js'; import { auditLog } from '../../utils/audit.js'; import { getTenantOwnerEmail, getTenantOwnerEmails } from '../../utils/memberships.js'; import { filterRecipientsByRole } from '../notification-preferences.service.js'; import { isDespachoPaidPlan, permiteOverage, type DespachoPricePhase } from '@horux/shared'; import { despachoPlanTieneDualidadDb, getPrecioDespachoDb } from '../plan-catalogo.service.js'; import { adjustDespachoOverage, countActiveContribuyentesForTenant, cancelOverageAddonForTenant, } from './addon.service.js'; // Simple in-memory cache with TTL const subscriptionCache = new Map(); export function invalidateSubscriptionCache(tenantId: string) { subscriptionCache.delete(`sub:${tenantId}`); } /** * Creates a subscription record in DB and a MercadoPago preapproval */ export async function createSubscription(params: { tenantId: string; plan: string; amount: number; payerEmail: string; }) { const tenant = await prisma.tenant.findUnique({ where: { id: params.tenantId }, }); if (!tenant) throw new Error('Tenant no encontrado'); // Create MercadoPago preapproval const mp = await mpService.createPreapproval({ tenantId: params.tenantId, reason: `Horux360 - Plan ${params.plan} - ${tenant.nombre}`, amount: params.amount, payerEmail: params.payerEmail, }); // Create DB record const subscription = await prisma.subscription.create({ data: { tenantId: params.tenantId, plan: params.plan as any, status: mp.status || 'pending', amount: params.amount, frequency: 'monthly', mpPreapprovalId: mp.preapprovalId, }, }); invalidateSubscriptionCache(params.tenantId); return { subscription, paymentUrl: mp.initPoint, }; } /** * Gets active subscription for a tenant (cached 5 min) */ export async function getActiveSubscription(tenantId: string) { const cached = subscriptionCache.get(`sub:${tenantId}`); if (cached && cached.expires > Date.now()) return cached.data; const subscription = await prisma.subscription.findFirst({ where: { tenantId }, orderBy: { createdAt: 'desc' }, }); subscriptionCache.set(`sub:${tenantId}`, { data: subscription, expires: Date.now() + 5 * 60 * 1000, }); return subscription; } /** * Updates subscription status from webhook notification */ export async function updateSubscriptionStatus(mpPreapprovalId: string, status: string) { const subscription = await prisma.subscription.findFirst({ where: { mpPreapprovalId }, }); if (!subscription) return null; const updated = await prisma.subscription.update({ where: { id: subscription.id }, data: { status }, }); invalidateSubscriptionCache(subscription.tenantId); // Handle cancellation if (status === 'cancelled') { const tenant = await prisma.tenant.findUnique({ where: { id: subscription.tenantId }, select: { nombre: true }, }); const ownerEmail = await getTenantOwnerEmail(subscription.tenantId); if (tenant && ownerEmail) { emailService.sendSubscriptionCancelled(ownerEmail, { nombre: tenant.nombre, plan: subscription.plan, }).catch(err => console.error('[EMAIL] Subscription cancelled notification failed:', err)); } } return updated; } /** * Records a payment from MercadoPago webhook. Idempotente por `mpPaymentId`: * MP puede mandar el mismo webhook múltiples veces y solo emitimos un email * cuando hay transición de estado (no en cada notificación duplicada). */ export async function recordPayment(params: { tenantId: string; subscriptionId: string; mpPaymentId: string; amount: number; status: string; paymentMethod: string; }) { // Detectar duplicados antes de insertar — `mpPaymentId` no es UNIQUE en el // schema (puede haber colisiones si MP reusa IDs entre flujos), pero combinado // con `tenantId` sí es único en la práctica. const existing = await prisma.payment.findFirst({ where: { tenantId: params.tenantId, mpPaymentId: params.mpPaymentId }, }); const previousStatus = existing?.status ?? null; const statusChanged = previousStatus !== params.status; let payment; if (existing) { payment = await prisma.payment.update({ where: { id: existing.id }, data: { amount: params.amount, status: params.status, paymentMethod: params.paymentMethod, ...(params.status === 'approved' && !existing.paidAt ? { paidAt: new Date() } : {}), }, }); } else { payment = await prisma.payment.create({ data: { tenantId: params.tenantId, subscriptionId: params.subscriptionId, mpPaymentId: params.mpPaymentId, amount: params.amount, status: params.status, paymentMethod: params.paymentMethod, ...(params.status === 'approved' ? { paidAt: new Date() } : {}), }, }); } // Solo notificar cuando hay transición real de estado. if (!statusChanged) return payment; const tenant = await prisma.tenant.findUnique({ where: { id: params.tenantId }, select: { nombre: true }, }); const ownerEmail = await getTenantOwnerEmail(params.tenantId); if (tenant && ownerEmail) { const subscription = await prisma.subscription.findUnique({ where: { id: params.subscriptionId }, }); if (params.status === 'approved') { emailService.sendPaymentConfirmed(ownerEmail, { nombre: tenant.nombre, amount: params.amount, plan: subscription?.plan || 'N/A', date: new Date().toLocaleDateString('es-MX'), }).catch(err => console.error('[EMAIL] Payment confirmed notification failed:', err)); } else if (params.status === 'rejected' || params.status === 'cancelled') { // Tanto `rejected` (banco/MP rechazó) como `cancelled` (user/sistema canceló // antes de cobro) ameritan aviso al owner — el efecto operativo es el mismo: // la suscripción no avanzó. emailService.sendPaymentFailed(ownerEmail, { nombre: tenant.nombre, amount: params.amount, plan: subscription?.plan || 'N/A', }).catch(err => console.error('[EMAIL] Payment failed notification failed:', err)); } } return payment; } /** * Manually marks a subscription as paid (for bank transfers) */ export async function markAsPaidManually(tenantId: string, amount: number) { const subscription = await getActiveSubscription(tenantId); if (!subscription) throw new Error('No hay suscripción activa'); // Update subscription status await prisma.subscription.update({ where: { id: subscription.id }, data: { status: 'authorized' }, }); // Record the manual payment const payment = await prisma.payment.create({ data: { tenantId, subscriptionId: subscription.id, mpPaymentId: `manual-${Date.now()}`, amount, status: 'approved', paymentMethod: 'bank_transfer', }, }); invalidateSubscriptionCache(tenantId); auditLog({ tenantId, action: 'payment.marked_paid_manually', entityType: 'Payment', entityId: payment.id, metadata: { amount, subscriptionId: subscription.id }, }); return payment; } /** * Generates a payment link for a tenant */ export async function generatePaymentLink(tenantId: string) { const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } }); if (!tenant) throw new Error('Tenant no encontrado'); const ownerEmail = await getTenantOwnerEmail(tenantId); if (!ownerEmail) throw new Error('No admin user found'); let subscription = await getActiveSubscription(tenantId); const plan = (subscription?.plan || tenant.plan) as Plan; if (plan === 'custom' || plan === 'trial') { throw new Error('No se puede generar link de pago para el plan actual'); } const frequency = (subscription?.frequency as Frequency) || 'annual'; let amount = subscription?.amount ? Number(subscription.amount) : 0; if (!amount) { amount = await getPlanPrice(plan, frequency, 'firstYear'); } // Los planes Business Control / Enterprise exceden el límite de cobro recurrente // de MercadoPago ($10k). Para esos montos usamos una Preference de pago único // anual; el webhook activa el período de 1 año al aprobarse. if (amount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) { if (!subscription) { subscription = await prisma.subscription.create({ data: { tenantId, plan: plan as any, status: 'pending', amount, frequency, }, }); invalidateSubscriptionCache(tenantId); } const mp = await mpService.createSubscriptionPreference({ tenantId, subscriptionId: subscription.id, plan, amount, payerEmail: ownerEmail, }); await prisma.subscription.update({ where: { id: subscription.id }, data: { mpPreferenceId: mp.preferenceId, status: 'pending', amount }, }); return { paymentUrl: mp.checkoutUrl }; } const mp = await mpService.createPreapproval({ tenantId, reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`, amount, payerEmail: ownerEmail, frequency, }); if (subscription) { await prisma.subscription.update({ where: { id: subscription.id }, data: { mpPreapprovalId: mp.preapprovalId, status: mp.status || 'pending' }, }); } else { await prisma.subscription.create({ data: { tenantId, plan: plan as any, status: mp.status || 'pending', amount, frequency, mpPreapprovalId: mp.preapprovalId, }, }); invalidateSubscriptionCache(tenantId); } return { paymentUrl: mp.initPoint }; } /** * Gets payment history for a tenant */ export async function getPaymentHistory(tenantId: string) { return prisma.payment.findMany({ where: { tenantId }, orderBy: { createdAt: 'desc' }, take: 50, }); } // ============================================================================ // Self-serve lifecycle (trial, subscribe, change, cancel) // ============================================================================ type Plan = 'trial' | 'custom' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus'; type Frequency = 'monthly' | 'annual'; /** * Precio vigente para un (plan, frequency, phase). Lee de BD vía * `despacho_plan_prices` con cache 5min — admin global edita desde * `/configuracion/precios-suscripcion` y los nuevos precios aplican * inmediatamente (cache invalidation post-edit). * * - Mi Empresa / Mi Empresa+: aceptan `monthly` o `annual` (anual = 10 * meses, descuento ~17%). * - Business Control / Enterprise: solo `annual` (falla si monthly). * - `custom`: no tiene precio en catálogo, el admin lo fija por tenant. */ export async function getPlanPrice( plan: Plan, frequency: Frequency, phase: DespachoPricePhase = 'renewal', ): Promise { if (plan === 'custom') { throw new Error('El plan custom no tiene precio en plan_prices — usa createTenant con amount explícito'); } return getPrecioDespachoDb(plan, frequency, phase); } /** * Activa prueba gratuita de 30 días. Una sola vez **por RFC** y, si se pasa * `ownerUserId`, también una sola vez **por humano** — un mismo dueño no puede * obtener trials nuevos creando RFCs adicionales. * * Gates: * 1. Plan no puede ser custom * 2. Tenant no puede tener ya un `trialEndsAt` (su propia prueba en curso o consumida) * 3. RFC normalizado NO debe existir en `trial_usages` * 4. Si `ownerUserId`: ningún otro tenant donde el user es owner debe tener * `trialEndsAt` (cada humano tiene derecho a 1 sola prueba) * 5. No hay otra suscripción activa/pendiente/trial para este tenant * * Inserta el RFC en `trial_usages` dentro de la transacción — si algo falla, rollback * deja el padrón sin la marca (consistente con la no-creación del trial). */ export async function startTrial(params: { tenantId: string; plan: Plan; frequency: Frequency; ownerUserId?: string; }): Promise<{ subscription: any; trialEndsAt: Date }> { if (params.plan === 'custom') { throw new Error('No se puede iniciar trial en plan custom'); } const tenant = await prisma.tenant.findUnique({ where: { id: params.tenantId } }); if (!tenant) throw new Error('Tenant no encontrado'); if (tenant.trialEndsAt) throw new Error('Este tenant ya usó su prueba gratuita'); // Gate persistente: RFC ya consumió trial en algún tenant (actual o previo) const normalizedRfc = tenant.rfc.toUpperCase(); const priorUsage = await prisma.trialUsage.findUnique({ where: { rfc: normalizedRfc }, }); if (priorUsage) { throw new Error( `El RFC ${normalizedRfc} ya consumió su prueba gratuita. ` + `Cada RFC tiene derecho a una sola prueba de 30 días. Contrata un plan para continuar.` ); } // Gate por owner: el mismo humano no puede usar trial dos veces creando RFCs // distintos. Cubre el escenario "borro tenant, creo otro, pido trial otra vez" // y "agrego segundo RFC bajo mi cuenta y pido trial". if (params.ownerUserId) { const ownedTenantWithTrial = await prisma.tenantMembership.findFirst({ where: { userId: params.ownerUserId, isOwner: true, active: true, tenantId: { not: params.tenantId }, tenant: { trialEndsAt: { not: null } }, }, select: { tenant: { select: { rfc: true } } }, }); if (ownedTenantWithTrial) { throw new Error( `Ya consumiste una prueba gratuita con otro RFC (${ownedTenantWithTrial.tenant.rfc}). ` + `Cada dueño tiene derecho a una sola prueba de 30 días. Para esta empresa contrata un plan directamente.` ); } } const existing = await prisma.subscription.findFirst({ where: { tenantId: params.tenantId, status: { in: ['trial', 'pending', 'authorized', 'paused'] } }, }); if (existing) throw new Error('Ya existe una suscripción activa o pendiente'); const trialEndsAt = new Date(); trialEndsAt.setDate(trialEndsAt.getDate() + 30); const now = new Date(); const subscription = await prisma.$transaction(async (tx) => { await tx.tenant.update({ where: { id: params.tenantId }, data: { trialEndsAt, plan: params.plan }, }); // Registra el RFC en el padrón — unique constraint previene race condition await tx.trialUsage.create({ data: { rfc: normalizedRfc, tenantId: params.tenantId, }, }); return tx.subscription.create({ data: { tenantId: params.tenantId, plan: params.plan, status: 'trial', amount: 0, frequency: params.frequency, currentPeriodStart: now, currentPeriodEnd: trialEndsAt, }, }); }); invalidateSubscriptionCache(params.tenantId); auditLog({ tenantId: params.tenantId, action: 'trial.started', entityType: 'Subscription', entityId: subscription.id, metadata: { plan: params.plan, frequency: params.frequency, rfc: normalizedRfc, trialEndsAt: trialEndsAt.toISOString() }, }); console.log(`[Trial] Iniciado para tenant ${params.tenantId} (RFC ${normalizedRfc}), vence ${trialEndsAt.toISOString()}`); return { subscription, trialEndsAt }; } /** * Crea una suscripción self-serve (el usuario eligió plan + frecuencia). * Lee precio de `plan_prices`, crea preapproval en MP, retorna paymentUrl. * * Falla si ya hay suscripción activa/pendiente o trial no vencido. Para cambiar * de plan durante una suscripción activa, usa `scheduleChange`. */ export async function subscribe(params: { tenantId: string; plan: Plan; frequency: Frequency; payerEmail: string; }): Promise<{ subscription: any; paymentUrl: string }> { if (params.plan === 'custom') { throw new Error('Plan custom no es self-serve — lo activa el admin global al crear tenant'); } const tenant = await prisma.tenant.findUnique({ where: { id: params.tenantId } }); if (!tenant) throw new Error('Tenant no encontrado'); const existing = await prisma.subscription.findFirst({ where: { tenantId: params.tenantId, status: { in: ['authorized', 'pending', 'paused'] }, }, }); if (existing) { throw new Error('Ya existe una suscripción activa o pendiente — usa "Cambiar plan" para modificar'); } // En planes despacho con dualidad (business_control: $21K primer año, $15K // renovaciones) creamos el preapproval con `firstYear`. Tras el primer pago // aprobado, el webhook llama `updatePreapprovalAmount` para bajar al monto // de renewal en los siguientes cobros. El `reason` explica ambos montos al // usuario en la pantalla de autorización MP. const amount = await getPlanPrice(params.plan, params.frequency, 'firstYear'); const hasDualidad = isDespachoPaidPlan(params.plan) && await despachoPlanTieneDualidadDb(params.plan); const renewalAmount = hasDualidad ? await getPlanPrice(params.plan, params.frequency, 'renewal') : amount; const reason = hasDualidad ? `${tenant.nombre} - Plan ${params.plan} - $${amount.toLocaleString('es-MX')} primer año, $${renewalAmount.toLocaleString('es-MX')} renovaciones` : `Horux360 - Plan ${params.plan} (${params.frequency}) - ${tenant.nombre}`; // Planes Business Control / Enterprise superan el límite de cobro recurrente // de MercadoPago ($10k). Se cobra el año completo vía Preference one-off; el // webhook activa el período anual tras el primer pago aprobado. if (amount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) { const subscription = await prisma.subscription.create({ data: { tenantId: params.tenantId, plan: params.plan, status: 'pending', amount, frequency: params.frequency, }, }); const mp = await mpService.createSubscriptionPreference({ tenantId: params.tenantId, subscriptionId: subscription.id, plan: params.plan, amount, payerEmail: params.payerEmail, }); await prisma.subscription.update({ where: { id: subscription.id }, data: { mpPreferenceId: mp.preferenceId }, }); await prisma.subscription.updateMany({ where: { tenantId: params.tenantId, status: 'trial' }, data: { status: 'trial_converted' }, }); await prisma.tenant.update({ where: { id: params.tenantId }, data: { plan: params.plan }, }); invalidateSubscriptionCache(params.tenantId); auditLog({ tenantId: params.tenantId, action: 'subscription.created', entityType: 'Subscription', entityId: subscription.id, metadata: { plan: params.plan, frequency: params.frequency, amount, paymentMethod: 'preference' }, }); return { subscription, paymentUrl: mp.checkoutUrl }; } const mp = await mpService.createPreapproval({ tenantId: params.tenantId, reason, amount, payerEmail: params.payerEmail, frequency: params.frequency, }); // Si había un trial activo, lo marca como completed (no cancelled: el trial terminó exitosamente) await prisma.subscription.updateMany({ where: { tenantId: params.tenantId, status: 'trial' }, data: { status: 'trial_converted' }, }); const subscription = await prisma.subscription.create({ data: { tenantId: params.tenantId, plan: params.plan, status: mp.status || 'pending', amount, frequency: params.frequency, mpPreapprovalId: mp.preapprovalId, }, }); await prisma.tenant.update({ where: { id: params.tenantId }, data: { plan: params.plan }, }); invalidateSubscriptionCache(params.tenantId); auditLog({ tenantId: params.tenantId, action: 'subscription.created', entityType: 'Subscription', entityId: subscription.id, metadata: { plan: params.plan, frequency: params.frequency, amount }, }); return { subscription, paymentUrl: mp.initPoint }; } /** * Calcula el monto a cobrar por un upgrade prorateado. * * proration = (newAmount - currentAmount) * (daysRemaining / periodDays) * * Redondeado a 2 decimales. Si no hay días restantes (período vencido), retorna 0 * — el caller debe caer en scheduleChange en vez de upgrade inmediato. */ export function calculateProration( currentAmount: number, newAmount: number, periodStart: Date | null, periodEnd: Date | null, ): { amount: number; daysRemaining: number; periodDays: number } { if (!periodStart || !periodEnd) return { amount: 0, daysRemaining: 0, periodDays: 0 }; const now = Date.now(); const endMs = periodEnd.getTime(); if (endMs <= now) return { amount: 0, daysRemaining: 0, periodDays: 0 }; const msPerDay = 1000 * 60 * 60 * 24; const daysRemaining = Math.max(0, Math.ceil((endMs - now) / msPerDay)); const periodDays = Math.max(1, Math.ceil((endMs - periodStart.getTime()) / msPerDay)); const fraction = Math.min(1, daysRemaining / periodDays); const diff = Math.max(0, newAmount - currentAmount); const amount = Math.round(diff * fraction * 100) / 100; return { amount, daysRemaining, periodDays }; } /** * Inicia un upgrade con cobro prorateado inmediato. * * Flujo: * 1. Valida que sea estrictamente un upgrade (precio nuevo > precio actual, misma frecuencia) * 2. Calcula el prorateo por días restantes del período actual * 3. Crea una Preference de MercadoPago para ese monto one-time * 4. Guarda en Subscription: upgradePreferenceId + upgradeTargetPlan + upgradeTargetAmount * 5. Retorna checkoutUrl — el cliente lo abre en nueva pestaña, el usuario paga * 6. El webhook detecta `external_reference: proration:*` y llama `applyApprovedUpgrade` * * Si falla antes de crear la preference, no hay estado que revertir. Si falla después * del MP call pero antes del DB update, la preference queda huérfana en MP (expirará sola). */ export async function initiateUpgrade(params: { tenantId: string; newPlan: Plan; payerEmail: string; }): Promise<{ subscription: any; checkoutUrl: string; proratedAmount: number }> { if (params.newPlan === 'custom') { throw new Error('No se puede upgrade a plan custom — lo asigna el admin global'); } const active = await prisma.subscription.findFirst({ where: { tenantId: params.tenantId, status: { in: ['authorized', 'trial'] }, }, orderBy: { createdAt: 'desc' }, }); if (!active) throw new Error('No hay suscripción activa para upgrade'); if (active.upgradePreferenceId) { throw new Error('Ya hay un upgrade en curso — cancélalo antes de iniciar otro'); } const currentFrequency = (active.frequency as Frequency) || 'monthly'; const newAmount = await getPlanPrice(params.newPlan, currentFrequency); const currentAmount = Number(active.amount); if (newAmount <= currentAmount) { throw new Error('El plan seleccionado no es un upgrade (precio menor o igual). Usa scheduleChange para downgrades.'); } const { amount: proratedAmount, daysRemaining } = calculateProration( currentAmount, newAmount, active.currentPeriodStart, active.currentPeriodEnd, ); if (proratedAmount <= 0) { throw new Error('No hay días restantes del período actual para prorratear — espera a que termine y contrata el nuevo plan'); } const tenant = await prisma.tenant.findUnique({ where: { id: params.tenantId } }); if (!tenant) throw new Error('Tenant no encontrado'); const description = `Upgrade a ${params.newPlan} — prorateo ${daysRemaining} día${daysRemaining !== 1 ? 's' : ''} restante${daysRemaining !== 1 ? 's' : ''} del período actual`; const { preferenceId, checkoutUrl } = await mpService.createProrationPreference({ tenantId: params.tenantId, subscriptionId: active.id, amount: proratedAmount, description, payerEmail: params.payerEmail, }); const updated = await prisma.subscription.update({ where: { id: active.id }, data: { upgradePreferenceId: preferenceId, upgradeTargetPlan: params.newPlan, upgradeTargetAmount: newAmount, }, }); invalidateSubscriptionCache(params.tenantId); console.log(`[Upgrade] Iniciado para tenant ${params.tenantId}: ${active.plan}→${params.newPlan} (${currentFrequency}). Prorateo: $${proratedAmount}. Preference: ${preferenceId}`); return { subscription: updated, checkoutUrl, proratedAmount }; } /** * Aplica un upgrade cuyo cobro prorateado fue aprobado por MercadoPago (llamado desde webhook). * * Acciones: * 1. Actualiza el monto recurrente del preapproval existente al nuevo precio * 2. Actualiza Subscription: plan, amount, status=authorized, limpia campos de upgrade * 3. Actualiza Tenant.plan * * Si el paso 1 falla (MP API down), re-lanza para que el webhook no consuma el evento — * MP reintentará. El paso 2 y 3 deben ser atómicos vía transacción. */ export async function applyApprovedUpgrade(subscriptionId: string): Promise { const sub = await prisma.subscription.findUnique({ where: { id: subscriptionId }, }); if (!sub) throw new Error(`Subscription ${subscriptionId} no encontrada`); if (!sub.upgradeTargetPlan || !sub.upgradeTargetAmount) { console.warn(`[Upgrade] Sub ${subscriptionId} sin campos upgradeTarget* — probable webhook duplicado o race, ignorando`); return; } const newPlan = sub.upgradeTargetPlan as Plan; const newAmount = Number(sub.upgradeTargetAmount); // Actualiza el monto del preapproval en MP (si existe). Si el nuevo monto // supera el límite de cobro recurrente de MP ($10k), cancelamos el preapproval // anterior: el plan alto se cobrará anualmente vía Preference one-off. if (sub.mpPreapprovalId) { if (newAmount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) { await mpService.cancelPreapproval(sub.mpPreapprovalId); console.log(`[Upgrade] Preapproval ${sub.mpPreapprovalId} cancelado porque el nuevo monto $${newAmount} supera el límite de MP`); } else { try { await mpService.updatePreapprovalAmount(sub.mpPreapprovalId, newAmount); } catch (error: any) { console.error(`[Upgrade] Error actualizando preapproval ${sub.mpPreapprovalId}:`, error.message); throw error; // Re-lanza para que MP reintente el webhook } } } await prisma.$transaction([ prisma.subscription.update({ where: { id: sub.id }, data: { plan: newPlan, amount: newAmount, status: 'authorized', upgradePreferenceId: null, upgradeTargetPlan: null, upgradeTargetAmount: null, }, }), prisma.tenant.update({ where: { id: sub.tenantId }, data: { plan: newPlan }, }), ]); invalidateSubscriptionCache(sub.tenantId); auditLog({ tenantId: sub.tenantId, action: 'subscription.plan_changed', entityType: 'Subscription', entityId: sub.id, metadata: { kind: 'upgrade_immediate', fromPlan: sub.plan, toPlan: newPlan, frequency: sub.frequency, newAmount, }, }); console.log(`[Upgrade] Aplicado exitosamente para tenant ${sub.tenantId}: ${sub.plan}→${newPlan} ($${newAmount}/${sub.frequency})`); // Reajusta el overage por contribuyente extra al nuevo plan: si pasa de // un plan sin overage (mi_empresa, etc.) a uno con overage (business_*), // crea el addon. Si pasa al revés, lo cancela. Fail-soft. await reconcileOverageAfterPlanChange(sub.tenantId, sub.plan, newPlan); } /** * Después de cualquier cambio de plan (upgrade, scheduled change aplicado, * cancelación), ajusta el add-on de overage según corresponda al nuevo plan. * Fail-soft: cualquier error se logea sin propagar. */ async function reconcileOverageAfterPlanChange( tenantId: string, fromPlan: string, toPlan: string, ): Promise { try { if (permiteOverage(toPlan)) { const count = await countActiveContribuyentesForTenant(tenantId); const result = await adjustDespachoOverage(tenantId, count); if (result.action !== 'none' && result.action !== 'skipped') { console.log(`[Overage] Reconcile ${fromPlan}→${toPlan} (tenant ${tenantId}): ${result.action} (count=${result.overageCount})`); } } else { // Plan nuevo NO permite overage → cancelar addon si existía. const r = await cancelOverageAddonForTenant(tenantId); if (r.cancelled) { console.log(`[Overage] Cancelado tras cambio a plan ${toPlan} sin overage (tenant ${tenantId})`); } } } catch (err: any) { console.error(`[Overage] Reconcile ${fromPlan}→${toPlan} (tenant ${tenantId}) fallo:`, err.message || err); } } /** * Aborta un upgrade en curso (el usuario cambió de opinión antes de pagar la preference). * Simplemente limpia los campos — MP dejará expirar la preference sola. */ export async function cancelPendingUpgrade(tenantId: string): Promise { const sub = await prisma.subscription.findFirst({ where: { tenantId, upgradePreferenceId: { not: null } }, }); if (!sub) throw new Error('No hay upgrade en curso para este tenant'); await prisma.subscription.update({ where: { id: sub.id }, data: { upgradePreferenceId: null, upgradeTargetPlan: null, upgradeTargetAmount: null, }, }); invalidateSubscriptionCache(tenantId); console.log(`[Upgrade] Cancelado pendiente para tenant ${tenantId}`); } /** * Programa un cambio de plan o frecuencia al próximo período. * Se aplica por el cron `applyPendingChanges` cuando `pendingEffectiveAt` llega. * * Usado para downgrades y cambios de frecuencia. Upgrades con misma frecuencia * deben ir por `initiateUpgrade` (cobro prorateado inmediato). */ export async function scheduleChange(params: { tenantId: string; newPlan: Plan; newFrequency: Frequency; }): Promise<{ subscription: any; effectiveAt: Date }> { if (params.newPlan === 'custom') { throw new Error('No se puede cambiar a plan custom — lo asigna el admin global'); } const active = await prisma.subscription.findFirst({ where: { tenantId: params.tenantId, status: { in: ['authorized', 'trial'] }, }, orderBy: { createdAt: 'desc' }, }); if (!active) throw new Error('No hay suscripción activa para cambiar'); if (active.plan === params.newPlan && active.frequency === params.newFrequency) { throw new Error('El plan y frecuencia son iguales a los actuales'); } // Valida que el nuevo plan/frecuencia tenga precio await getPlanPrice(params.newPlan, params.newFrequency); // Si no hay currentPeriodEnd (raro — trial en curso u otro caso), // programa el cambio para mañana como salvaguarda const effectiveAt = active.currentPeriodEnd || new Date(Date.now() + 24 * 60 * 60 * 1000); const updated = await prisma.subscription.update({ where: { id: active.id }, data: { pendingPlan: params.newPlan, pendingFrequency: params.newFrequency, pendingEffectiveAt: effectiveAt, }, }); invalidateSubscriptionCache(params.tenantId); auditLog({ tenantId: params.tenantId, action: 'subscription.plan_changed', entityType: 'Subscription', entityId: updated.id, metadata: { kind: 'scheduled', fromPlan: active.plan, toPlan: params.newPlan, fromFrequency: active.frequency, toFrequency: params.newFrequency, effectiveAt: effectiveAt.toISOString(), }, }); return { subscription: updated, effectiveAt }; } /** * Reactiva una suscripción cancelada que aún está dentro de su período pagado. * * MP preapproval cancelado es terminal — no se puede revivir. Esta función crea * un preapproval nuevo con los mismos parámetros (plan/amount/frequency) y * `start_date = currentPeriodEnd` para que el primer cobro caiga al final del * período ya pagado (evita doble cobro). * * Resultado: subscription con status=pending + nuevo mpPreapprovalId. El usuario * debe abrir paymentUrl y autorizar en MP. Al autorizar, webhook → authorized. * * Validaciones: * - Debe existir una subscription status=cancelled con currentPeriodEnd en el futuro * - Si el período ya venció, redirige al flujo normal de subscribe (picker) */ export async function reactivateSubscription(params: { tenantId: string; payerEmail: string; }): Promise<{ subscription: any; paymentUrl: string }> { const cancelled = await prisma.subscription.findFirst({ where: { tenantId: params.tenantId, status: 'cancelled', }, orderBy: { createdAt: 'desc' }, }); if (!cancelled) { throw new Error('No hay suscripción cancelada para reactivar'); } const now = new Date(); if (!cancelled.currentPeriodEnd || cancelled.currentPeriodEnd.getTime() <= now.getTime()) { throw new Error('El período pagado ya venció — contrata un nuevo plan desde el selector'); } if (cancelled.plan === 'custom') { throw new Error('Reactivación de plan custom requiere coordinación con el admin global'); } const tenant = await prisma.tenant.findUnique({ where: { id: params.tenantId } }); if (!tenant) throw new Error('Tenant no encontrado'); const amount = Number(cancelled.amount); const frequency = (cancelled.frequency as Frequency) || 'monthly'; const mp = await mpService.createPreapproval({ tenantId: params.tenantId, reason: `Horux360 - Reactivación Plan ${cancelled.plan} (${frequency}) - ${tenant.nombre}`, amount, payerEmail: params.payerEmail, frequency, startDate: cancelled.currentPeriodEnd, // Primer cobro al final del período actual }); const updated = await prisma.subscription.update({ where: { id: cancelled.id }, data: { status: 'pending', mpPreapprovalId: mp.preapprovalId, // Limpia cualquier upgrade/change pendiente que hubiera antes de cancelar pendingPlan: null, pendingFrequency: null, pendingEffectiveAt: null, upgradePreferenceId: null, upgradeTargetPlan: null, upgradeTargetAmount: null, }, }); // Asegura que tenant.plan refleje el plan reactivado await prisma.tenant.update({ where: { id: params.tenantId }, data: { plan: cancelled.plan }, }); invalidateSubscriptionCache(params.tenantId); auditLog({ tenantId: params.tenantId, action: 'subscription.reactivated', entityType: 'Subscription', entityId: updated.id, metadata: { plan: cancelled.plan, frequency, nextChargeAt: cancelled.currentPeriodEnd!.toISOString() }, }); console.log(`[Reactivate] Tenant ${params.tenantId}: ${cancelled.plan} (${frequency}), próximo cobro: ${cancelled.currentPeriodEnd.toISOString()}`); return { subscription: updated, paymentUrl: mp.initPoint }; } /** * Cancela la suscripción activa. El acceso continúa hasta `currentPeriodEnd` * (el middleware `plan-limits` sigue respetando `status in (authorized, cancelled)` * con periodo vigente — no requiere cambio). * * También cancela el preapproval en MercadoPago para que no se siga cobrando. */ export async function cancelSubscription(tenantId: string): Promise<{ subscription: any }> { const active = await prisma.subscription.findFirst({ where: { tenantId, status: { in: ['authorized', 'trial', 'pending', 'paused'] }, }, orderBy: { createdAt: 'desc' }, }); if (!active) throw new Error('No hay suscripción activa para cancelar'); // Cancela el addon de overage primero (antes de marcar la sub como cancelled // para que el lookup de la sub aún la encuentre activa). Fail-soft. try { const r = await cancelOverageAddonForTenant(tenantId); if (r.cancelled) console.log(`[Overage] Cancelado por cancelación de suscripción (tenant ${tenantId})`); } catch (err: any) { console.error(`[Overage] Error cancelando addon en cancelSubscription (tenant ${tenantId}):`, err.message || err); } if (active.mpPreapprovalId) { await mpService.cancelPreapproval(active.mpPreapprovalId); } const updated = await prisma.subscription.update({ where: { id: active.id }, data: { status: 'cancelled', pendingPlan: null, pendingFrequency: null, pendingEffectiveAt: null, }, }); invalidateSubscriptionCache(tenantId); auditLog({ tenantId, action: 'subscription.cancelled', entityType: 'Subscription', entityId: updated.id, metadata: { plan: active.plan, currentPeriodEnd: active.currentPeriodEnd?.toISOString() }, }); // Email notificación (non-blocking) const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { nombre: true }, }); const ownerEmail = await getTenantOwnerEmail(tenantId); if (tenant && ownerEmail) { emailService.sendSubscriptionCancelled(ownerEmail, { nombre: tenant.nombre, plan: active.plan, }).catch(err => console.error('[EMAIL] Cancellation email failed:', err)); } return { subscription: updated }; } // ============================================================================ // Cron helpers: apply pending changes + expire trials // ============================================================================ /** * Aplica cambios de plan programados cuyo `pendingEffectiveAt` ya pasó. * Llamado por cron diario. * * Para cada cambio pendiente: * 1. Cancela el preapproval viejo en MP * 2. Crea nuevo preapproval con el nuevo plan/frecuencia/monto * 3. Actualiza la Subscription a `pending` (esperando que el usuario autorice el nuevo preapproval) * * Nota: si el usuario no autoriza el nuevo preapproval rápidamente, el middleware * `plan-limits` lo trata como pending — aún con acceso según período previo. */ export async function applyPendingChanges(): Promise<{ applied: number; errors: number }> { const now = new Date(); const pending = await prisma.subscription.findMany({ where: { pendingEffectiveAt: { lte: now, not: null }, pendingPlan: { not: null }, status: { in: ['authorized', 'trial'] }, }, include: { tenant: true }, }); let applied = 0; let errors = 0; for (const sub of pending) { try { const newPlan = sub.pendingPlan as Plan; const newFrequency = (sub.pendingFrequency || 'monthly') as Frequency; const newAmount = await getPlanPrice(newPlan, newFrequency); const adminEmail = await getTenantOwnerEmail(sub.tenantId); if (!adminEmail) { console.error(`[Pending] Sub ${sub.id} sin admin user — omito`); errors++; continue; } // Cancelar preapproval viejo if (sub.mpPreapprovalId) { await mpService.cancelPreapproval(sub.mpPreapprovalId); } // Crear preapproval nuevo const mp = await mpService.createPreapproval({ tenantId: sub.tenantId, reason: `Horux360 - Plan ${newPlan} (${newFrequency}) - ${sub.tenant.nombre}`, amount: newAmount, payerEmail: adminEmail, frequency: newFrequency, }); await prisma.$transaction([ prisma.subscription.update({ where: { id: sub.id }, data: { plan: newPlan, frequency: newFrequency, amount: newAmount, status: 'pending', mpPreapprovalId: mp.preapprovalId, pendingPlan: null, pendingFrequency: null, pendingEffectiveAt: null, currentPeriodStart: now, // currentPeriodEnd se actualizará al recibir el webhook de authorization }, }), prisma.tenant.update({ where: { id: sub.tenantId }, data: { plan: newPlan }, }), ]); invalidateSubscriptionCache(sub.tenantId); // Reajusta el overage según el nuevo plan (fail-soft). await reconcileOverageAfterPlanChange(sub.tenantId, sub.plan, newPlan); applied++; console.log(`[Pending] Aplicado cambio para tenant ${sub.tenantId}: ${sub.plan}→${newPlan} (${newFrequency})`); } catch (error: any) { console.error(`[Pending] Error en sub ${sub.id}:`, error.message); errors++; } } return { applied, errors }; } /** * Cron diario de avisos pre-vencimiento. Itera suscripciones cuyo `currentPeriodEnd` * está dentro de los próximos 7 días (o el día mismo del vencimiento). Por cada una * envía un email al owner con el bucket apropiado (7d, 3d, 1d, 0d) y guarda el bucket * en `lastReminderDay` para no duplicar. * * Idempotencia: * - Si el bucket actual es menor o igual al guardado, ya se notificó este bucket → skip. * - Si el bucket actual es MAYOR que el guardado, el período rolló (renovación) — se * actualiza `lastReminderDay` al nuevo bucket pero NO se envía email (el período * nuevo está lejos, no hay nada que avisar). Próximas corridas avisarán al bajar. * * Buckets (días restantes): 7 → 3 → 1 → 0 (post-vencimiento, último aviso de cortesía). * Trial usa template `trialReminder`/`trialExpired`; suscripciones de pago usan * `subscriptionExpiring`. `cancelled` dentro de período también recibe aviso (es la * señal final antes de pagar de menos). */ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly: number; skipped: number; errors: number }> { const now = new Date(); const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); // Solo avisamos a tenants que estén en alguno de estos estados — pending y // paused no aplican (no han llegado al primer cobro o están suspendidas por MP). // trial_expired y trial post-expiry caen en el bucket 0 si están dentro del // último día — ahí mandamos el aviso final de "tu prueba terminó". const candidates = await prisma.subscription.findMany({ where: { OR: [ // Activos por vencer { status: { in: ['authorized', 'trial', 'cancelled'] }, currentPeriodEnd: { lte: sevenDaysFromNow, gte: oneDayAgo } }, // Trial recién vencido (último aviso 0d) { status: 'trial_expired', currentPeriodEnd: { gte: oneDayAgo } }, ], }, include: { tenant: { select: { nombre: true, rfc: true, databaseName: true } } }, }); let sent = 0; let resetOnly = 0; let skipped = 0; let errors = 0; for (const sub of candidates) { if (!sub.currentPeriodEnd) continue; // Calcula el bucket actual de días restantes (al cero más cercano hacia abajo). const msUntil = sub.currentPeriodEnd.getTime() - now.getTime(); const daysUntil = Math.ceil(msUntil / (24 * 60 * 60 * 1000)); let bucket: number | null = null; if (daysUntil <= 0) bucket = 0; else if (daysUntil <= 1) bucket = 1; else if (daysUntil <= 3) bucket = 3; else if (daysUntil <= 7) bucket = 7; if (bucket === null) { skipped++; continue; } const lastBucket = sub.lastReminderDay; // Período renovado (ej. lastBucket=0 y bucket=7) — actualiza el tracker pero no envía. if (lastBucket !== null && bucket > lastBucket) { await prisma.subscription.update({ where: { id: sub.id }, data: { lastReminderDay: bucket, lastReminderSentAt: now }, }); resetOnly++; continue; } // Mismo o menor — ya se notificó este bucket o más cercano. if (lastBucket !== null && bucket >= lastBucket) { skipped++; continue; } // Hay algo que avisar. try { // Para suscripciones de pago, respeta preferencia 'subscription_expiring' del rol owner. // Para trials siempre avisa al owner (no depende de preferencias de notificación informativa). const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired'; let emailsToNotify: string[] = []; if (isTrialFlow) { const ownerEmail = await getTenantOwnerEmail(sub.tenantId); if (ownerEmail) emailsToNotify = [ownerEmail]; } else { const pool = await tenantDb.getPool(sub.tenantId, sub.tenant.databaseName); const ownerEmails = await getTenantOwnerEmails(sub.tenantId); const recipientsWithRole = ownerEmails.map(email => ({ email, role: 'owner' as const })); emailsToNotify = await filterRecipientsByRole(pool, 'subscription_expiring', recipientsWithRole); } if (emailsToNotify.length === 0) { skipped++; continue; } for (const ownerEmail of emailsToNotify) { if (isTrialFlow) { if (bucket === 0) { await emailService.sendTrialExpired(ownerEmail, { nombre: sub.tenant.nombre, despachoNombre: sub.tenant.nombre, }); } else { await emailService.sendTrialReminder(ownerEmail, { nombre: sub.tenant.nombre, despachoNombre: sub.tenant.nombre, diasRestantes: Math.max(0, daysUntil), wizardCompleto: true, }); } } else { await emailService.sendSubscriptionExpiring(ownerEmail, { nombre: sub.tenant.nombre, plan: sub.plan, expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }), }); } } await prisma.subscription.update({ where: { id: sub.id }, data: { lastReminderDay: bucket, lastReminderSentAt: now }, }); sent++; } catch (err: any) { console.error(`[ExpiryReminder] Error en sub ${sub.id}:`, err.message || err); errors++; } } return { sent, resetOnly, skipped, errors }; } /** * Transiciona trials vencidos (`Tenant.trialEndsAt` ya pasó) a `pending`. * El usuario debe agregar método de pago para continuar — el middleware `plan-limits` * empezará a restringir features. */ export async function expireTrials(): Promise<{ expired: number }> { const now = new Date(); const expiredTrials = await prisma.subscription.findMany({ where: { status: 'trial', currentPeriodEnd: { lt: now } }, }); let expired = 0; for (const sub of expiredTrials) { await prisma.subscription.update({ where: { id: sub.id }, data: { status: 'trial_expired' }, }); invalidateSubscriptionCache(sub.tenantId); expired++; console.log(`[Trial] Expiró trial de tenant ${sub.tenantId} (plan ${sub.plan})`); } return { expired }; }