# 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
<td>{meses[row.mes - 1]}</td>
<td className="text-right">{formatCurrency(row.ingresosAcumulados)}</td> // mensual
<td className="text-right">{formatCurrency(row.ingresosAcum)}</td>
<td className="text-right">{formatCurrency(row.deducciones)}</td>
<td className="text-right">{formatCurrency(row.deduccionesAcum)}</td>
<td className={cn(
  "text-right font-medium",
  row.baseGravableAcum < 0 ? "text-destructive" : ""
)}>{formatCurrency(row.baseGravableAcum)}</td>
```

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 `<CardTitle>` 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
<div className="space-y-2">
  <FilaDesglose label={`Ingresos del periodo (${labelMesFinal})`} value={delPeriodo.ingresos} />
  <FilaDesglose label={`(+) Ingresos acumulados anteriores ${labelAnteriores}`} value={anteriores.ingresos} />
  <FilaDesglose label={`(−) Deducciones del periodo`} value={delPeriodo.deducciones} negative />
  <FilaDesglose label={`(−) Deducciones acumuladas anteriores`} value={anteriores.deducciones} negative />
  <Divider />
  <FilaDesglose
    label="(=) Base gravable acumulada"
    value={total.baseGravable}
    bold
    danger={total.baseGravable < 0}
  />
  <FilaDesglose label="ISR causado (acumulado)" value={total.isrCausado} />
  <FilaDesglose label="(−) ISR retenido (acumulado)" value={total.isrRetenido} negative />
  <Divider />
  <FilaDesglose label="ISR a pagar" value={total.isrAPagar} bold large />
</div>
```

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 `<div className="flex justify-between py-2 border-b">` 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.
