Files
HoruxDespachosNuevo/apps/api/src/services/plan-catalogo.service.ts

211 lines
6.8 KiB
TypeScript

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<string, DespachoPlanLimits> | null = null;
let cacheExpiresAt = 0;
async function loadCache(): Promise<Map<string, DespachoPlanLimits>> {
const rows = await prisma.despachoPlanPrice.findMany();
const map = new Map<string, DespachoPlanLimits>();
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<DespachoPlanLimits | null> {
if (!cacheData || Date.now() > cacheExpiresAt) await loadCache();
return cacheData!.get(plan) ?? null;
}
/** Lee todos los planes despacho. Cache 5min. */
export async function getAllDespachoPlanLimits(): Promise<DespachoPlanLimits[]> {
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<boolean> {
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<boolean> {
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<number> {
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,
}));
}