Files
HoruxDespachosNuevo/docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md

348 lines
14 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.
# Filtros "Considerar activos" y "Considerar NCs" en /impuestos — Fase 1
## Contexto
La pestaña ISR e IVA de `/impuestos` actualmente solo tiene un toggle de
"Conciliación" que cambia la semántica de fechas. El owner pidió dos toggles
adicionales:
1. **Considerar activos** — cuando ACTIVADO, incluye facturas tipo I con
`uso_cfdi` ∈ {I01, I02, I03, I04, I05, I06, I07, I08} (compras de activos
fijos / inversiones). Cuando DESACTIVADO, excluye esas facturas.
2. **Considerar NCs** — cuando ACTIVADO, incluye facturas tipo E con
`cfdi_tipo_relacion = '01'` (notas de crédito). Cuando DESACTIVADO, las
excluye.
### Decisión de defaults
**Default ambos toggles ON (incluir)** — revertido del default original OFF
por concerns de performance: con default OFF, el cache `metricas_mensuales`
quedaría siempre bypass-eado en `/impuestos` hasta Fase 2. Con default ON,
las cargas iniciales aprovechan el cache (comportamiento idéntico al de
versiones previas), y el contador opt-in al view filtrado cuando lo necesita.
Trade-off aceptado: el contador debe **desactivar manualmente** los toggles
cuando quiere ver números sin activos / sin NCs. La lógica fiscal de
"depreciación de activos" requiere consciencia del contador, no se aplica
silenciosamente.
Los filtros aplican **solo** en la pestaña Impuestos (IVA + ISR). Dashboard,
reportes, drill-downs, alertas y demás permanecen intactos.
## Justificación fiscal
- Los activos fijos (uso I01-I08) deben depreciarse, no deducirse en su mes
de adquisición. Excluirlos del cálculo provisional mensual evita inflar las
deducciones. La pestaña dedicada "Activos Fijos" (en `/impuestos`) es donde
se muestra y gestiona esa información.
- Las NCs tipoRel=01 son ajustes a documentos previos. El owner quiere ver
los números **brutos sin ajustes** por default y opt-in con el toggle. Asume
el riesgo de over-reporting si el contador olvida activarlo.
## Fases
- **Fase 1 (este spec):** UI + backend con live query. Sin cambios al cache
`metricas_mensuales`. Cuando los toggles están en su default (OFF), el cache
queda bypass-eado y todo es live query.
- **Fase 2 (spec posterior):** extender `metricas_mensuales` con columnas
base + 2 deltas para hacer el toggle instantáneo (computado por suma/resta).
## Cambios — Frontend
### `apps/web/app/(dashboard)/impuestos/page.tsx`
State nuevo (defaults `true` = filter active = incluir, cache-friendly):
```ts
const [considerarActivos, setConsiderarActivos] = useState(true);
const [considerarNCs, setConsiderarNCs] = useState(true);
```
Con defaults `true`, las cargas iniciales aprovechan el cache de
`metricas_mensuales`. El gate `!conciliacion && considerarActivos && considerarNCs`
queda en `true` por default y permite cache hit. El contador opt-in al view
filtrado desactivando los toggles cuando lo necesita.
UI: 2 toggle buttons en la misma fila que "Conciliación", mismo styling.
Orden recomendado: `Régimen | Conciliación | Considerar activos | Considerar NCs`.
```tsx
<button
onClick={() => setConsiderarActivos(!considerarActivos)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
considerarActivos
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
title="Si está inactivo, no se consideran facturas tipo I con uso de CFDI I01-I08 (compras de activos fijos)."
>
<CheckSquare className="h-4 w-4" />
Considerar activos
</button>
```
(Análogo para Considerar NCs con tooltip "...facturas tipo E con tipo de
relación 01 (notas de crédito).")
Pasar a todos los hooks consumidos en la pestaña ISR e IVA.
### `apps/web/lib/hooks/use-impuestos.ts`
Extender 5 hooks. Ejemplo:
```ts
export function useResumenIsrDesglosado(
fechaFin: string,
conciliacion?: boolean,
considerarActivos?: boolean,
considerarNCs?: boolean,
) {
const tk = useTenantKey();
const { selectedContribuyenteId } = useContribuyenteStore();
return useQuery({
queryKey: ['isr-resumen-desglosado', tk, fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId],
queryFn: () => impuestosApi.getResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId),
enabled: !!fechaFin,
});
}
```
Aplicar el mismo patrón a `useResumenIsr`, `useResumenIva`, `useIsrMensual`,
`useIvaMensual`.
### `apps/web/lib/api/impuestos.ts`
Extender funciones HTTP. Ejemplo:
```ts
export async function getResumenIsrDesglosado(
fechaFin: string,
conciliacion?: boolean,
considerarActivos?: boolean,
considerarNCs?: boolean,
contribuyenteId?: string | null,
): Promise<ResumenIsrDesglosado> {
const params = new URLSearchParams();
params.set('fechaFin', fechaFin);
if (conciliacion) params.set('conciliacion', 'true');
if (considerarActivos) params.set('considerarActivos', 'true');
if (considerarNCs) params.set('considerarNCs', 'true');
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get<ResumenIsrDesglosado>(`/impuestos/isr/resumen-desglosado?${params}`);
return response.data;
}
```
(Análogo para `getResumenIsr`, `getResumenIva`, `getIsrMensual`, `getIvaMensual`.)
## Cambios — Backend
### Helper compartido en `apps/api/src/services/impuestos.service.ts`
```ts
/**
* Construye fragmentos AND adicionales para WHERE clauses según los toggles
* "Considerar activos" y "Considerar NCs" en la UI de impuestos.
*
* - considerarActivos === false → excluir facturas tipo I con uso de CFDI I01-I08.
* - considerarNCs === false → excluir facturas tipo E con cfdi_tipo_relacion = '01'.
*
* Cuando ambos son true (default backend), retorna string vacío. Esto preserva
* el comportamiento histórico para callers que no pasan los flags (ej. dashboard).
*/
function buildExtraFilters(considerarActivos: boolean, considerarNCs: boolean): string {
const parts: string[] = [];
if (!considerarActivos) {
parts.push(`AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08'))`);
}
if (!considerarNCs) {
parts.push(`AND NOT (tipo_comprobante = 'E' AND COALESCE(cfdi_tipo_relacion, '') = '01')`);
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
```
### Funciones modificadas
Agregar 2 parámetros booleanos opcionales con default `true` (= include
todo, comportamiento histórico). Forman el último par de la signature.
| Función | Archivo | Cambio |
|---|---|---|
| `calcularIngresosPorRegimen` | `dashboard.service.ts` | +`considerarActivos=true, considerarNCs=true`, concatenar `buildExtraFilters(...)` al WHERE |
| `calcularEgresosPorRegimen` | `dashboard.service.ts` | Idem |
| `getResumenIva` | `impuestos.service.ts` | Idem + propagar al cache gate (ver abajo) |
| `getIvaMensual` | `impuestos.service.ts` | Idem |
| `getResumenIsr` | `impuestos.service.ts` | Idem + propagar a `calcular*PorRegimen` |
| `getIsrMensual` | `impuestos.service.ts` | Idem + propagar a `calcular*PorRegimen` |
| `getResumenIsrDesglosado` | `impuestos.service.ts` | Idem + propagar a las 3 llamadas a `getResumenIsr` |
**Importante**: como `buildExtraFilters` está en `impuestos.service.ts` y
`calcular*PorRegimen` viven en `dashboard.service.ts`, hay que **mover el
helper a un módulo compartido** o duplicarlo. Recomendación: mover a un
nuevo `apps/api/src/services/_shared/cfdi-filters.ts` (módulo neutral
reutilizable). Ambos services lo importan.
### Aplicación del fragmento
Concatenar al WHERE de TODA query que escanee `cfdis` dentro de las funciones
afectadas. Buscar patrón `WHERE ${VIGENTE} AND ${FR}` y agregar
`${buildExtraFilters(...)}` al final del WHERE.
Ejemplo en una query existente:
```ts
const FR = getFR(conciliacion);
const extra = buildExtraFilters(considerarActivos, considerarNCs);
const { rows } = await pool.query(`
SELECT regimen_fiscal_emisor as regimen, ...
FROM cfdis
WHERE ${VIGENTE} AND ${FR}${extra}
AND ${ctx.esEmisor}
GROUP BY regimen_fiscal_emisor
`, [fechaInicio, fechaFin]);
```
`buildExtraFilters` ya retorna con leading space, así que se concatena directo.
**Subqueries con alias** (`SUM_E_REFERENCING_*`): el alias `e` para la tabla
externa requiere referenciar columnas como `e.tipo_comprobante`,
`e.uso_cfdi`, `e.cfdi_tipo_relacion`. Necesitamos una variante del helper que
acepte alias:
```ts
function buildExtraFiltersAlias(alias: string, considerarActivos: boolean, considerarNCs: boolean): string {
const parts: string[] = [];
if (!considerarActivos) {
parts.push(`AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08'))`);
}
if (!considerarNCs) {
parts.push(`AND NOT (${alias}.tipo_comprobante = 'E' AND COALESCE(${alias}.cfdi_tipo_relacion, '') = '01')`);
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
```
Y se usa donde aparezcan subqueries con alias `e` (ej. `SUM_E_REFERENCING_*`,
`HAS_E_REFERENCING_MISMO_MES`, `E_REFERENCIA_I_PPD_07_MISMO_MES` si existe).
### Controllers — `apps/api/src/controllers/impuestos.controller.ts`
Helper para parsear (junto a `parseConciliacion`):
```ts
function parseFlag(req: Request, key: string, defaultValue = true): boolean {
const v = req.query[key];
if (v === undefined || v === null) return defaultValue;
return v === 'true' || v === '1';
}
```
Cada handler relevante (`getResumenIva`, `getIvaMensual`, `getResumenIsr`,
`getIsrMensual`, `getResumenIsrDesglosado`) parsea los 2 nuevos flags con
**default `true`** y los pasa al service.
```ts
const considerarActivos = parseFlag(req, 'considerarActivos', true);
const considerarNCs = parseFlag(req, 'considerarNCs', true);
```
**Razón del default `true` en el controller**: si por algún motivo el query
param no llega (cliente legacy, prueba manual, otro consumer), comportamiento
es como antes (todo incluido). El frontend siempre manda el flag explícito,
así que en la práctica el default solo aplica al testing externo.
### Cache gate en `getResumenIva` (línea ~322)
Extender la condición:
```ts
if (
!conciliacion &&
considerarActivos && // new — cache solo aplica con backend default
considerarNCs && // new
contribuyenteId &&
...
) {
const cached = await readResumenIvaFromCache(...);
if (cached) return cached;
}
```
Con UI default (ambos toggles ON), `considerarActivos=true && considerarNCs=true`
→ cache hit (comportamiento idéntico a versiones previas). Cuando el contador
desactiva alguno → cache bypass → live query (~1-3s). Aceptable porque el
desactivado es action consciente, no la carga inicial. Fase 2 hará los toggles
instantáneos vía cache base+deltas.
## No-cambios
- **Schema BD**: ninguno. SQL puro.
- **Cache `metricas_mensuales`**: estructura intacta. Solo se actualiza el gate.
- **Dashboard, reportes, drill-downs, alertas**: comportamiento idéntico
(gracias a defaults `true` en `calcular*PorRegimen`).
- **Activos Fijos tab**: usa su propio `activos-fijos.service.ts`, no pasa
por las funciones filtradas. Verificar en el smoke.
- **`getRegimenesDelPeriodo`** y otros que NO calculan ingresos/deducciones
no se modifican. Los regímenes disponibles en el dropdown siguen siendo
los mismos (basados en presencia de CFDIs, no filtrados por estos toggles).
## Riesgos
1. **Tocar funciones compartidas con dashboard**: `calcular*PorRegimen` viven
en `dashboard.service.ts`. Default `true` debería preservar el dashboard,
pero hay que verificar manualmente post-deploy.
2. **Performance Fase 1**: con UI default ON (cache-friendly), las cargas
iniciales son rápidas. Solo cuando el contador desactiva un toggle hay
live query. Fase 2 elimina ese delay también.
3. **Subqueries con alias**: hay 5+ subqueries con alias `e` en
`impuestos.service.ts` (rama I PPD/07). Cada una necesita el helper alias.
Riesgo de olvidar una → resultados inconsistentes.
4. **NCs default OFF puede sobre-reportar ingresos**: el contador puede no
notar que las NCs están excluidas si no lee el tooltip. Mitigación:
tooltip claro y label "Considerar NCs" (lectura obvia).
## Plan de pruebas (smoke)
1. **Typecheck**: `pnpm --filter @horux/shared typecheck`,
`pnpm --filter @horux/api typecheck`. Ambos PASS sin errores.
2. **Dashboard regression**: abrir `/dashboard` → KPIs (ingresos, gastos,
utilidad) deben tener los mismos valores que antes del deploy.
3. **Activos Fijos tab**: abrir `/impuestos` → pestaña "Activos Fijos" → la
tabla debe seguir mostrando todas las facturas I con uso I01-I08.
4. **UI default (ambos toggles OFF)**: cargar `/impuestos` ISR. Verificar
que ingresos del periodo y deducciones son menores que antes (excluyen
activos + NCs tipoRel=01).
5. **Toggle "Considerar activos" ON**: deducciones suben con la suma de los
activos del periodo.
6. **Toggle "Considerar NCs" ON**: comportamiento depende del lado:
- Como receptor (NC recibida que cancela una factura PUE): deducciones
bajan (la NC resta).
- Como emisor (NC emitida que cancela una factura PUE propia): ingresos
bajan.
7. **Combinaciones de los 3 toggles** (Conciliación + Activos + NCs): ocho
combinaciones, números deben ser consistentes.
8. **IVA tab**: mismas pruebas (toggle on/off, comparar números).
9. **Tabla "Histórico ISR"**: debe respetar los 2 nuevos toggles también
(cada fila refleja los acumulados con los filtros activos).
10. **Sección "Cálculo de ISR del Periodo"**: las 3 ramas (`delPeriodo`,
`anteriores`, `total`) deben respetar los toggles consistentemente.
## Pendientes derivados
- **Fase 2**: extender `metricas_mensuales` con columnas
`*_activos`, `*_ncs_01` (×3 métricas IVA = 6 columnas nuevas).
Migration + recompute del cache + actualizar lectura del cache para hacer
suma/resta según toggles. Fase 2 entrega toggles instantáneos.
- **Tooltip + iconos**: si el owner quiere distinguir visualmente los 3
toggles (Conciliación con un check, Activos con un asset icon, NCs con un
document icon), aplicar después.
- **Persistencia de los toggles**: hoy el state vive en `useState`, se pierde
al recargar. Si se quiere persistir, considerar `localStorage` o agregar a
`tenant-view-store`. Out-of-scope para Fase 1.
- **Dashboard parity**: si en el futuro el owner quiere los mismos toggles en
`/dashboard`, ya está habilitado por la signature de `calcular*PorRegimen`
— solo falta UI + propagación. Out-of-scope.