- 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.
1322 lines
46 KiB
TypeScript
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 };
|
|
}
|