# 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).

## Cambios — Catálogo

`packages/shared/src/constants/despacho-plans.ts`:

```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`:

```ts
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`:

```tsx
<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>
```

4. 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.
