Update: nueva version Horux Despachos

This commit is contained in:
consultoria-as
2026-04-27 22:09:36 -06:00
commit 6b36db1403
614 changed files with 125926 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
export const DESPACHO_PLANS = {
trial: {
name: 'Trial',
maxRfcs: 3,
maxUsers: 1,
maxCfdisPorContribuyente: 1_000_000,
timbresIncluidosMes: 20,
dbMode: 'MANAGED' as const,
permiteServidorBackup: false,
features: [
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',
'calendario', 'conciliacion', 'documentos', 'facturacion',
'forecasting', 'xml_sat',
],
},
mi_empresa: {
name: 'Mi Empresa',
maxRfcs: 1,
maxUsers: 3,
maxCfdisPorContribuyente: 1_000_000,
timbresIncluidosMes: 50,
dbMode: 'MANAGED' as const,
permiteServidorBackup: false,
features: [
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',
'calendario', 'conciliacion', 'documentos', 'facturacion',
'forecasting', 'xml_sat',
],
},
mi_empresa_plus: {
name: 'Mi Empresa +',
maxRfcs: 1,
maxUsers: 3,
maxCfdisPorContribuyente: 1_000_000,
timbresIncluidosMes: 50,
dbMode: 'MANAGED' as const,
permiteServidorBackup: false,
features: [
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',
'calendario', 'conciliacion', 'documentos', 'facturacion',
'forecasting', 'xml_sat', 'api', 'ia_lolita',
],
},
business_control: {
name: 'Business Control',
maxRfcs: 100,
maxUsers: -1,
maxCfdisPorContribuyente: 1_000_000,
timbresIncluidosMes: 0,
dbMode: 'BYO' as const,
permiteServidorBackup: true,
features: [
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',
'calendario', 'conciliacion', 'documentos', 'facturacion',
'forecasting', 'xml_sat', 'api',
],
},
// Custom — gratis, sin fecha fin, solo asignable por Admin Global.
// Reusa el enum `custom` (legacy "precio variable" tenía 0 tenants).
// Comportamiento idéntico a Mi Empresa (1 RFC, MANAGED, sin API ni Lolita)
// pero NO se incluye en DESPACHO_PLAN_PRICES — no genera Subscription ni
// cobro MP. Ningún cron lo expira (sin trialEndsAt, sin currentPeriodEnd).
// Oculto del catálogo user-facing, visible solo en `/clientes` admin.
custom: {
name: 'Custom',
maxRfcs: 1,
maxUsers: 3,
maxCfdisPorContribuyente: 1_000_000,
timbresIncluidosMes: 50,
dbMode: 'MANAGED' as const,
permiteServidorBackup: false,
features: [
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',
'calendario', 'conciliacion', 'documentos', 'facturacion',
'forecasting', 'xml_sat',
],
},
// Identificador interno: business_cloud (no se renombra el enum por
// backward compat de suscripciones existentes). Nombre display: "Enterprise".
business_cloud: {
name: 'Enterprise',
maxRfcs: 100,
maxUsers: -1,
maxCfdisPorContribuyente: 3_000_000,
timbresIncluidosMes: 0,
dbMode: 'BYO' as const,
permiteServidorBackup: true,
features: [
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',
'calendario', 'conciliacion', 'documentos', 'facturacion',
'forecasting', 'xml_sat', 'api',
],
},
} as const;
export type DespachoPlan = keyof typeof DESPACHO_PLANS;
/**
* Precios MXN (IVA incluido) para planes despacho pagables.
*
* - `monthly`: precio mensual. Solo aplica a planes con `permiteMonthly=true`
* (Mi Empresa, Mi Empresa+). Para Business Control y Enterprise es null.
* - `firstYear` / `renewal`: precios anuales. `firstYear` es lo que se cobra
* al contratar; `renewal` lo que MP cobra automáticamente en renovaciones.
* Cuando ambos son iguales no hay dualidad.
*
* Política de descuento (D1, 2026-04-26): Mi Empresa y Mi Empresa+ tienen
* descuento del ~17% al pagar el año adelantado — el cobro anual es
* equivalente a 10 meses (en lugar de 12).
*
* mi_empresa: $580/mes → $5,800/año (10 meses)
* mi_empresa_plus: $900/mes → $9,000/año (10 meses)
*/
export const DESPACHO_PLAN_PRICES = {
mi_empresa: {
monthly: 580,
firstYear: 5_800,
renewal: 5_800,
permiteMonthly: true,
},
mi_empresa_plus: {
monthly: 900,
firstYear: 9_000,
renewal: 9_000,
permiteMonthly: true,
},
business_control: {
monthly: null,
firstYear: 25_850,
renewal: 25_850,
permiteMonthly: false,
},
business_cloud: { // display: Enterprise
monthly: null,
firstYear: 43_000,
renewal: 43_000,
permiteMonthly: false,
},
} as const;
export type DespachoPaidPlan = keyof typeof DESPACHO_PLAN_PRICES;
export type DespachoPricePhase = 'firstYear' | 'renewal';
export type DespachoFrequency = 'monthly' | 'annual';
/**
* Resuelve el precio MXN para un (plan, frequency, phase). Para monthly
* la fase se ignora (no hay dualidad mensual). Para annual aplica
* firstYear o renewal según corresponda. Throws si el plan no permite
* la frecuencia solicitada.
*/
export function getPrecioDespacho(
plan: DespachoPaidPlan,
frequency: DespachoFrequency,
phase: DespachoPricePhase = 'renewal',
): number {
const cfg = DESPACHO_PLAN_PRICES[plan];
if (frequency === 'monthly') {
if (!cfg.permiteMonthly || cfg.monthly == null) {
throw new Error(`El plan ${plan} no permite frecuencia mensual`);
}
return cfg.monthly;
}
return cfg[phase];
}
/** True si el plan acepta frecuencia mensual (Mi Empresa y Mi Empresa+). */
export function permiteFrecuenciaMensual(plan: string): boolean {
if (plan in DESPACHO_PLAN_PRICES) {
return DESPACHO_PLAN_PRICES[plan as DespachoPaidPlan].permiteMonthly;
}
return false;
}
/**
* Costo mensual MXN por contribuyente extra que excede `maxRfcs` del
* plan. Solo aplica a business_control y business_cloud (Enterprise).
* Mi Empresa tiene límite duro de 1 RFC; no permite extras.
*/
export const DESPACHO_OVERAGE_PRICE_MENSUAL = 45;
/** True si el plan cobra distinto en el primer año vs renovaciones (anual). */
export function despachoPlanTieneDualidad(plan: DespachoPaidPlan): boolean {
const p = DESPACHO_PLAN_PRICES[plan];
return p.firstYear !== p.renewal;
}
export function getDespachoPlanLimits(plan: DespachoPlan) {
return DESPACHO_PLANS[plan];
}
export function hasDespachoFeature(plan: DespachoPlan, feature: string): boolean {
return (DESPACHO_PLANS[plan]?.features as readonly string[])?.includes(feature) ?? false;
}
export function isDespachoTenant(tenantRfc: string | null | undefined): boolean {
return typeof tenantRfc === 'string' && tenantRfc.toUpperCase().startsWith('DESPACHO_');
}
/** True si el plan es uno pagable de despacho (excluye trial). */
export function isDespachoPaidPlan(plan: string): plan is DespachoPaidPlan {
return plan === 'business_control' || plan === 'business_cloud'
|| plan === 'mi_empresa' || plan === 'mi_empresa_plus';
}
/** Planes que permiten cobrar overage por contribuyente extra. */
export function permiteOverage(plan: string): boolean {
return plan === 'business_control' || plan === 'business_cloud';
}

View File

@@ -0,0 +1,46 @@
export const PLANS = {
starter: {
name: 'Starter',
cfdiLimit: 0,
usersLimit: 1,
features: ['dashboard', 'cfdi_basic', 'iva_isr'],
},
business: {
name: 'Business',
cfdiLimit: 50,
usersLimit: 3,
features: ['dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas', 'calendario', 'conciliacion', 'forecasting', 'xml_sat', 'documentos'],
},
business_ia: {
name: 'Business + IA',
cfdiLimit: 50,
usersLimit: 3,
features: ['dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas', 'calendario', 'conciliacion', 'forecasting', 'xml_sat', 'documentos', 'ia_lolita'],
},
custom: {
name: 'Custom',
cfdiLimit: 50,
usersLimit: 3,
features: ['dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas', 'calendario', 'conciliacion', 'forecasting', 'xml_sat', 'documentos', 'ia_lolita'],
},
enterprise: {
name: 'Enterprise',
cfdiLimit: 100,
usersLimit: -1,
features: ['dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas', 'calendario', 'conciliacion', 'forecasting', 'xml_sat', 'documentos', 'api', 'ia_lolita'],
},
} as const;
export type Plan = keyof typeof PLANS;
export function getPlanLimits(plan: Plan) {
return PLANS[plan];
}
export function hasFeature(plan: Plan, feature: string): boolean {
// Defensive: un plan desconocido (típicamente un valor que no pertenece
// al catálogo Horux 360 — p.ej. un plan despacho usado por error)
// retorna false en vez de crashear con "Cannot read properties of undefined".
// El caller debe usar el catálogo correcto según la vertical del tenant.
return (PLANS[plan]?.features as readonly string[] | undefined)?.includes(feature) ?? false;
}

View File

@@ -0,0 +1,76 @@
import type { Role, PlatformRole } from '../types/auth';
export const GLOBAL_ADMIN_RFC = 'HTS240708LJA';
/** Roles que son superset (implican todos los demás platform roles). */
const SUPERSET_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti'];
/**
* "Admin global" = staff interno de Horux 360 con rol `platform_admin` o `platform_ti`.
*
* Primero checa `platformRoles` del user (nueva fuente de verdad post-migración).
* Si no hay platformRoles (JWT viejo, sesión pre-deploy), cae al check legacy
* por RFC + rol owner del tenant HTS240708LJA.
*
* Código nuevo debería preferir `hasPlatformRoleFromArray(platformRoles, 'platform_admin')`.
*/
export function isGlobalAdminRfc(
tenantRfc: string | undefined,
role: string | undefined,
platformRoles?: PlatformRole[] | null,
): boolean {
// Nueva fuente: platform_admin o platform_ti en user_platform_roles
if (platformRoles && SUPERSET_ROLES.some(r => platformRoles.includes(r))) return true;
// Fallback legacy: owner del tenant con RFC admin global
return role === 'owner' && tenantRfc === GLOBAL_ADMIN_RFC;
}
/** ¿El user tiene algún rol de plataforma (staff interno)? */
export function isPlatformStaffFromRoles(platformRoles?: PlatformRole[] | null): boolean {
return !!platformRoles && platformRoles.length > 0;
}
/** ¿El user tiene el rol de plataforma indicado? admin y ti implican todos. */
export function hasPlatformRoleFromArray(
platformRoles: PlatformRole[] | undefined | null,
role: PlatformRole,
): boolean {
if (!platformRoles) return false;
if (SUPERSET_ROLES.some(r => platformRoles.includes(r))) return true;
return platformRoles.includes(role);
}
export const ROLES = {
owner: {
name: 'Dueño',
permissions: ['read', 'write', 'delete', 'manage_users', 'manage_settings'],
},
cfo: {
name: 'CFO',
permissions: ['read', 'write', 'delete', 'manage_users', 'manage_settings'],
},
contador: {
name: 'Contador',
permissions: ['read', 'write', 'resolve_alertas'],
},
auxiliar: {
name: 'Auxiliar',
permissions: ['read', 'write', 'resolve_alertas'],
},
visor: {
name: 'Visor',
permissions: ['read'],
},
supervisor: {
name: 'Supervisor',
permissions: ['read', 'write', 'resolve_alertas', 'manage_carteras'],
},
cliente: {
name: 'Cliente',
permissions: ['read'],
},
} as const;
export function hasPermission(role: Role, permission: string): boolean {
return ROLES[role].permissions.includes(permission as any);
}