Files
HoruxDespachosNuevo/apps/api/src/services/payment/subscription.service.ts
Horux Dev b217342a96 feat(notificaciones): configuración de notificaciones por rol
- Nueva tabla tenant notification_role_preferences para guardar (email_type, role, enabled).
- Migración 051 aplicada a todos los tenants.
- Backend expone endpoint /notificaciones con matriz de preferencias por rol.
- Filtrado por rol en documento_subido, weekly_update, subscription_expiring,
  alertas_nuevas y recordatorio_proximo.
- Frontend rediseñado como tabla notificación × rol con toggles inmediatos.
2026-06-17 00:04:37 +00:00

1322 lines
46 KiB
TypeScript

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<string, { data: any; expires: number }>();
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<number> {
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<void> {
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<void> {
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<void> {
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 };
}