6.6 KiB
Plan Custom — gratis, sin fecha fin, solo asignable por Admin Global
Contexto
El owner pidió un plan "Custom" para casos donde quiere otorgar acceso al sistema sin cobro y sin fecha de finalización (cortesía, beta tester, caso especial). Solo el Admin Global puede asignarlo; los usuarios finales no deben verlo en su catálogo de planes.
Decisión clave — Reusar enum custom
El Plan enum de Prisma ya incluye custom (legacy: "precio variable por
tenant"). En dev hay 0 tenants en ese plan, y la lógica antigua en
subscription.service.ts rechaza custom del flujo self-serve — patrón
que coincide con la nueva semántica. Reusar el enum evita migration y
mantiene compatibilidad.
Reglas
- Comportamiento: idéntico a Mi Empresa (1 RFC, MANAGED, 50 timbres/mes, features básicas, sin API ni Lolita).
- Costo: $0. No genera Subscription, no usa MercadoPago.
- Vigencia: indefinida.
tenant.trialEndsAt = null. SincurrentPeriodEnd. Ningún cron lo expira. - Visibilidad: oculto del catálogo user-facing. Solo aparece como
opción en
/clientes(admin global).
Cambios — Catálogo
packages/shared/src/constants/despacho-plans.ts:
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',
],
},
NO se agrega a DESPACHO_PLAN_PRICES (gratis). Helpers existentes:
permiteOverage('custom')→false✓ (ya retorna false porque solo cubre business_control y business_cloud)isDespachoPaidPlan('custom')→false✓ (idem)permiteFrecuenciaMensual('custom')→false✓ (no está en DESPACHO_PLAN_PRICES)
Cambios — Frontend types
apps/web/lib/api/tenants.ts:
Extender el tipo del campo plan en CreateTenantData y UpdateTenantData:
type AdminAssignablePlan =
| 'starter' | 'business' | 'business_ia' | 'enterprise' // legacy Horux 360
| 'custom'; // nuevo
(Despacho paid plans NO se incluyen — esos van por self-serve del owner, fuera de scope per acuerdo con owner.)
Cambios — Página /clientes (admin)
apps/web/app/(dashboard)/clientes/page.tsx:
- Reemplazar
PlanTypelocal porAdminAssignablePlanimportado. - Eliminar el
planLabelslocal (líneas 174-178) yplanColorslocal (líneas 180-184). Usar elPLAN_LABELSglobal que ya existe arriba del archivo (cubre todo). Para colores, expandir el map global o inline en el render. - Extender el
<Select>del form para incluircustom:
<SelectContent>
<SelectItem value="starter">Starter (legacy) — Sin CFDIs, 1 usuario</SelectItem>
<SelectItem value="business">Business (legacy) — 50 CFDIs, 3 usuarios</SelectItem>
<SelectItem value="business_ia">Business + IA (legacy)</SelectItem>
<SelectItem value="enterprise">Enterprise (legacy) — 100 CFDIs, ilimitado</SelectItem>
<SelectItem value="custom">Custom — Sin cobro, sin fecha fin (despacho)</SelectItem>
</SelectContent>
- Cuando el plan seleccionado es
custom, ocultar el campoamount(no aplica) o forzarlo a 0.
Cambios — Página /configuracion/planes-despacho (user)
apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx:
-
Las cards visibles son
mi_empresa,mi_empresa_plus,business_control,business_cloud.customNO aparece (no está en ese array). -
Si
planInfo?.plan === 'custom': mostrar un banner read-only prominente:"Estás en el plan Custom asignado por tu administrador. Contacta a soporte si necesitas cambiar."
Y NO renderizar las cards (o renderizarlas atenuadas con botones deshabilitados).
No-cambios
- Schema BD / migration — el enum
customya existe. - Backend
PUT /api/tenants/:id— ya acepta cualquier valor del enum Prisma (sin Zod gate). Cero cambios. subscription.service.ts— su lógica anti-customexistente sigue vigente y coincide con el nuevo comportamiento (rechaza self-serve).getMyPlanendespacho.controller.ts— ya leetenant.plandirectamente. Custom se reportará al frontend correctamente.- Cron
applyPendingChangesyexpireTrials— Custom no tiene Subscription ni trialEndsAt, no le afectan. - Trial RFC limit (V.1.0.11) — Custom tiene
trialEndsAt=null, así que el limit de 5 no aplica. Aplica el límite duro del catálogo (1).
Riesgos / limitaciones aceptadas
- Transición paid → custom: si el admin cambia un tenant que tenía
suscripción MP activa a
custom, el preapproval MP sigue cobrando hasta que se cancele manualmente. Mitigación: el admin debe cancelar la suscripción primero desde/configuracion/suscripciondel tenant impersonado, luego asignar custom. Documentar en runbook. - Transición custom → paid: el admin NO puede asignar planes
despacho pagables desde
/clientes(no incluidos en el dropdown). El tenant debe pasar por self-serve normal en/configuracion/planes-despacho. Esto evita el escenario de un tenant en plan paid sin Subscription que sería inconsistente. - Hard limit de 1 RFC en custom: igual que Mi Empresa, el límite
de 1 RFC para custom es solo billing-only hoy (no enforced en
contribuyente.controller.ts:create). Si se quiere enforce duro, replicar el patrón del trial limit. Out of scope.
Plan de pruebas
pnpm typecheckshared + api + web targeted: PASS.- Admin asigna custom: desde
/clientes, edit tenant, seleccionar "Custom", guardar. Verificartenant.plan === 'custom'en BD. - Admin asigna custom a tenant en trial: trialEndsAt debería limpiarse (a través de la lógica del service). Si el service no lo limpia, agregar.
- User en custom: login como ese tenant, ir a
/configuracion/planes-despacho→ ver banner "Estás en plan Custom". - Admin asigna otro plan a tenant en custom: dropdown muestra los demás planes legacy. Asignación funciona.
getMyPlanretorna custom:/api/despachos/me/planretorna{ plan: 'custom', isTrialActive: false, ... }.
Implementación
~30 líneas netas en 4 archivos:
despacho-plans.ts— agregar entrada custom (~12 líneas).tenants.ts(api client) — extender tipos (~3 líneas).clientes/page.tsx— dropdown + cleanup (~10 líneas).planes-despacho/page.tsx— banner Custom (~10 líneas).
Cambio chico, hago directo sin subagents. Una commit en Downloads + V.1.0.14 en OneDrive.