Files
HoruxDespachos/docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md
2026-04-27 22:09:36 -06:00

14 KiB
Raw Permalink Blame History

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):

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.

<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:

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:

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

/**
 * 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:

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:

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):

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.

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:

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.