Files
HoruxDespachosNuevo/docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md

299 lines
12 KiB
Markdown
Raw 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.
# 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.