Initial commit - Horux Despachos NL
This commit is contained in:
166
docs/superpowers/specs/2026-04-27-custom-plan-design.md
Normal file
166
docs/superpowers/specs/2026-04-27-custom-plan-design.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user