348 lines
14 KiB
Markdown
348 lines
14 KiB
Markdown
# 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.
|