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

167 lines
6.6 KiB
Markdown

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