import { prisma } from '../config/database.js'; export interface PlanLimits { maxRfcs: number; maxUsers: number; timbresIncluidosMes: number; features: string[]; } export interface AddonDelta { maxRfcs?: number; maxUsers?: number; timbresIncluidosMes?: number; features?: string[]; } export interface DespachoPlanLimits { plan: string; nombre: string; monthly: number | null; firstYear: number | null; renewal: number | null; permiteMonthly: boolean; maxRfcs: number; maxUsers: number; timbresIncluidosMes: number; dbMode: 'BYO' | 'MANAGED'; permiteServidorBackup: boolean; permiteSatIncremental: boolean; } /** Suma deltas de addons activos sobre los limits base de un plan. -1 = ilimitado se preserva. */ export function computeEffectiveLimits(baseLimits: PlanLimits, addonDeltas: AddonDelta[]): PlanLimits { const result: PlanLimits = { maxRfcs: baseLimits.maxRfcs, maxUsers: baseLimits.maxUsers, timbresIncluidosMes: baseLimits.timbresIncluidosMes, features: [...baseLimits.features], }; for (const delta of addonDeltas) { if (delta.maxRfcs) { result.maxRfcs = result.maxRfcs === -1 ? -1 : result.maxRfcs + delta.maxRfcs; } if (delta.maxUsers) { result.maxUsers = result.maxUsers === -1 ? -1 : result.maxUsers + delta.maxUsers; } if (delta.timbresIncluidosMes) { result.timbresIncluidosMes += delta.timbresIncluidosMes; } if (delta.features) { for (const f of delta.features) { if (!result.features.includes(f)) result.features.push(f); } } } return result; } // ============================================================================ // Catálogo despacho — lee de despacho_plan_prices con cache 5min // ============================================================================ const CACHE_TTL_MS = 5 * 60 * 1000; let cacheData: Map | null = null; let cacheExpiresAt = 0; async function loadCache(): Promise> { const rows = await prisma.despachoPlanPrice.findMany(); const map = new Map(); for (const r of rows) { map.set(r.plan, { plan: r.plan, nombre: r.nombre, monthly: r.monthly !== null ? Number(r.monthly) : null, firstYear: r.firstYear !== null ? Number(r.firstYear) : null, renewal: r.renewal !== null ? Number(r.renewal) : null, permiteMonthly: r.permiteMonthly, maxRfcs: r.maxRfcs, maxUsers: r.maxUsers, timbresIncluidosMes: r.timbresIncluidosMes, dbMode: r.dbMode as 'BYO' | 'MANAGED', permiteServidorBackup: r.permiteServidorBackup, permiteSatIncremental: r.permiteSatIncremental, }); } cacheData = map; cacheExpiresAt = Date.now() + CACHE_TTL_MS; return map; } /** Invalida el cache. Llamar tras editar precios/limits desde admin. */ export function invalidateDespachoPlanCache(): void { cacheData = null; cacheExpiresAt = 0; } /** Lee limits + precios de un plan despacho. Cache 5min. */ export async function getDespachoPlanLimits(plan: string): Promise { if (!cacheData || Date.now() > cacheExpiresAt) await loadCache(); return cacheData!.get(plan) ?? null; } /** Lee todos los planes despacho. Cache 5min. */ export async function getAllDespachoPlanLimits(): Promise { if (!cacheData || Date.now() > cacheExpiresAt) await loadCache(); return Array.from(cacheData!.values()); } /** True si el plan acepta frecuencia mensual. Lee de BD via cache. */ export async function permiteFrecuenciaMensualDb(plan: string): Promise { const cfg = await getDespachoPlanLimits(plan); return cfg?.permiteMonthly ?? false; } /** True si el plan cobra distinto en el primer año vs renovaciones. Lee de BD. */ export async function despachoPlanTieneDualidadDb(plan: string): Promise { const cfg = await getDespachoPlanLimits(plan); if (!cfg || cfg.firstYear === null || cfg.renewal === null) return false; return cfg.firstYear !== cfg.renewal; } /** * Resuelve el precio MXN para un (plan, frequency, phase) leyendo de BD. * Throws si el plan no existe en BD o no permite la frecuencia solicitada. */ export async function getPrecioDespachoDb( plan: string, frequency: 'monthly' | 'annual', phase: 'firstYear' | 'renewal' = 'renewal', ): Promise { const cfg = await getDespachoPlanLimits(plan); if (!cfg) throw new Error(`Plan ${plan} no encontrado en catálogo BD`); if (frequency === 'monthly') { if (!cfg.permiteMonthly || cfg.monthly === null) { throw new Error(`El plan ${plan} no permite frecuencia mensual`); } return cfg.monthly; } const price = phase === 'firstYear' ? cfg.firstYear : cfg.renewal; if (price === null) throw new Error(`El plan ${plan} no tiene precio anual configurado`); return price; } // ============================================================================ // Endpoints viejos — backward compat con /plan-catalogo/* routes // (sin callers frontend conocidos; se mantienen por si algo externo consume) // ============================================================================ export async function listPlans(_verticalProfile?: string) { const all = await getAllDespachoPlanLimits(); // Excluir trial y custom del catálogo público (admin-only) return all .filter(p => p.plan !== 'trial' && p.plan !== 'custom') .map(p => ({ codename: p.plan, nombre: p.nombre, verticalProfile: 'CONTABLE' as const, precioBase: p.firstYear ?? 0, frecuencia: p.permiteMonthly ? 'mensual' : 'anual', limits: { maxRfcs: p.maxRfcs, maxUsers: p.maxUsers, timbresIncluidosMes: p.timbresIncluidosMes, features: [], // features viven en TS (DESPACHO_PLANS); este endpoint no las expone } as PlanLimits, })); } export async function getPlanByCodename(codename: string) { const p = await getDespachoPlanLimits(codename); if (!p) return null; return { codename: p.plan, nombre: p.nombre, verticalProfile: 'CONTABLE' as const, precioBase: p.firstYear ?? 0, frecuencia: p.permiteMonthly ? 'mensual' : 'anual', limits: { maxRfcs: p.maxRfcs, maxUsers: p.maxUsers, timbresIncluidosMes: p.timbresIncluidosMes, features: [], } as PlanLimits, }; } export async function listAddons(verticalProfile?: string) { const where: any = { active: true }; if (verticalProfile) { where.OR = [ { verticalProfile }, { verticalProfile: null }, ]; } const addons = await prisma.planAddonCatalogo.findMany({ where, orderBy: { precio: 'asc' }, }); return addons.map(a => ({ id: a.id, codename: a.codename, nombre: a.nombre, verticalProfile: a.verticalProfile, precio: Number(a.precio), frecuencia: a.frecuencia, delta: a.delta as AddonDelta, })); }