632 lines
27 KiB
Markdown
632 lines
27 KiB
Markdown
# 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+A−Y−B ← 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.
|