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