Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,233 @@
/**
* Catálogo de planes despacho — fuente de verdad para `features` (acopladas
* a UI/middleware) y defaults estáticos para limits/precios.
*
* IMPORTANTE — split desde 2026-04-30:
* - **`features`**: viven aquí en código. Modificarlas requiere release.
* - **`maxRfcs`, `maxUsers`, `timbresIncluidosMes`, `dbMode`,
* `permiteServidorBackup`, `name`**: estos son **defaults** — la fuente
* autoritativa vive en la tabla `despacho_plan_prices` (modelo
* `DespachoPlanPrice`), editable por admin global. El backend lee la BD
* vía `getDespachoPlanLimits()` en `apps/api/src/services/plan-catalogo.service.ts`.
* - **`maxCfdisPorContribuyente`**: solo en TS — no se gating en runtime
* (los valores de millones son nominales).
*
* El frontend público (sidebar/feature-gate) sigue leyendo de aquí para
* features. La UI admin (`/configuracion/precios-suscripcion`) lee de BD
* para mostrar valores editables.
*/
export const DESPACHO_PLANS = {
trial: {
name: 'Trial',
maxRfcs: 3,
maxUsers: 1,
maxCfdisPorContribuyente: 1_000_000,
timbresIncluidosMes: 0,
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 — monto mensual variable, definido al provisionar el tenant.
// - amount = 0 → sin tarjeta, sin Subscription, sin cobro MP, no expira.
// - amount > 0 → Subscription con preapproval MP mensual; el monto se
// toma del parámetro al crear el tenant, no de DESPACHO_PLAN_PRICES.
// Solo asignable por Admin Global. Limits idénticos a Mi Empresa
// (1 RFC, MANAGED, sin API ni Lolita). Oculto del catálogo user-facing.
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 con precio fijo en catálogo
* (excluye trial y custom). Custom se cobra por monto variable definido al
* provisionar — usar `isCustomPlan` + chequeo de `Subscription.amount > 0`. */
export function isDespachoPaidPlan(plan: string): plan is DespachoPaidPlan {
return plan === 'business_control' || plan === 'business_cloud'
|| plan === 'mi_empresa' || plan === 'mi_empresa_plus';
}
/** True si el plan es Custom (monto variable, definido al provisionar). */
export function isCustomPlan(plan: string): boolean {
return plan === 'custom';
}
/** 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,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);
}