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