# ISR — Base gravable acumulada y desglose del periodo ## Contexto En `/impuestos` (pestaña ISR) hay dos lugares donde la base gravable se calcula mes a mes en lugar de acumulado, lo cual es fiscalmente incorrecto para pagos provisionales mensuales: 1. **Tabla "Histórico ISR"** (`apps/web/app/(dashboard)/impuestos/page.tsx`, líneas ~503-568): cada fila aplica `Math.max(0, ing_mes − ded_mes)` por mes independiente. Resultado: un mes con pérdida no reduce el acumulado. 2. **Sección "Cálculo de ISR Acumulado"** (mismas líneas ~371-432): muestra los totales del rango filtrado en `resumenIsr`, sin distinguir lo que ya estaba acumulado de meses previos del mismo año vs. el periodo actual. El bug raíz vive en `getIsrMensual` (`apps/api/src/services/impuestos.service.ts`, líneas 409-486): el query corre de `${año}-${mm}-01` a fin de mes, así que el campo nombrado `ingresosAcumulados` en `IsrMensual` realmente trae solo el mes (deuda heredada del refactor previo, el nombre miente). ## Objetivo Mostrar la base gravable y los montos acumulados correctamente: 1. En la tabla, agregar columnas **Ingresos Acum.**, **Deducciones Acum.** y **Base Gravable Acum.** (estas tres son running totals desde enero hasta el mes de cada fila). La **BG mensual desaparece** del display — solo queda la acumulada, que es la única fiscalmente válida. 2. En la sección de cálculo, presentar el desglose como aparece en el formato 14 (declaración provisional mensual del SAT): ``` Ingresos del periodo + Ingresos anteriores − Deducciones del periodo − Deducciones anteriores = Base gravable acumulada ``` Donde **"del periodo" = mes final del filtro** y **"anteriores" = enero hasta el mes anterior al final**. ## Reglas fiscales - **No se aplica `max(0, ...)` al display** de base gravable. Los déficits son reales y se muestran negativos (en rojo). Si filtras febrero y enero tuvo utilidad pero febrero pérdida grande, `BG_acum_feb` puede ser negativa. - **`max(0, ...)` se aplica únicamente al pasar a ISR causado**: si `BG_acum < 0`, ISR causado = 0. SAT hace lo mismo en el formato 14. - **El año fiscal se resetea en enero**. "Anteriores" jamás cruza a años previos. ## Cambios — Backend ### `apps/api/src/services/impuestos.service.ts` **`getIsrMensual` (líneas 409-486):** Después del loop que llena `result[]` con datos mensuales, agregar un segundo pase que computa los running totals: ```ts let ingAcum = 0, dedAcum = 0; for (const row of result) { ingAcum += row.ingresosAcumulados; // (mensual, a pesar del nombre) dedAcum += row.deducciones; row.ingresosAcum = ingAcum; row.deduccionesAcum = dedAcum; row.baseGravableAcum = ingAcum - dedAcum; // sin clamp } ``` Nota sobre naming: el campo existente `ingresosAcumulados` en `IsrMensual` se mantiene por compat (es el mensual). Los nuevos campos son `ingresosAcum`, `deduccionesAcum`, `baseGravableAcum`. En el spec del rename total al final puede ocurrir, pero no es scope de este cambio. **Nueva función exportada** `getResumenIsrDesglosado`: ```ts export async function getResumenIsrDesglosado( pool: Pool, fechaFin: string, tenantId: string, conciliacion?: boolean, contribuyenteId?: string | null, ): Promise<{ delPeriodo: ResumenIsr; anteriores: ResumenIsr; total: ResumenIsr; }> ``` Lógica: 1. Derivar `año = fechaFin.year`, `mesFinal = fechaFin.month`. 2. Tres rangos: - **delPeriodo**: `${año}-${mesFinal}-01` a fin de `mesFinal` (solo mes final) - **anteriores**: `${año}-01-01` a `${año}-${mesFinal-1}-${ultDia}` (Ene a mesFinal-1; vacío si mesFinal=1) - **total**: `${año}-01-01` a fin de `mesFinal` (Ene a mesFinal) 3. Llamar `getResumenIsr` 3 veces con esos rangos, retornar el objeto. Caso `mesFinal=1`: retornar `anteriores` con todos los campos en cero (no se hace query inútil). ### `apps/api/src/controllers/impuestos.controller.ts` Agregar handler `getResumenIsrDesglosado`: ```ts // GET /api/impuestos/resumen-isr-desglosado?fechaFin=...&conciliacion=...&contribuyenteId=... ``` El filtro por régimen no se pasa al endpoint — el frontend hace el lookup contra `resumenIsr.baseGravablePorRegimen[]` igual que hoy con `useResumenIsr`, para que la lógica de filtrado siga centralizada en un solo lugar. ### `apps/api/src/routes/impuestos.routes.ts` Agregar la ruta `/resumen-isr-desglosado` con los mismos middlewares que `/resumen-isr` (auth + tenant + plan limits). ## Cambios — Shared types ### `packages/shared/src/types/reportes.ts` (o donde viva `IsrMensual`) Agregar campos al type: ```ts export interface IsrMensual { // ...campos existentes ingresosAcum: number; deduccionesAcum: number; baseGravableAcum: number; // sin clamp, puede ser negativo } export interface ResumenIsrDesglosado { delPeriodo: ResumenIsr; anteriores: ResumenIsr; total: ResumenIsr; } ``` ## Cambios — Frontend ### `apps/web/lib/api/impuestos.ts` Agregar función `getResumenIsrDesglosado` (cliente HTTP) y hook `useResumenIsrDesglosado` en `apps/web/lib/hooks/use-impuestos.ts`. ### `apps/web/app/(dashboard)/impuestos/page.tsx` **Tabla "Histórico ISR" (líneas ~502-568):** Headers (6 columnas): ``` Mes | Ingresos | Ingresos Acum. | Deducciones | Deducciones Acum. | Base Gravable Acum. ``` Body por fila: ```tsx {meses[row.mes - 1]} {formatCurrency(row.ingresosAcumulados)} // mensual {formatCurrency(row.ingresosAcum)} {formatCurrency(row.deducciones)} {formatCurrency(row.deduccionesAcum)} {formatCurrency(row.baseGravableAcum)} ``` Fila Total: eliminar. La última fila (diciembre) ya es el total YTD, no hace falta sumar acumulados (sería incorrecto). Si se quiere conservar, mostrar solo los mensuales sumados (= total año) y el último valor acumulado de la columna BG Acum. **Decisión por defecto en este spec:** eliminar fila Total. Si el usuario prefiere conservarla, lo discutimos al implementar. Export Excel: 6 columnas alineadas con UI: ```ts [ { header: 'Mes', key: 'Mes' }, { header: 'Ingresos', key: 'Ingresos' }, { header: 'Ingresos Acumulados', key: 'IngresosAcum' }, { header: 'Deducciones', key: 'Deducciones' }, { header: 'Deducciones Acumuladas', key: 'DeduccionesAcum' }, { header: 'Base Gravable Acumulada', key: 'BaseGravableAcum' }, ] ``` **Sección "Cálculo de ISR del Periodo" (líneas ~371-432):** 1. Renombrar `` de "Cálculo de ISR Acumulado" a "Cálculo de ISR del Periodo". 2. Reemplazar el query `useResumenIsr(fechaInicio, fechaFin, conciliacion)` por `useResumenIsrDesglosado(fechaFin, conciliacion, contribuyenteId)`. El filtro por régimen se aplica del lado frontend contra `total.baseGravablePorRegimen[]` (mismo patrón que hoy). 3. Layout nuevo del card content: ```tsx
``` Etiquetas dinámicas: - `labelMesFinal` = `"Mar 2026"` (mes y año de `fechaFin`) - `labelAnteriores` = `"(Ene-Feb)"` o `"(sin meses anteriores)"` cuando `mesFinal === 1`. Si `mesFinal === 1`: las dos filas "anteriores" muestran `$0` con texto discretamente atenuado y el label dice "(sin meses anteriores)". `FilaDesglose` puede ser un componente local del archivo o sustituirse por el mismo `
` que ya se usa. Decisión por defecto: inline (no extraer componente nuevo, mantener el patrón existente). ## No-cambios - `getResumenIsr` se mantiene tal cual — sigue usándose en KPIs y otros lugares. - Los KPIs en la parte alta de la pestaña ISR (Ingresos, Deducciones, Base Gravable, etc.) **siguen mostrando los valores del rango filtrado completo**. El cambio aplica solo a la tabla histórica y a la sección de cálculo. - `metricas_mensuales` (cache) sigue guardando valores mensuales puros — el acumulado se computa al consumir el cache. Sin invalidaciones. - IVA mensual (`getIvaMensual`) no se toca. ## Riesgos - **BG mensual deja de aparecer en la tabla**: si algún usuario hacía export y reportaba la BG mensual a contadores, esa columna ya no existe. Mitigación: comunicar el cambio en el changelog/release notes. - **Año cruzado**: si el usuario filtra `fechaFin = 2026-03-31` pero `fechaInicio` es de 2025, "anteriores" sigue siendo solo Ene-Feb 2026, no baja a 2025. Esperable porque ISR se acumula por año fiscal. - **Performance**: 3 queries `getResumenIsr` por refresh de la sección de cálculo. Cada uno hace ~10 queries internos (por régimen, retenciones, etc.). En un mes promedio del año, son ~30 queries. Aceptable para un endpoint on-demand. Si se vuelve cuello de botella, optimizar con un solo query agregado. ## Plan de pruebas (smoke) 1. `pnpm typecheck` debe seguir limpio en `@horux/api` y `@horux/shared`. 2. Backend — abrir REPL/curl: - `GET /api/impuestos/resumen-isr-desglosado?fechaFin=2026-03-31&...`: - `delPeriodo` = solo Mar 2026 - `anteriores` = Ene-Feb 2026 - `total.ingresos === delPeriodo.ingresos + anteriores.ingresos` - `total.baseGravable === total.ingresos − total.deducciones` (sin clamp, puede ser negativo) - Mismo endpoint con `fechaFin=2026-01-31`: - `anteriores.ingresos === 0`, `anteriores.deducciones === 0`, etc. 3. Frontend tabla: - Tenant con datos en varios meses (p.ej. Patito): verificar que cada fila muestre el running total correcto. - Tenant con un mes negativo (Husberto Feb si hay datos): la BG Acum debe aparecer en rojo y reducir el acumulado del mes siguiente. 4. Frontend sección: - Filtrar `mes=marzo`: ver que los 4 renglones cuadren con la fórmula y la línea BG sea la suma algebraica. - Filtrar `mes=enero`: ver que las dos líneas "anteriores" digan "$0" con etiqueta "(sin meses anteriores)". - Filtrar `mes=diciembre`: ver acumulado anual completo, "anteriores" = Ene-Nov, "del periodo" = Dic. 5. Validación cruzada con declaración SAT real (si owner tiene una a la mano): confirmar que los números del desglose coincidan con la declaración formato 14. ## Pendientes derivados - Considerar agregar **un endpoint `getIsrMensualConAcumulados`** que retorne los acumulados pre-computados, en vez de exponerlos como campos extra del endpoint actual. Reduciría payload si solo se necesita una vista. - Si el cache de `metricas_mensuales` empieza a usarse para ISR (hoy solo es para IVA), repetir la fix del acumulado al consumir el cache. - **Recompute opcional**: el bug actual ya no es visible (eliminamos la BG mensual) pero la fila de cálculo del periodo SÍ depende de queries en vivo. No hay cache que invalidar — el fix es inmediato al deploy.