Files
HoruxDespachos/docs/superpowers/specs/2026-04-27-custom-plan-design.md
2026-04-27 22:09:36 -06:00

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. Sin currentPeriodEnd. Ningún cron lo expira.
  • Visibilidad: oculto del catálogo user-facing. Solo aparece como opción en /clientes (admin global).

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:

  1. Reemplazar PlanType local por AdminAssignablePlan importado.
  2. Eliminar el planLabels local (líneas 174-178) y planColors local (líneas 180-184). Usar el PLAN_LABELS global que ya existe arriba del archivo (cubre todo). Para colores, expandir el map global o inline en el render.
  3. Extender el <Select> del form para incluir custom:
<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>
  1. Cuando el plan seleccionado es custom, ocultar el campo amount (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. custom NO 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 custom ya 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-custom existente sigue vigente y coincide con el nuevo comportamiento (rechaza self-serve).
  • getMyPlan en despacho.controller.ts — ya lee tenant.plan directamente. Custom se reportará al frontend correctamente.
  • Cron applyPendingChanges y expireTrials — 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

  1. 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/suscripcion del tenant impersonado, luego asignar custom. Documentar en runbook.
  2. 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.
  3. 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

  1. pnpm typecheck shared + api + web targeted: PASS.
  2. Admin asigna custom: desde /clientes, edit tenant, seleccionar "Custom", guardar. Verificar tenant.plan === 'custom' en BD.
  3. 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.
  4. User en custom: login como ese tenant, ir a /configuracion/planes-despacho → ver banner "Estás en plan Custom".
  5. Admin asigna otro plan a tenant en custom: dropdown muestra los demás planes legacy. Asignación funciona.
  6. getMyPlan retorna custom: /api/despachos/me/plan retorna { 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.