Files
HoruxDespachos/docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md
2026-04-27 22:09:36 -06:00

12 KiB
Raw Permalink Blame History

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:

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:

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:

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

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:

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

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

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