211 lines
6.8 KiB
TypeScript
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,
|
|
}));
|
|
}
|