Files
HoruxDespachos/docs/plans/2026-04-27-session.md
2026-04-27 22:09:36 -06:00

632 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sesión 2026-04-27 — Resumen del día
Sesión enfocada en el módulo de Impuestos y la gestión de planes: refactor
del cálculo de ISR acumulado al estilo formato 14 del SAT, dos toggles
nuevos para excluir activos fijos y NCs, extensión del filtro de activos
para cubrir las cadenas P → I y E → {I, P → I}, límite de 5 RFCs durante
el trial gratuito, fix de un bug crítico de scope SQL, y un plan "Custom"
gratis sin fecha fin asignable solo por Admin Global.
Ocho releases shippeadas en OneDrive durante la sesión: V.1.0.6, V.1.0.7,
V.1.0.8, V.1.0.9, V.1.0.11, V.1.0.12, V.1.0.14 (V.1.0.10 y V.1.0.13 fueron
solo docs).
---
## Índice
1. [V.1.0.6 — ISR base gravable acumulada y desglose del periodo](#1-v106--isr-base-gravable-acumulada-y-desglose-del-periodo)
2. [V.1.0.7 — Filtros "Considerar activos" y "Considerar NCs" (Fase 1)](#2-v107--filtros-considerar-activos-y-considerar-ncs-fase-1)
3. [V.1.0.8 — Defaults de los toggles a ON (cache-friendly)](#3-v108--defaults-de-los-toggles-a-on-cache-friendly)
4. [V.1.0.9 — Filtro de activos extendido a P y E relacionadas](#4-v109--filtro-de-activos-extendido-a-p-y-e-relacionadas)
5. [Spec en pipeline (no shipped) — Sort por nombre en drill-down](#5-spec-en-pipeline-no-shipped--sort-por-nombre-en-drill-down)
6. [V.1.0.11 — Límite de 5 RFCs durante trial gratuito](#6-v1011--límite-de-5-rfcs-durante-trial-gratuito)
7. [V.1.0.12 — Fix bug de scope SQL en filtro de activos](#7-v1012--fix-bug-de-scope-sql-en-filtro-de-activos)
8. [V.1.0.14 — Plan Custom (gratis, sin fecha fin, solo admin)](#8-v1014--plan-custom-gratis-sin-fecha-fin-solo-admin)
9. [Pendientes derivados](#9-pendientes-derivados)
Documentos relacionados creados hoy:
- `docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md`
- `docs/superpowers/plans/2026-04-27-isr-base-gravable-acumulada.md`
- `docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md`
- `docs/superpowers/plans/2026-04-27-filtros-activos-ncs-impuestos-fase1.md`
- `docs/superpowers/specs/2026-04-27-drill-down-sort-by-name-design.md` (no implementado)
- `docs/superpowers/specs/2026-04-27-trial-rfc-limit-design.md`
- `docs/superpowers/specs/2026-04-27-custom-plan-design.md`
---
## 1. V.1.0.6 — ISR base gravable acumulada y desglose del periodo
### Problema
La pestaña ISR de `/impuestos` calculaba la base gravable mes a mes con
`Math.max(0, ing ded)`. Esto perdía déficits acumulados: un mes con
pérdida no reducía el acumulado de meses siguientes. La lógica fiscal
correcta es acumular ingresos y deducciones desde enero, restar al final,
y solo aplicar `max(0, …)` al pasar a ISR causado.
### Cambios — Tabla "Histórico ISR"
Antes: 4 columnas (Mes, Ingresos, Deducciones, Base Gravable). Base
Gravable era el `max(0, ing_mes ded_mes)` mensual independiente.
Después: **6 columnas** — Mes, Ingresos, Ingresos Acum., Deducciones,
Deducciones Acum., Base Gravable Acum. La columna BG mensual desaparece.
La BG Acum. se calcula como `ingAcum dedAcum` **sin clamp**: si el
acumulado es negativo, se renderiza en rojo (`text-destructive`). Fila
"Total" eliminada (la última fila con datos ya es el YTD).
### Cambios — Sección "Cálculo de ISR Acumulado" → "Cálculo de ISR del Periodo"
Rename del título y reescritura del card al estilo del formato 14 SAT:
```
Ingresos del periodo (Mar 2026) $X
(+) Ingresos acumulados anteriores (Ene-Feb) $A
() Deducciones del periodo (Mar 2026) $Y
() Deducciones acumuladas anteriores $B
─────────────────────────────────────────────
(=) Base gravable acumulada $X+AYB ← rojo si negativa
ISR causado (acumulado) tarifa(max(0, BG))
() ISR retenido (acumulado) $R
─────────────────────────────────────────────
ISR a pagar max(0, causado retenido)
```
"Del periodo" = único el **mes final** del filtro (no el rango entero).
"Anteriores" = enero hasta el mes previo al mes final, mismo año.
Etiquetas de mes derivadas dinámicamente: `mesFinal=1` muestra "(sin meses
anteriores)", `mesFinal=2` muestra "(Ene)", etc.
### Backend — endpoint nuevo
`GET /api/impuestos/isr/resumen-desglosado?fechaFin=...&conciliacion=...&contribuyenteId=...`
Internamente llama 3 veces a `getResumenIsr` con rangos distintos:
- `delPeriodo`: solo el mes final (1 mes)
- `anteriores`: Ene-1 a (mesFinal-1)-último-día (vacío si mesFinal=1)
- `total`: Ene-1 a último-día-del-mes-final
`Promise.all` para los 2 que son independientes (`delPeriodo` + `total`).
Cuando `mesFinal === 1`, evita query inútil retornando `emptyResumenIsr()`
para anteriores.
### Archivos modificados (V.1.0.6)
```
packages/shared/src/types/impuestos.ts
apps/api/src/services/impuestos.service.ts
apps/api/src/controllers/impuestos.controller.ts
apps/api/src/routes/impuestos.routes.ts
apps/web/lib/api/impuestos.ts
apps/web/lib/hooks/use-impuestos.ts
apps/web/app/(dashboard)/impuestos/page.tsx
```
`IsrMensual` extendido con `ingresosAcum`, `deduccionesAcum`,
`baseGravableAcum` (running totals desde enero). `BaseGravableRegimen`
ganó `isrCausado` para alimentar la sección por régimen.
---
## 2. V.1.0.7 — Filtros "Considerar activos" y "Considerar NCs" (Fase 1)
### Problema
La pestaña Impuestos no permitía excluir compras de activos fijos
(que se deprecian, no se deducen mensualmente) ni notas de crédito
(NCs tipoRel=01 que ajustan facturas previas). El contador necesita
poder ver/ocultar estas categorías para análisis.
### UI
Dos toggles nuevos junto a "Conciliación":
```
[Régimen ▾] [☐ Conciliación] [☐ Considerar activos] [☐ Considerar NCs]
```
Mismo styling que Conciliación, tooltips descriptivos via `title`.
**En esta versión los defaults eran OFF** (excluir por default). El default
se invirtió a ON en V.1.0.8 — ver §3.
Toggle ON = considerar/incluir.
Toggle OFF = no considerar/excluir.
### Backend
Helper neutral en módulo nuevo `apps/api/src/services/_shared/cfdi-filters.ts`:
- `buildExtraFilters(considerarActivos, considerarNCs)` → fragmento WHERE
para queries con `FROM cfdis` directo.
- `buildExtraFiltersAlias(alias, considerarActivos, considerarNCs)`
versión alias-aware para subqueries (`FROM cfdis e`).
Cuando ambos flags son `true` retorna string vacío (no afecta el WHERE).
Se extendieron 7 funciones de servicio con 2 nuevos parámetros opcionales,
default `true` para preservar el comportamiento de los callers que no los
pasan (dashboard, reportes, alertas):
| Función | Archivo |
|---|---|
| `calcularIngresosPorRegimen` | `dashboard.service.ts` |
| `calcularEgresosPorRegimen` | `dashboard.service.ts` |
| `getResumenIva` | `impuestos.service.ts` |
| `getIvaMensual` | `impuestos.service.ts` |
| `getResumenIsr` | `impuestos.service.ts` |
| `getIsrMensual` | `impuestos.service.ts` |
| `getResumenIsrDesglosado` | `impuestos.service.ts` |
Templates de subqueries de la rama I PPD/07 (`SUM_E_REFERENCING_TRAS`,
`SUM_E_REFERENCING_RET`, `HAS_E_REFERENCING_MISMO_MES`) y sus helpers
intermedios (`bucketCausadoAny`, `bucketAcreditableAny`, `signed*` exprs,
`readResumenIvaFromCache`) propagaron los flags a través de 3 niveles.
Cache gate de IVA extendido: `metricas_mensuales` solo se consulta cuando
`!conciliacion && considerarActivos && considerarNCs`. Cualquier toggle
distinto del default backend → live query.
### Frontend
- API client: 5 funciones HTTP serializan los flags como query params,
incluyendo cuando son `false` (`if (flag !== undefined) params.set(..., String(flag))`).
- Hooks: 5 hooks incluyen los flags en `queryKey` para refetch al togglear.
- UI: state + 2 toggle buttons + propagación a las 5 llamadas.
### Decisión de diseño — Default backend `true`, default UI inicial `false`
El backend default `true` (= include todo) preserva dashboard, reportes,
etc. La UI inicialmente arrancó con default `false` (= excluir por
default) por lógica fiscal. La asimetría se resolvió en V.1.0.8.
### Pruebas
- `pnpm typecheck` shared + api: PASS.
- Web typecheck para los archivos del plan: clean (otros errores web son
pre-existentes, fuera de scope).
- Smoke deferido a verificación manual del owner.
### Fase 2 (futura)
Extender `metricas_mensuales` con columnas base + 2 deltas (`*_activos`,
`*_ncs_01`) por métrica IVA. Hace los toggles instantáneos vía
suma/resta sin live query.
---
## 3. V.1.0.8 — Defaults de los toggles a ON (cache-friendly)
### Motivación
Tras shippear V.1.0.7, el final code review levantó una observación
importante: con UI default OFF (ambos toggles excluyendo), el cache
`metricas_mensuales` queda **siempre bypass-eado** en `/impuestos`.
Cada carga inicial era live query (~1-3s). El cache solo servía cuando
el contador activaba manualmente ambos toggles.
### Decisión
Invertir defaults UI de `false` a `true`. Trade-off:
- **Antes (V.1.0.7)**: default OFF → carga inicial siempre lenta.
Default fiscalmente "más correcto" (excluir por automático).
- **Después (V.1.0.8)**: default ON → carga inicial rápida (cache hit,
comportamiento idéntico al de versiones previas). El contador activa
el filtro cuando lo necesita.
La consciencia del filtro queda como acción del contador, no como default
silencioso. Fase 2 elimina el dilema: con cache base+deltas, ambos
defaults serán igual de rápidos.
### Cambios
```ts
// Antes:
const [considerarActivos, setConsiderarActivos] = useState(false);
const [considerarNCs, setConsiderarNCs] = useState(false);
// Después:
const [considerarActivos, setConsiderarActivos] = useState(true);
const [considerarNCs, setConsiderarNCs] = useState(true);
```
Plus actualización del spec doc para reflejar la decisión.
### Archivos modificados (V.1.0.8)
```
apps/web/app/(dashboard)/impuestos/page.tsx
docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md
```
---
## 4. V.1.0.9 — Filtro de activos extendido a P y E relacionadas
### Problema
El filtro de activos en V.1.0.7 solo excluía facturas tipo I con uso
I01-I08. Pero en realidad un activo fijo se materializa en varias
facturas relacionadas:
1. La I de la compra (uso I01-I08).
2. La P (complemento de pago) que paga esa compra.
3. Eventualmente una E (NC) que cancela la compra o el pago.
Si el contador desactiva "Considerar activos", esperaría ver excluidas
**todas** las facturas asociadas a la operación de activo fijo, no solo
la I original. De lo contrario los pagos y NCs quedan visibles sin la
factura que los origina, generando inconsistencia.
### Solución
Extender el predicado del filtro de activos para cubrir 3 capas:
| Capa | Predicado SQL |
|---|---|
| **1. I directa** | `tipo_comprobante = 'I' AND uso_cfdi IN (I01-I08)` |
| **2. P → I-activo** | `tipo='P' AND EXISTS(SELECT 1 FROM cfdis i_act WHERE i_act.uuid=uuid_relacionado AND i_act.tipo='I' AND i_act.uso_cfdi IN (I01-I08))` |
| **3. E → {I-activo, P-de-activo}** | `tipo='E' AND EXISTS(SELECT 1 FROM cfdis r_act WHERE r_act.uuid IN cfdis_relacionados pipe-split AND (r_act es I-activo OR r_act es P-de-activo))` |
La capa 3 cubre los dos casos del owner:
- E tipoRel=01 que cancela una P que pagó un activo (caso 1 del prompt).
- E tipoRel=03 que devuelve directamente una I-activo (caso 3 del prompt).
- Cualquier otro tipoRel — el predicado es genérico, no filtra por
`cfdi_tipo_relacion` en la rama de activos.
### Independencia con el filtro de NCs
Los dos filtros operan en AND:
- "Considerar NCs" OFF: excluye todas las E tipoRel=01, sin importar a
qué se relacionen.
- "Considerar activos" OFF: excluye E (cualquier tipoRel) que se
relacione con activos.
Una E tipoRel=01 sobre I regular: solo el filtro de NCs la afecta.
Una E tipoRel=03 sobre I-activo: solo el filtro de activos la afecta.
Una E tipoRel=01 sobre I-activo: ambos filtros la excluirían.
### Comportamiento por tipo de CFDI
| CFDI | Excluido si activos OFF? |
|---|---|
| I uso I01-I08 | ✅ predicado 1 |
| I uso G03 (gasto regular) | ❌ |
| P pagando I-activo | ✅ predicado 2 |
| P pagando I regular | ❌ |
| E tipoRel=01 → I-activo | ✅ predicado 3 |
| E tipoRel=03 → I-activo | ✅ predicado 3 |
| E tipoRel=01 → P-de-activo | ✅ predicado 3 |
| E tipoRel=07 → I PPD/07 (anticipo) | ❌ no es activo |
| E tipoRel=01 → I regular | ❌ (lo cubre el filtro NCs si está OFF) |
### Implementación
Cambio único en `apps/api/src/services/_shared/cfdi-filters.ts` (~70
líneas netas, el archivo creció de ~50 a ~110 líneas). Helpers internos
`activosExclusionNoAlias()` y `activosExclusionAlias(alias)` encapsulan
los 3 predicados. `buildExtraFilters` y `buildExtraFiltersAlias` los
invocan cuando `!considerarActivos`.
Cero cambios downstream — todos los callsites del helper (16+ en
service/dashboard) heredan automáticamente el comportamiento extendido.
### Performance
- **Default UI ON (V.1.0.8)**: el helper retorna empty string. Cero
impacto, cache hit normal.
- **Filtro activos OFF**: cada query con `FROM cfdis` ejecuta los 3
predicados con EXISTS anidados. Sin índice en `uuid_relacionado` ni
`cfdis_relacionados`, las queries grandes pueden ser ~10-20% más
lentas. Aceptable para Fase 1.
- Si el perf hit se vuelve notorio, evaluar índice B-tree en
`uuid_relacionado` y GIN sobre array para `cfdis_relacionados` en
Fase 2 (parte del cache extension).
### Archivos modificados (V.1.0.9)
```
apps/api/src/services/_shared/cfdi-filters.ts
```
---
## 5. Spec en pipeline (no shipped) — Sort por nombre en drill-down
Se diseñó y especificó el cambio para agregar sort por nombre emisor /
receptor en la página `/drill-down` genérica (los KPIs del dashboard
abren ahí). Spec en
`docs/superpowers/specs/2026-04-27-drill-down-sort-by-name-design.md`,
shippeada en V.1.0.5.
**Estado**: spec aprobada, implementación pendiente. Cambio trivial
(~6 líneas en un archivo). Las páginas de alertas con tablas similares
quedaron fuera de scope para evitar plan grande — se evaluarán en otra
sesión.
---
## 6. V.1.0.11 — Límite de 5 RFCs durante trial gratuito
### Problema
Despachos en periodo de prueba (30 días) podían agregar RFCs sin
restricción. El owner pidió un límite duro de 5 RFCs para forzar al
contador a contratar un plan si necesita gestionar más.
### Reglas
| Estado | Límite RFCs |
|---|---|
| Trial activo (`tenant.trialEndsAt > now`) | **5 contribuyentes activos** (5 OK, 6 bloqueado) |
| Trial expirado | Aplica el límite del plan vigente; este spec no agrega nada nuevo |
| Plan pagado (sin trial activo) | Sin nuevo límite |
### Backend (`apps/api/src/controllers/contribuyente.controller.ts`)
Constante local `TRIAL_MAX_CONTRIBUYENTES = 5`. En el handler `create`,
antes del `createContribuyente`:
```ts
const tenant = await prisma.tenant.findUnique({
where: { id: req.user!.tenantId },
select: { trialEndsAt: true },
});
const isTrialActive = tenant?.trialEndsAt ? tenant.trialEndsAt > new Date() : false;
if (isTrialActive) {
const activeCount = await countActiveContribuyentes(req.tenantPool!);
if (activeCount >= TRIAL_MAX_CONTRIBUYENTES) {
return next(new AppError(
403,
`Durante el periodo de prueba puedes gestionar hasta ${TRIAL_MAX_CONTRIBUYENTES} contribuyentes. Contrata un plan para agregar más.`,
));
}
}
```
### Frontend (`apps/web/app/(dashboard)/contribuyentes/page.tsx`)
- `useQuery(['my-plan-info'], ...)` para fetch `/despachos/me/plan` (endpoint existente).
- Cómputo `trialAtLimit = isTrialActive && activeCount >= 5`.
- Los 2 botones "Agregar RFC" / "Agregar primer RFC" reciben
`disabled={trialAtLimit}` con `title` mostrando el tooltip exacto
literal del owner:
> "Límite de contribuyentes para la prueba gratuita, para continuar agregando contribuyentes, selecciona un plan."
### No-cambios
- No nueva tabla, no migration.
- Mi Empresa hard limit a 1 RFC sigue siendo billing-only (out of scope).
- `tenant.cfdiLimit`, `tenant.usersLimit` no se tocan.
### Archivos modificados (V.1.0.11)
```
apps/api/src/controllers/contribuyente.controller.ts
apps/web/app/(dashboard)/contribuyentes/page.tsx
docs/superpowers/specs/2026-04-27-trial-rfc-limit-design.md
```
---
## 7. V.1.0.12 — Fix bug de scope SQL en filtro de activos
### Problema reportado por owner
Tras shippear V.1.0.9 (filtro de activos extendido a P y E relacionadas),
el owner reportó que la factura `8ec2eaf3-7879-11f0-81a8-8daae9822b10`
(tipo P, monto $295,100) seguía apareciendo en cálculos cuando
desactivaba el toggle "Considerar activos". La P pagaba la I
`5C874749-748F-11F0-96B1-2B9310891836`, que tenía `uso_cfdi = I03`
(Equipo de transporte) — un activo fijo per la regla.
### Causa raíz — scope ambiguity SQL
En `activosExclusionNoAlias()` (helper en `_shared/cfdi-filters.ts`),
el subquery del predicado P referenciaba `LOWER(uuid_relacionado)` sin
qualifying. Como el subquery usa `FROM cfdis i_act` y `i_act` también
tiene la columna `uuid_relacionado`, PostgreSQL resolvía la referencia
no-qualificada al **scope interno** (`i_act.uuid_relacionado`) en vez
del outer (`cfdis.uuid_relacionado`).
Resultado: el predicado evaluaba "¿existe un i_act donde
`i_act.uuid = i_act.uuid_relacionado` AND uso I01-I08?" — eso es
prácticamente siempre `false` (un CFDI no se referencia a sí mismo).
El `AND NOT (FALSE) = AND TRUE`, así que la P **nunca se excluía**.
Mismo bug en el predicado E (`cfdis_relacionados` no qualificado dentro
del subquery con `r_act` que también tiene esa columna).
La versión `Alias` (`activosExclusionAlias`) NO tenía el bug porque ahí
escribí `${alias}.uuid_relacionado` qualificado explícitamente.
### Fix
Qualifying las 2 referencias outer dentro de los subqueries con `cfdis.`:
```sql
-- Antes (bug):
WHERE LOWER(i_act.uuid) = LOWER(uuid_relacionado)
-- Después (fix):
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
```
Y para el predicado E:
```sql
-- Antes (bug):
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis_relacionados), '|'))
-- Después (fix):
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
```
Comentario JSDoc agregado a la función explicando por qué necesita
qualifying explícito (evitar futuras regresiones).
### Validación
Test directo con SQL antes/después del fix:
```
BUGGY version (sin qualifying outer):
bug_excludes = false ← P nunca se excluía
FIXED version (cfdis.uuid_relacionado):
fixed_excludes = true ← P correctamente excluida
Referenced I: { uuid: '5c874749...', uso_cfdi: 'I03', tipo_comprobante: 'I' }
```
### Lecciones
- **Qualifying explícito es defensivo siempre que un subquery comparta
nombre de tabla con el outer**. Postgres prioriza scope interno sin
warning, así que el bug es silencioso.
- **El test del owner en producción es indispensable**: el typecheck y
los tests de tipo no detectan errores semánticos de SQL. El fix solo
apareció porque el owner reportó un caso específico que esperaba
excluir.
### Archivos modificados (V.1.0.12)
```
apps/api/src/services/_shared/cfdi-filters.ts
```
---
## 8. V.1.0.14 — Plan Custom (gratis, sin fecha fin, solo admin)
### Problema
El owner pidió un plan especial para casos de cortesía / beta tester /
caso especial: acceso al sistema sin cobro, sin fecha de finalización,
asignable solo por el Admin Global y oculto del catálogo user-facing.
### Decisión clave — Reusar enum `custom`
El Plan enum de Prisma ya tenía `custom` (legacy: "precio variable por
tenant") con **0 tenants** en dev. La lógica antigua en
`subscription.service.ts` ya rechazaba `custom` del flujo self-serve —
patrón que coincide con la nueva semántica. Reusar el enum evita migration
y mantiene compatibilidad con código existente.
### Comportamiento
- **Limits**: idéntico a Mi Empresa (1 RFC, 50 timbres/mes, MANAGED, sin
API ni Lolita).
- **Costo**: $0. NO se incluye en `DESPACHO_PLAN_PRICES` → no genera
Subscription, no usa MercadoPago.
- **Vigencia**: indefinida. `tenant.trialEndsAt = null`. Sin
`currentPeriodEnd`. Ningún cron lo expira (`expireTrials`,
`applyPendingChanges` no le afectan).
- **Visibilidad**: oculto del catálogo en `/configuracion/planes-despacho`
(página user). Solo aparece como opción en `/clientes` (admin global).
### Catálogo (`packages/shared/src/constants/despacho-plans.ts`)
Nueva entrada `custom` en `DESPACHO_PLANS` con limits idénticos a Mi Empresa
y mismo array de features. NO se agrega a `DESPACHO_PLAN_PRICES`
helpers `permiteOverage('custom')` y `isDespachoPaidPlan('custom')` ya
retornan `false` por exclusión.
### Admin `/clientes` — extensión del dropdown
La página admin tenía un dropdown limitado a `starter | business |
enterprise`. Cambios:
- `PlanType` extendido a `starter | business | business_ia | enterprise | custom`.
- Tipo `CreateTenantData.plan` y `UpdateTenantData.plan` en
`apps/web/lib/api/tenants.ts` extendidos al mismo union.
- Dropdown ahora lista los 4 legacy con etiqueta "(legacy)" + Custom con
descripción "Sin cobro, sin fecha fin (despacho)".
- Cuando se selecciona Custom, el input "Monto Mensual" se oculta y
aparece nota: "Plan Custom no genera cobro ni suscripción. Vigencia
indefinida."
- Lista de tenants ahora usa el `PLAN_LABELS` global (cubre todos los
planes incluyendo despacho) en vez del `planLabels` local que solo
cubría legacy. `planColors` extendido con entradas para todos los
planes despacho + custom.
**Out of scope**: asignar planes despacho pagables (`mi_empresa`,
`mi_empresa_plus`, `business_control`, `business_cloud`) desde
`/clientes`. Esos van por self-serve del owner para evitar el escenario
de un tenant en plan paid sin Subscription. Si se necesita en el futuro,
requiere manejar la creación/cancelación de preapproval MP en el
endpoint admin.
### User `/configuracion/planes-despacho` — banner Custom
- `Despachoplan` type extendido con `custom`.
- Si `currentPlan === 'custom'`: banner rosa al top con "Plan Custom —
sin cobro, vigencia indefinida" + descripción "Tu cuenta está bajo un
plan especial asignado por tu administrador. Contacta a soporte si
necesitas cambiar de plan." Las cards de planes pagables se ocultan
(no hay opción de auto-cambio).
- Otros planes: comportamiento idéntico al de antes.
### Backend — sin cambios
`PUT /api/tenants/:id` ya acepta cualquier valor del enum Prisma (no
hay Zod gate restrictivo en el endpoint admin). Solo el endpoint
self-serve `addMyTenant` tiene Zod limitado a legacy — no se toca, sigue
siendo correcto que self-serve no permita Custom.
### 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.
2. **Hard limit 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 V.1.0.11.
### Archivos modificados (V.1.0.14)
```
packages/shared/src/constants/despacho-plans.ts
apps/web/lib/api/tenants.ts
apps/web/app/(dashboard)/clientes/page.tsx
apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx
docs/superpowers/specs/2026-04-27-custom-plan-design.md
```
---
## 9. Pendientes derivados
### Verificación manual del owner (smoke)
Aún no se ha ejecutado smoke en navegador para los cambios de la sesión.
Lista mínima:
1. **Dashboard regression**: KPIs (`Ingresos`, `Gastos`, `Utilidad`) deben
ser idénticos a los de V.1.0.5 (= sin filtros aplicados).
2. **Histórico ISR (V.1.0.6)**: 6 columnas, BG_acum negativa en rojo,
running totals correctos, sin fila Total.
3. **Cálculo de ISR del Periodo (V.1.0.6)**: 8 renglones, etiquetas
dinámicas según `mesFinal`, BG en rojo si negativa.
4. **Toggle "Considerar activos" OFF (V.1.0.7+V.1.0.9)**:
- Excluye I uso I01-I08.
- Excluye P pagando esos I.
- Excluye E (cualquier tipoRel) referenciando esos I o P.
5. **Toggle "Considerar NCs" OFF (V.1.0.7)**: excluye E tipoRel=01.
6. **Combinaciones de los 3 toggles** (Conciliación + Activos + NCs):
8 combinaciones, números deben ser consistentes.
7. **Activos Fijos tab**: la tabla sigue mostrando todos los I con uso
I01-I08, no afectada por los toggles de impuestos.
8. **Filtro de régimen**: sigue distribuyendo correctamente cuando se
selecciona un régimen y se togglea cualquier filtro.
### Performance follow-ups
- Si toggle activos OFF en `/impuestos` siente lento (>3-4s), considerar:
- Índice B-tree en `uuid_relacionado` (cheap).
- Migración para indexar `cfdis_relacionados` (caro: requiere GIN
sobre array, o normalizar a tabla M:N).
- Fase 2 del feature de filtros: extender `metricas_mensuales` con
columnas base + deltas. Toggles instantáneos sin importar el estado.
### Push manual
Las versiones están commiteadas en OneDrive pero **no pusheadas a
`origin/main`**. Owner las push cuando quiera.
```
72ffdca V.1.0.14 ← Custom plan (gratis, sin fecha fin, solo admin)
6686e70 V.1.0.13 ← session doc updated (V.1.0.11 + V.1.0.12)
59d71ae V.1.0.12 ← fix scope SQL helper (bug crítico V.1.0.9)
f4e0d6f V.1.0.11 ← trial RFC limit (5 max)
b8c9df1 V.1.0.10 ← session summary doc original
297ffdb V.1.0.9 ← filtro activos extendido a P y E (con bug que V.1.0.12 arregla)
4b7566e V.1.0.8 ← defaults flipped a ON
2970ccf V.1.0.7 ← filtros activos/NCs Fase 1
cc34c39 V.1.0.6 ← ISR base gravable acumulada
```
### Fuera de scope (a evaluar después)
- **Implementar drill-down sort por nombre** (spec ya aprobada).
- **Replicar los toggles de impuestos a `/dashboard`** (si se pide):
ya está habilitado por las signatures de `calcular*PorRegimen`
solo falta UI + propagación.
- **Persistencia de los toggles** (hoy son `useState`, se pierden al
recargar): considerar `localStorage` o `tenant-view-store`.
- **Hard limit Mi Empresa (1 RFC)**: hoy es solo billing-only. Replicar
el patrón del trial limit V.1.0.11 (Mi Empresa también haría check
duro al crear). Aplica también para Custom (1 RFC). Considerar al
implementar Fase 2 del feature de filtros.
- **Bug class similar al V.1.0.12 en otras subqueries**: revisar otros
helpers SQL del repo que tengan subqueries con tablas del mismo
nombre (cfdis, conciliaciones, etc.) y qualifying débil. Sería un
audit pasivo, no urgente.
- **Asignar planes despacho pagados desde `/clientes` admin**: hoy
Custom es el único plan despacho asignable desde admin. Si se quiere
agregar también `mi_empresa`, `business_control`, etc., requiere
manejar creación/cancelación de preapproval MP en el endpoint admin.
Out of scope para V.1.0.14, pendiente futuro.