Files
HoruxDespachosNuevo/apps/api/src/services/impuestos.service.ts
Horux Dev 2208cee87f fix(impuestos): desactivar JIT en queries con subplans correlacionados
- Agrega helper withJitOff en impuestos.service.ts
- Ejecuta getResumenIva, getIvaMensual y readResumenIvaFromCache con SET LOCAL jit = off
- Evita compilación JIT de ~17s en queries con costo estimado alto

feat(contribuyentes): auto-asignar a cartera del supervisor

- Al crear contribuyente con supervisorUserId, se agrega automáticamente
  a todas las carteras top-level del supervisor

feat(permisos): restricciones de UI por rol en contribuyentes

- Oculta botón Add-ons para roles distintos de owner/cfo
- Oculta botón Eliminar contribuyente para no-owner
- Oculta botón Agregar RFC para auxiliar/visor/cliente/contador

feat(cfdi): ver CFDI desde conceptos y forma de pago en Excel

- Agrega botón Ver CFDI en cada fila de la tabla de Conceptos
- Agrega columna Forma de Pago en export Excel de CFDIs
- Agrega columna Forma de Pago en export individual de CFDI

chore(migraciones): índices GIN para relaciones de activos

- 048: índices btree parciales para activos
- 049: índices GIN para cfdis_relacionados y uuid_relacionado
2026-05-28 02:38:30 +00:00

1194 lines
51 KiB
TypeScript
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.
import type { Pool, PoolClient } from 'pg';
import type { IvaMensual, IsrMensual, ResumenIva, IvaRegimenDetalle, ResumenIsr } from '@horux/shared';
import { getRegimenesIgnoradosClaves } from './regimen.service.js';
import {
calcularIngresosPorRegimen,
calcularEgresosPorRegimen,
calcularNcsEmitidasPorRegimen,
calcularNcsRecibidasPorRegimen,
calcularGastosNoDeduciblesEfectivoPorRegimen,
calcularIvaNoAcreditableEfectivoPorRegimen,
NO_DEDUCIBLE_EFECTIVO_I_PUE,
NO_DEDUCIBLE_EFECTIVO_P,
} from './dashboard.service.js';
import { prisma } from '../config/database.js';
import { resolveContribuyenteContext } from '../utils/contribuyente-context.js';
import { planCache, type CacheRange } from '../utils/metricas-cache.js';
import { buildExtraFilters, buildExtraFiltersAlias } from './_shared/cfdi-filters.js';
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
// Para cálculos de IVA/ISR la fecha efectiva depende del tipo de comprobante:
// - tipo P (complemento de pago): fecha real del cobro (fecha_pago_p)
// - otros tipos (I, E, T, N): fecha_emision del comprobante
// El CASE se evalúa por fila, garantizando que un P emitido en mayo por un pago
// real de noviembre quede contabilizado en noviembre.
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END`;
const FECHA_RANGO = `${FECHA_EFECTIVA} >= $1::date AND ${FECHA_EFECTIVA} < ($2::date + interval '1 day')`;
const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN (
SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day')
)`;
function getFR(conciliacion?: boolean): string {
return conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
}
const TODOS_REGIMENES = ['605', '606', '612', '621', '625', '626', '601', '603', '607', '608', '610', '611', '614', '615', '620', '622', '623', '624'];
// Claves de producto/servicio excluidas de cálculos fiscales
const CLAVES_EXCLUIDAS = `('84121603','93161608','85101501','85121800')`;
const EXCL_IVA_TRAS = `COALESCE((SELECT SUM(COALESCE(cc.iva_traslado_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS}), 0)`;
const EXCL_IVA_RET = `COALESCE((SELECT SUM(COALESCE(cc.iva_retencion_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS}), 0)`;
const EXCL_ISR_RET = `COALESCE((SELECT SUM(COALESCE(cc.isr_retencion_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS}), 0)`;
// Fragmentos IVA: CFDIs tipo P usan campos `_pago_mxn` directos; los demás
// tipos usan los campos base del CFDI restando IVA de conceptos excluidos
// (claves prod/serv 84121603, 93161608, 85101501, 85121800).
//
// Los campos `_pago_mxn` se usan tal cual sin clamp — el spec del usuario
// los toma directos. (El clamp `LEAST(iva, monto*0.16)` defendía contra XMLs
// que reportaban IVA de la factura completa en P parciales; se removió a
// petición del owner; ver doc 2026-04-26-iva-refactor.md.)
const IVA_TRAS_EXPR = `CASE WHEN tipo_comprobante = 'P'
THEN COALESCE(iva_traslado_pago_mxn, 0)
ELSE COALESCE(iva_traslado_mxn, 0) - (${EXCL_IVA_TRAS})
END`;
const IVA_RET_EXPR = `CASE WHEN tipo_comprobante = 'P'
THEN COALESCE(iva_retencion_pago_mxn, 0)
ELSE COALESCE(iva_retencion_mxn, 0) - (${EXCL_IVA_RET})
END`;
// Versiones parametrizadas por alias — usadas en subqueries que resuelven
// las E que cancelan I PPD/07 (rama nueva). Mismo tratamiento sin clamp.
const EXCL_IVA_TRAS_ALIAS = (alias: string) =>
`COALESCE((SELECT SUM(COALESCE(cc.iva_traslado_mxn, 0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = ${alias}.id AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS}), 0)`;
const EXCL_IVA_RET_ALIAS = (alias: string) =>
`COALESCE((SELECT SUM(COALESCE(cc.iva_retencion_mxn, 0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = ${alias}.id AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS}), 0)`;
const IVA_TRAS_EXPR_ALIAS = (alias: string) => `CASE WHEN ${alias}.tipo_comprobante = 'P'
THEN COALESCE(${alias}.iva_traslado_pago_mxn, 0)
ELSE COALESCE(${alias}.iva_traslado_mxn, 0) - (${EXCL_IVA_TRAS_ALIAS(alias)})
END`;
const IVA_RET_EXPR_ALIAS = (alias: string) => `CASE WHEN ${alias}.tipo_comprobante = 'P'
THEN COALESCE(${alias}.iva_retencion_pago_mxn, 0)
ELSE COALESCE(${alias}.iva_retencion_mxn, 0) - (${EXCL_IVA_RET_ALIAS(alias)})
END`;
/**
* Condición que identifica I PPD con TipoRelacion=07 (aplicación de anticipo
* en operación PPD). La PPD no aporta IVA en su mes de emisión (se causa al
* cobrar via tipo P). Cuando existe una E del mismo mes/año que la referencia
* y la cancela, la E resta IVA en su mes pero la I PPD/07 no aportó nada que
* compensar. La regla (mirror de `i07PpdComp` en dashboard.service.ts) es
* darle a la I PPD/07 el IVA de la E que la cancela, así I PPD + E netean a 0
* dentro del mes.
*/
const IS_I_PPD_07 = `(tipo_comprobante = 'I' AND metodo_pago = 'PPD' AND COALESCE(cfdi_tipo_relacion, '') = '07')`;
/**
* Subqueries que suman el IVA_TRAS (ó IVA_RET) de las E del **mismo lado**
* (emisor o receptor del contribuyente) que referencian este CFDI (la I PPD/07)
* en `cfdis_relacionados` y caen en el **mismo mes/año**. Aplicado a I PPD/07
* via las ramas nuevas en signed exprs: la I PPD/07 hereda como aporte el
* IVA de TODAS las E que la cancelan (sin importar su tipoRelación),
* igualando lo que esas mismas E restan en NEG.
*
* No filtra por tipoRelación: en PPD, las E/07 que referencian la I PPD/07
* SÍ entran al NEG (vía la condición `E_REFERENCIA_I_PPD_07_MISMO_MES` agregada
* al `bucketCausadoNeg`/`bucketAcreditableNeg`). Como ambas patas — la E
* resta en NEG y la I PPD hereda — usan el mismo set de E's, los IVAs se
* cancelan exactamente dentro del mes. Esto es distinto al triángulo PUE
* (anticipo + I PUE/07 + E/07) donde la E/07 sigue excluida del NEG porque
* apunta al anticipo, no a una I PPD/07.
*
* `esLadoE` es la cláusula `UPPER(rfc_emisor)='X'` o `UPPER(rfc_receptor)='X'`
* traducida al alias `e` (el caller hace el rewrite). Filtrar por mismo
* lado evita capturar E's de otros contribuyentes del tenant que casualmente
* referencien el mismo UUID.
*/
const SUM_E_REFERENCING_TRAS = (
esLadoE: string,
considerarActivos: boolean,
considerarNCs: boolean,
) => {
if (!considerarNCs) return '0';
return `COALESCE((
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
FROM cfdis e
WHERE e.tipo_comprobante = 'E'
AND e.metodo_pago = 'PUE'
AND e.status NOT IN ('Cancelado', '0')
AND ${esLadoE}
AND e.cfdis_relacionados IS NOT NULL
AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour'))
= date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
), 0)`;
};
const SUM_E_REFERENCING_RET = (
esLadoE: string,
considerarActivos: boolean,
considerarNCs: boolean,
) => {
if (!considerarNCs) return '0';
return `COALESCE((
SELECT SUM(${IVA_RET_EXPR_ALIAS('e')})
FROM cfdis e
WHERE e.tipo_comprobante = 'E'
AND e.metodo_pago = 'PUE'
AND e.status NOT IN ('Cancelado', '0')
AND ${esLadoE}
AND e.cfdis_relacionados IS NOT NULL
AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour'))
= date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
), 0)`;
};
// Régimen del contribuyente según su lado: emisor/receptor del CFDI.
// Usa el RFC del contribuyente (via `ctx.esEmisor`/`ctx.esReceptor`) para
// determinar el lado, no el `type` de BD.
const regimenTenantExpr = (ctx: { esEmisor: string; esReceptor: string }) =>
`CASE WHEN ${ctx.esEmisor} THEN regimen_fiscal_emisor
WHEN ${ctx.esReceptor} THEN regimen_fiscal_receptor
ELSE NULL END`;
/**
* Predicado EXISTS que detecta si el CFDI actual (alias implícito `cfdis`) es
* referenciado en `cfdis_relacionados` por al menos una E del **mismo lado**
* y **mismo mes/año**. Usado para incluir I PPD/07 en los buckets Any — sin
* esto, las I PPD/07 quedan fuera del WHERE y las ramas nuevas en signed
* exprs nunca se evalúan. No filtra tipoRelación: en PPD cualquier E que
* referencie la I PPD/07 cuenta (incluyendo las 07, fiscalmente correctas).
*/
const HAS_E_REFERENCING_MISMO_MES = (
esLadoE: string,
considerarActivos: boolean,
considerarNCs: boolean,
) => {
if (!considerarNCs) return 'FALSE';
return `EXISTS (
SELECT 1 FROM cfdis e
WHERE e.tipo_comprobante = 'E'
AND e.metodo_pago = 'PUE'
AND e.status NOT IN ('Cancelado', '0')
AND ${esLadoE}
AND e.cfdis_relacionados IS NOT NULL
AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
AND date_trunc('month', e.fecha_emision)
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
)`;
};
// Atribución por lado usando RFC en lugar de `type`. Los buckets son
// factories que reciben el context del contribuyente:
// POS: CFDIs que suman (I PUE + P del lado correcto)
// NEG: NC del mismo lado (todas las E PUE, sin filtrar TipoRelación —
// las E/07 también restan, en línea con la lógica del owner que asume
// que el contador emite la E/07 cuando aplica el anticipo).
// Queries deben sumar signed: CASE WHEN POS THEN +X WHEN NEG THEN -X.
// Balance final = Causado Acreditable.
const bucketCausadoPos = (ctx: { esEmisor: string }) => `(
${ctx.esEmisor} AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
OR tipo_comprobante = 'P'
)
)`;
const bucketCausadoNeg = (ctx: { esEmisor: string }) => `(
${ctx.esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
)`;
// Art. 5 LIVA fracción I: el IVA acreditable requiere que el gasto cumpla los
// requisitos de deducibilidad ISR. Por Art. 27 fracción III LISR, gastos > $2k
// pagados en efectivo NO son deducibles → su IVA tampoco es acreditable.
// Excluimos esas filas del bucket acreditable POS. Para complementos P,
// comparación con monto_pago_mxn (cada P es pago independiente).
const bucketAcreditablePos = (ctx: { esReceptor: string }) => `(
${ctx.esReceptor} AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE' AND NOT ${NO_DEDUCIBLE_EFECTIVO_I_PUE})
OR (tipo_comprobante = 'P' AND NOT ${NO_DEDUCIBLE_EFECTIVO_P})
)
)`;
// El bucket NEG (NCs) NO se filtra por la regla del efectivo — una NC recibida
// reduce el universo acreditable independiente de cómo se pagó. Si la NC es
// del proveedor cancelando una factura no-acreditable original, el efecto neto
// sigue correcto porque la factura nunca aportó IVA.
const bucketAcreditableNeg = (ctx: { esReceptor: string }) => `(
${ctx.esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
)`;
const bucketCausadoAny = (
ctx: { esEmisor: string },
considerarActivos: boolean,
considerarNCs: boolean,
) => {
const esEmisorE = ctx.esEmisor.replace(/\brfc_(emisor|receptor)\b/g, 'e.rfc_$1');
return `(${bucketCausadoPos(ctx)} OR ${bucketCausadoNeg(ctx)} OR (
${ctx.esEmisor} AND ${IS_I_PPD_07} AND ${HAS_E_REFERENCING_MISMO_MES(esEmisorE, considerarActivos, considerarNCs)}
))`;
};
const bucketAcreditableAny = (
ctx: { esReceptor: string },
considerarActivos: boolean,
considerarNCs: boolean,
) => {
const esReceptorE = ctx.esReceptor.replace(/\brfc_(emisor|receptor)\b/g, 'e.rfc_$1');
return `(${bucketAcreditablePos(ctx)} OR ${bucketAcreditableNeg(ctx)} OR (
${ctx.esReceptor} AND ${IS_I_PPD_07} AND ${HAS_E_REFERENCING_MISMO_MES(esReceptorE, considerarActivos, considerarNCs)}
))`;
};
// Signed SUM expressions. La compensación I PUE/07 se removió a petición del
// owner — las I PUE/07 ahora aportan IVA completo y la E/07 (si se emite)
// resta normalmente vía bucket NEG (que ya no filtra TipoRelación). El owner
// asume que la E/07 se emitirá; si el contador la omite, el IVA del anticipo
// se sobrecausa (vs el flujo previo, que era robusto a E/07 ausente).
//
// Rama I PPD/07 con E del mismo mes: la I PPD/07 hereda el IVA de la E que
// la cancela. Sin esto, la E resta IVA en su mes pero la I PPD/07 nunca
// aportó nada (PPD espera al P). Mirror de `i07PpdComp` en dashboard.service.ts.
// El subquery se filtra por mismo lado (emisor↔emisor o receptor↔receptor)
// usando el predicado `esEmisor`/`esReceptor` reescrito al alias `e`.
const signedCausadoTras = (
ctx: { esEmisor: string },
considerarActivos: boolean,
considerarNCs: boolean,
) => {
const esEmisorE = ctx.esEmisor.replace(/\brfc_(emisor|receptor)\b/g, 'e.rfc_$1');
return `CASE
WHEN ${bucketCausadoPos(ctx)} THEN ${IVA_TRAS_EXPR}
WHEN ${ctx.esEmisor} AND ${IS_I_PPD_07} THEN ${SUM_E_REFERENCING_TRAS(esEmisorE, considerarActivos, considerarNCs)}
WHEN ${bucketCausadoNeg(ctx)} THEN -(${IVA_TRAS_EXPR})
ELSE 0
END`;
};
const signedCausadoRet = (
ctx: { esEmisor: string },
considerarActivos: boolean,
considerarNCs: boolean,
) => {
const esEmisorE = ctx.esEmisor.replace(/\brfc_(emisor|receptor)\b/g, 'e.rfc_$1');
return `CASE
WHEN ${bucketCausadoPos(ctx)} THEN ${IVA_RET_EXPR}
WHEN ${ctx.esEmisor} AND ${IS_I_PPD_07} THEN ${SUM_E_REFERENCING_RET(esEmisorE, considerarActivos, considerarNCs)}
WHEN ${bucketCausadoNeg(ctx)} THEN -(${IVA_RET_EXPR})
ELSE 0
END`;
};
const signedAcreditableTras = (
ctx: { esReceptor: string },
considerarActivos: boolean,
considerarNCs: boolean,
) => {
const esReceptorE = ctx.esReceptor.replace(/\brfc_(emisor|receptor)\b/g, 'e.rfc_$1');
return `CASE
WHEN ${bucketAcreditablePos(ctx)} THEN ${IVA_TRAS_EXPR}
WHEN ${ctx.esReceptor} AND ${IS_I_PPD_07} THEN ${SUM_E_REFERENCING_TRAS(esReceptorE, considerarActivos, considerarNCs)}
WHEN ${bucketAcreditableNeg(ctx)} THEN -(${IVA_TRAS_EXPR})
ELSE 0
END`;
};
const signedAcreditableRet = (
ctx: { esReceptor: string },
considerarActivos: boolean,
considerarNCs: boolean,
) => {
const esReceptorE = ctx.esReceptor.replace(/\brfc_(emisor|receptor)\b/g, 'e.rfc_$1');
return `CASE
WHEN ${bucketAcreditablePos(ctx)} THEN ${IVA_RET_EXPR}
WHEN ${ctx.esReceptor} AND ${IS_I_PPD_07} THEN ${SUM_E_REFERENCING_RET(esReceptorE, considerarActivos, considerarNCs)}
WHEN ${bucketAcreditableNeg(ctx)} THEN -(${IVA_RET_EXPR})
ELSE 0
END`;
};
// Regímenes que SIEMPRE restan deducciones para ISR, sin importar PF/PM.
const REGIMENES_RESTA_DEDUCCIONES = ['606', '612'];
/**
* Determina la fórmula de base gravable para un régimen fiscal dado el tipo
* de persona (PF o PM via rfcLength).
*
* - `ingresos-deducciones`: base = max(0, ingresos deducciones)
* - `ingresos`: base = max(0, ingresos) (tasa plana en RESICO PF)
*
* Los regímenes 606 (Arrendamiento) y 612 (PF Empresarial) siempre restan.
* El régimen 626 (RESICO) distingue por tipo de persona: PM (RFC 12) resta
* deducciones, PF (RFC 13) usa tasa plana sobre ingresos. Otros regímenes PM
* (601, 603, 607…) no restan aquí — sus deducciones se consideran vía el
* coeficiente de utilidad en el cálculo del ISR causado (Art. 14 LISR).
*
* Single source of truth — usada por `calcularResumenIsr` (KPI del periodo)
* y `getIsrMensual` (tabla histórico). Duplicar esto antes causó que el
* histórico mostrara `base = ingresos` para RESICO PM.
*/
export function determinarFormulaBaseGravable(
clave: string,
rfcLength: number,
): 'ingresos-deducciones' | 'ingresos' {
if (REGIMENES_RESTA_DEDUCCIONES.includes(clave)) return 'ingresos-deducciones';
if (clave === '626' && rfcLength === 12) return 'ingresos-deducciones';
return 'ingresos';
}
// Régimen 605 (Sueldos y Salarios): el patrón ya retuvo ISR, no genera
// ingreso/deducción para ISR del contribuyente. Se muestra en Dashboard
// como ingreso general, pero se excluye de cálculos de ISR.
const ISR_EXCLUIR_REGIMEN = `AND regimen_fiscal_emisor != '605'`;
const ISR_EXCLUIR_REGIMEN_REC = `AND regimen_fiscal_receptor != '605'`;
/**
* Lee IVA mensual agregado por mes desde metricas_mensuales. Solo aplica a
* años cerrados (< año actual) y contribuyente seleccionado. Agrega los 3
* campos canónicos: iva_trasladado_total, iva_acreditable, iva_retenido_cobrado
* (todos post-refactor, alineados con dashboard). Retorna null si no hay
* filas cacheadas (caller debe caer a on-the-fly).
*/
async function readIvaMensualFromCache(
pool: Pool,
año: number,
contribuyenteId: string,
): Promise<Map<number, { t: number; a: number; r: number }> | null> {
const safe = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
if (!safe) return null;
const { rows } = await pool.query<{ mes: number; t: string; a: string; r: string }>(`
SELECT mes,
COALESCE(SUM(iva_trasladado_total), 0)::numeric(14,2) as t,
COALESCE(SUM(iva_acreditable), 0)::numeric(14,2) as a,
COALESCE(SUM(iva_retenido_cobrado), 0)::numeric(14,2) as r
FROM metricas_mensuales
WHERE contribuyente_id = $1 AND anio = $2
GROUP BY mes
`, [safe, año]);
if (rows.length === 0) return null;
const map = new Map<number, { t: number; a: number; r: number }>();
for (const row of rows) {
map.set(Number(row.mes), { t: Number(row.t), a: Number(row.a), r: Number(row.r) });
}
return map;
}
/**
* IVA Mensual desglosado: trasladado, acreditable, retenido, resultado por mes.
*
* Usa la misma fórmula canónica que `getResumenIva` (6 buckets, retención neta):
* Trasladado = causado bruto (Emit+I+PUE + Emit+P + Recib+E+PUE)
* Acreditable = acreditable bruto (Recib+I+PUE + Recib+P + Emit+E+PUE)
* Retenido = retención(causado) retención(acreditable)
* Resultado = T A R
*
* Read-through cache: años pasados con contribuyente seleccionado leen de
* `metricas_mensuales`. El año actual y sin-contribuyente siguen on-the-fly.
*/
export async function getIvaMensual(
pool: Pool,
año: number,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<IvaMensual[]> {
// Cache read-through: solo si año pasado, sin conciliación, con contribuyente y flags default
const currentYear = new Date().getFullYear();
const cacheable =
process.env.METRICAS_BYPASS_CACHE !== '1' &&
año < currentYear &&
!conciliacion &&
considerarActivos &&
considerarNCs &&
!!contribuyenteId;
let perMes: Map<number, { t: number; a: number; r: number }> | null = null;
if (cacheable) {
perMes = await readIvaMensualFromCache(pool, año, contribuyenteId!);
}
if (!perMes) {
// On-the-fly: dos queries agregadas por mes (causado y acreditable),
// mismos buckets que getResumenIva. Filtro por RFC (ctx.esEmisor/esReceptor)
// en vez de `type` para evitar inconsistencias multi-contribuyente.
const FR = getFR(conciliacion);
const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId);
const REGIMEN_TENANT = regimenTenantExpr(ctx);
const añoStart = `${año}-01-01`;
const añoEnd = `${año}-12-31`;
const extra = buildExtraFilters(considerarActivos, considerarNCs);
const { rows: causadoRows } = await withJitOff(pool, (client) =>
client.query<{ mes: number; trasladado: string; retencion: string }>(`
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
FROM cfdis
WHERE ${bucketCausadoAny(ctx, considerarActivos, considerarNCs)}
AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY mes
`, [añoStart, añoEnd, TODOS_REGIMENES])
);
const { rows: acreditableRows } = await withJitOff(pool, (client) =>
client.query<{ mes: number; trasladado: string; retencion: string }>(`
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
FROM cfdis
WHERE ${bucketAcreditableAny(ctx, considerarActivos, considerarNCs)}
AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY mes
`, [añoStart, añoEnd, TODOS_REGIMENES])
);
perMes = new Map();
for (const row of causadoRows) {
const acc = perMes.get(Number(row.mes)) || { t: 0, a: 0, r: 0 };
acc.t += Number(row.trasladado);
acc.r += Number(row.retencion);
perMes.set(Number(row.mes), acc);
}
for (const row of acreditableRows) {
const acc = perMes.get(Number(row.mes)) || { t: 0, a: 0, r: 0 };
acc.a += Number(row.trasladado);
acc.r -= Number(row.retencion);
perMes.set(Number(row.mes), acc);
}
}
const result: IvaMensual[] = [];
let acumulado = 0;
for (let m = 1; m <= 12; m++) {
const monthData = perMes.get(m) || { t: 0, a: 0, r: 0 };
const t = monthData.t;
const a = monthData.a;
const r = monthData.r;
const resultado = t - a - r;
acumulado += resultado;
result.push({
id: 0,
año,
mes: m,
ivaTrasladado: t,
ivaAcreditable: a,
ivaRetenido: r,
resultado,
acumulado,
estado: 'pendiente',
fechaDeclaracion: null,
});
}
return result;
}
/**
* ISR Mensual desglosado: ingresos, deducciones, base gravable por mes del año.
*/
export async function getIsrMensual(
pool: Pool,
año: number,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
regimenClave?: string | null,
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<IsrMensual[]> {
// Reutiliza la misma lógica que las cards (calcular{Ingresos,Egresos}PorRegimen)
// aplicada a cada mes del año. Esto garantiza que la tabla "Histórico ISR" cuadre
// célula a célula con los KPIs del periodo activo (reglas por grupo de régimen,
// resta de notas de crédito, pagos P solo cuentan lo cobrado, etc.).
// El RFC del contribuyente determina si régimen 626 resta deducciones (PM) o no (PF).
const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId);
const rfcLength = ctx.rfcLength;
const result: IsrMensual[] = [];
for (let m = 1; m <= 12; m++) {
const lastDay = new Date(año, m, 0).getDate();
const mm = String(m).padStart(2, '0');
const dd = String(lastDay).padStart(2, '0');
const fi = `${año}-${mm}-01`;
const ff = `${año}-${mm}-${dd}`;
const [ingresosData, egresosData, ncsEmData, ncsRecData] = await Promise.all([
calcularIngresosPorRegimen(pool, tenantId, fi, ff, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
calcularEgresosPorRegimen(pool, tenantId, fi, ff, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
calcularNcsEmitidasPorRegimen(pool, tenantId, fi, ff, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
calcularNcsRecibidasPorRegimen(pool, tenantId, fi, ff, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
]);
let ing: number;
let ded: number;
let ncsEm: number;
let ncsRec: number;
let base: number;
if (regimenClave) {
ing = ingresosData.porRegimen.find(r => r.regimenClave === regimenClave)?.monto || 0;
ded = egresosData.porRegimen.find(r => r.regimenClave === regimenClave)?.monto || 0;
ncsEm = ncsEmData.porRegimen.find(r => r.regimenClave === regimenClave)?.monto || 0;
ncsRec = ncsRecData.porRegimen.find(r => r.regimenClave === regimenClave)?.monto || 0;
const formula = determinarFormulaBaseGravable(regimenClave, rfcLength);
base = formula === 'ingresos-deducciones' ? Math.max(0, ing - ded) : Math.max(0, ing);
} else {
// Sin régimen: agregar por régimen aplicando la fórmula correcta a cada
// uno y sumar las bases (no aplicar ing ded global, porque algunos
// regímenes no restan deducciones — ej. RESICO PF, otros PM).
const regimenesConDatos = new Set<string>([
...ingresosData.porRegimen.map(r => r.regimenClave).filter(c => c !== '605'),
...egresosData.porRegimen.map(r => r.regimenClave).filter(c => c !== '605'),
]);
ing = 0;
ded = 0;
ncsEm = ncsEmData.porRegimen.filter(r => r.regimenClave !== '605').reduce((s, r) => s + r.monto, 0);
ncsRec = ncsRecData.porRegimen.filter(r => r.regimenClave !== '605').reduce((s, r) => s + r.monto, 0);
base = 0;
for (const clave of regimenesConDatos) {
const ingReg = ingresosData.porRegimen.find(r => r.regimenClave === clave)?.monto || 0;
const dedReg = egresosData.porRegimen.find(r => r.regimenClave === clave)?.monto || 0;
const formula = determinarFormulaBaseGravable(clave, rfcLength);
ing += ingReg;
ded += dedReg;
base += formula === 'ingresos-deducciones'
? Math.max(0, ingReg - dedReg)
: Math.max(0, ingReg);
}
}
result.push({
id: 0,
año,
mes: m,
ingresosAcumulados: ing,
deducciones: ded,
baseGravable: base,
ncsEmitidas: ncsEm,
ncsRecibidas: ncsRec,
ncsEmitidasAcum: 0, // se llena en el segundo pase abajo
ncsRecibidasAcum: 0,
ingresosAcum: 0,
deduccionesAcum: 0,
baseGravableAcum: 0,
isrCausado: 0,
isrRetenido: 0,
isrAPagar: 0,
estado: 'pendiente',
fechaDeclaracion: null,
});
}
// Running totals: para cada mes, acumular ingresos y deducciones desde enero
// hasta ese mes inclusive. baseGravableAcum NO se clampa — los déficits se
// muestran negativos en la UI y solo se clampan al pasar a ISR causado.
let ingAcum = 0;
let dedAcum = 0;
let ncsEmAcum = 0;
let ncsRecAcum = 0;
for (const row of result) {
ingAcum += row.ingresosAcumulados; // (campo mensual, naming heredado)
dedAcum += row.deducciones;
ncsEmAcum += row.ncsEmitidas;
ncsRecAcum += row.ncsRecibidas;
row.ingresosAcum = ingAcum;
row.deduccionesAcum = dedAcum;
row.ncsEmitidasAcum = ncsEmAcum;
row.ncsRecibidasAcum = ncsRecAcum;
row.baseGravableAcum = ingAcum - dedAcum;
}
return result;
}
/**
* Read-through cache para ResumenIva: lee `iva_trasladado_total`,
* `iva_acreditable`, `iva_retenido_cobrado` desde `metricas_mensuales` cuando
* el rango cae en años pasados con contribuyente seleccionado. Calcula el
* acumulado anual on-the-fly (su rango difiere de fechaInicio-fechaFin).
*
* Si no hay filas cacheadas, retorna `null` y el caller cae al path on-the-fly.
*/
async function readResumenIvaFromCache(
pool: Pool,
range: CacheRange,
fechaInicio: string,
fechaFin: string,
conciliacion: boolean | undefined,
ctx: { esEmisor: string; esReceptor: string },
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<ResumenIva | null> {
const { rows } = await pool.query<{
regimen: string;
trasladado: string;
acreditable: string;
retenido: string;
}>(`
SELECT regimen_fiscal AS regimen,
COALESCE(SUM(iva_trasladado_total), 0)::numeric(14,2) AS trasladado,
COALESCE(SUM(iva_acreditable), 0)::numeric(14,2) AS acreditable,
COALESCE(SUM(iva_retenido_cobrado), 0)::numeric(14,2) AS retenido
FROM metricas_mensuales
WHERE contribuyente_id = $1
AND make_date(anio, mes, 1) BETWEEN $2::date AND $3::date
AND regimen_fiscal IS NOT NULL
GROUP BY regimen_fiscal
`, [range.contribuyenteId, range.startDate, range.endDate]);
if (rows.length === 0) return null;
const catalogo = await prisma.regimen.findMany({ where: { activo: true } });
const descMap = new Map(catalogo.map(r => [r.clave, r.descripcion]));
let trasladado = 0;
let acreditable = 0;
let retenido = 0;
const trasladadoPorRegimen: IvaRegimenDetalle[] = [];
const acreditablePorRegimen: IvaRegimenDetalle[] = [];
const retenidoPorRegimen: IvaRegimenDetalle[] = [];
for (const r of rows) {
const tras = Number(r.trasladado);
const acr = Number(r.acreditable);
const ret = Number(r.retenido);
trasladado += tras;
acreditable += acr;
retenido += ret;
const desc = descMap.get(r.regimen) || r.regimen;
if (tras !== 0) trasladadoPorRegimen.push({ regimenClave: r.regimen, regimenDescripcion: desc, monto: tras });
if (acr !== 0) acreditablePorRegimen.push({ regimenClave: r.regimen, regimenDescripcion: desc, monto: acr });
if (ret !== 0) retenidoPorRegimen.push({ regimenClave: r.regimen, regimenDescripcion: desc, monto: ret });
}
const resultado = trasladado - acreditable - retenido;
// Acumulado anual: su rango (year-01-01 → fechaFin) difiere del rango cacheado;
// se calcula on-the-fly contra raw cfdis. Una sola query.
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
const REGIMEN_TENANT = regimenTenantExpr(ctx);
const acumRow = (await withJitOff(pool, (client) =>
client.query(`
SELECT
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
(
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0)
) as total
FROM cfdis
WHERE ${VIGENTE}
AND (${REGIMEN_TENANT}) = ANY($3)
AND ${acumFR}
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])
)).rows[0];
// Cache hit retorna 0/empty para los surface IVA No Acreditable. El cache
// aún no persiste esos campos — si se hace crítico para BI, agregar columna
// `iva_no_acreditable_efectivo` a metricas_mensuales y poblarla en
// `metricas-compute.service.ts`.
return {
trasladado,
trasladadoPorRegimen,
acreditable,
acreditablePorRegimen,
retenido,
retenidoPorRegimen,
resultado,
acumuladoAnual: Number(acumRow?.total || 0),
ivaNoAcreditableEfectivo: 0,
ivaNoAcreditableEfectivoPorRegimen: [],
};
}
/**
* Resumen IVA para un rango de fechas, desglosado por régimen.
*
* Alineado con la fórmula del dashboard `calcularIvaBalancePorRegimen`:
* Resultado = Trasladado Acreditable Retenido
*
* Donde cada tarjeta usa los mismos 6 buckets del dashboard, pero con
* retención **separada** (en el dashboard la retención está embebida en
* el IVA neto de cada bucket; aquí se exhibe en su propia tarjeta para
* Control de Impuestos).
*
* Trasladado = causado bruto (Emit+I+PUE) + (Emit+P) + (Recib+E+PUE)
* Acreditable = acreditable bruto (Recib+I+PUE) + (Recib+P) + (Emit+E+PUE)
* Retenido = retención(causado) retención(acreditable)
*
* Algebraicamente: T A R == dashboard.balance, céntimo por céntimo.
*/
/**
* Ejecuta un callback con un client de pool con JIT desactivado (SET LOCAL jit = off).
* Usa una transacción implícita para que el SET LOCAL se restaure automáticamente
* al liberar la conexión. Esto evita que PostgreSQL compile JIT para queries con
* muchos subplans (correlacionados), lo cual puede tardar >15s en queries con
* costo estimado muy alto aunque la ejecución real sea rápida.
*/
async function withJitOff<T>(pool: Pool, fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('SET LOCAL jit = off');
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
throw e;
} finally {
client.release();
}
}
export async function getResumenIva(
pool: Pool,
fechaInicio: string,
fechaFin: string,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<ResumenIva> {
const FR = getFR(conciliacion);
const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId);
const REGIMEN_TENANT = regimenTenantExpr(ctx);
const extra = buildExtraFilters(considerarActivos, considerarNCs);
// Read-through cache (pasa ctx completo para derivar regimen + signed exprs).
// Solo aplica cuando flags son default (true) para garantizar que las queries
// filtradas no lean datos del caché completo.
const cacheRange =
considerarActivos && considerarNCs
? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId)
: null;
if (cacheRange) {
const cached = await readResumenIvaFromCache(pool, cacheRange, fechaInicio, fechaFin, conciliacion, ctx, considerarActivos, considerarNCs);
if (cached) return cached;
}
// Queries con JIT off: evitan compilación JIT de >15s en queries con muchos
// subplans correlacionados (activado por costo estimado >100k).
const { rows: causadoRows } = await withJitOff(pool, (client) =>
client.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
SELECT ${REGIMEN_TENANT} as regimen,
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
FROM cfdis
WHERE ${bucketCausadoAny(ctx, considerarActivos, considerarNCs)}
AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY ${REGIMEN_TENANT}
`, [fechaInicio, fechaFin, TODOS_REGIMENES])
);
const { rows: acreditableRows } = await withJitOff(pool, (client) =>
client.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
SELECT ${REGIMEN_TENANT} as regimen,
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
FROM cfdis
WHERE ${bucketAcreditableAny(ctx, considerarActivos, considerarNCs)}
AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY ${REGIMEN_TENANT}
`, [fechaInicio, fechaFin, TODOS_REGIMENES])
);
// Combinar por régimen: el set de régimenes posibles es la unión de ambos lados.
type Acc = { trasCausado: number; retCausado: number; trasAcreditable: number; retAcreditable: number };
const porRegimen = new Map<string, Acc>();
const ensure = (k: string): Acc => {
let v = porRegimen.get(k);
if (!v) { v = { trasCausado: 0, retCausado: 0, trasAcreditable: 0, retAcreditable: 0 }; porRegimen.set(k, v); }
return v;
};
for (const r of causadoRows) {
if (!r.regimen) continue;
const acc = ensure(r.regimen);
acc.trasCausado += Number(r.trasladado);
acc.retCausado += Number(r.retencion);
}
for (const r of acreditableRows) {
if (!r.regimen) continue;
const acc = ensure(r.regimen);
acc.trasAcreditable += Number(r.trasladado);
acc.retAcreditable += Number(r.retencion);
}
const catalogo = await prisma.regimen.findMany({ where: { activo: true } });
const descMap = new Map(catalogo.map(r => [r.clave, r.descripcion]));
let trasladado = 0;
let acreditable = 0;
let retenido = 0;
const trasladadoPorRegimen: IvaRegimenDetalle[] = [];
const acreditablePorRegimen: IvaRegimenDetalle[] = [];
const retenidoPorRegimen: IvaRegimenDetalle[] = [];
for (const [regimen, acc] of porRegimen) {
const tras = acc.trasCausado;
const acre = acc.trasAcreditable;
const ret = acc.retCausado - acc.retAcreditable;
trasladado += tras;
acreditable += acre;
retenido += ret;
const desc = descMap.get(regimen) || regimen;
if (tras !== 0) trasladadoPorRegimen.push({ regimenClave: regimen, regimenDescripcion: desc, monto: tras });
if (acre !== 0) acreditablePorRegimen.push({ regimenClave: regimen, regimenDescripcion: desc, monto: acre });
if (ret !== 0) retenidoPorRegimen.push({ regimenClave: regimen, regimenDescripcion: desc, monto: ret });
}
const resultado = trasladado - acreditable - retenido;
// Acumulado anual (misma fórmula T A R, pero rango = enero → fechaFin).
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
const { rows: [acumRow] } = await withJitOff(pool, (client) =>
client.query(`
SELECT
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
(
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0)
) as total
FROM cfdis
WHERE ${VIGENTE}
AND (${REGIMEN_TENANT}) = ANY($3)
AND ${acumFR}${extra}
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])
);
// IVA No Acreditable surface (Art. 5 LIVA fracción I + Art. 27 fracción III LISR).
// No participa en `resultado` — ya excluido del `acreditable` arriba via filtro
// en `bucketAcreditablePos`. Aquí solo se exhibe el monto excluido para el contador.
const noAcreditableData = await calcularIvaNoAcreditableEfectivoPorRegimen(
pool, tenantId, fechaInicio, fechaFin, undefined, undefined,
conciliacion, contribuyenteId, considerarActivos, considerarNCs,
);
return {
trasladado,
trasladadoPorRegimen,
acreditable,
acreditablePorRegimen,
retenido,
retenidoPorRegimen,
resultado,
acumuladoAnual: Number(acumRow?.total || 0),
ivaNoAcreditableEfectivo: noAcreditableData.total,
ivaNoAcreditableEfectivoPorRegimen: noAcreditableData.porRegimen,
};
}
/**
* Calcula ISR progresivo según tarifa del Art. 96
*/
async function calcularIsrProgresivo(baseGravable: number, anio: number): Promise<number> {
if (baseGravable <= 0) return 0;
const tarifas = await prisma.isrTarifa.findMany({
where: { anio },
orderBy: { limiteInferior: 'asc' },
});
if (tarifas.length === 0) return 0;
// Encontrar el rango correcto
let tarifa = tarifas[tarifas.length - 1]; // default: último rango
for (const t of tarifas) {
const ls = t.limiteSuperior ? Number(t.limiteSuperior) : Infinity;
if (baseGravable >= Number(t.limiteInferior) && baseGravable <= ls) {
tarifa = t;
break;
}
}
const excedente = baseGravable - Number(tarifa.limiteInferior);
const impuestoMarginal = excedente * (Number(tarifa.porcentajeExcedente) / 100);
return Number(tarifa.cuotaFija) + impuestoMarginal;
}
/**
* Calcula ISR RESICO PF según Art. 113-E
*/
async function calcularIsrResicoPF(ingresos: number, anio: number): Promise<number> {
if (ingresos <= 0) return 0;
const tasas = await prisma.isrResicoTasa.findMany({
where: { anio },
orderBy: { montoMaximo: 'asc' },
});
if (tasas.length === 0) return 0;
for (const t of tasas) {
if (ingresos <= Number(t.montoMaximo)) {
return ingresos * (Number(t.porcentaje) / 100);
}
}
// Si supera todos los rangos, usar el último
const ultima = tasas[tasas.length - 1];
return ingresos * (Number(ultima.porcentaje) / 100);
}
/**
* Resumen ISR con cálculo por régimen, coeficiente de utilidad, ISR progresivo/RESICO.
*/
export async function getResumenIsr(
pool: Pool,
fechaInicio: string,
fechaFin: string,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<ResumenIsr> {
const FR = getFR(conciliacion);
const extra = buildExtraFilters(considerarActivos, considerarNCs);
// Ingresos + Deducciones + NCs (emitidas/recibidas) + Gastos no deducibles
// (efectivo > $2k) en paralelo. Las NCs se necesitan en el cálculo de base
// gravable para regímenes con fórmula `ingresos-deducciones` (ver loop abajo).
const [ingresosData, egresosData, ncsEmitidasData, ncsRecibidasData, gastosNoDedData] = await Promise.all([
calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
calcularNcsEmitidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
calcularNcsRecibidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
calcularGastosNoDeduciblesEfectivoPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
]);
// RFC del contribuyente (o tenant) para determinar persona moral/física
const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId);
const rfcLength = ctx.rfcLength;
// Base gravable por régimen
const baseGravablePorRegimen: import('@horux/shared').BaseGravableRegimen[] = [];
// Todos los regímenes que tienen ingresos/egresos/NCs (excluir 605 — sueldos,
// ISR retenido por patrón)
const regimenesConDatos = new Set([
...ingresosData.porRegimen.map(r => r.regimenClave).filter(c => c !== '605'),
...egresosData.porRegimen.map(r => r.regimenClave).filter(c => c !== '605'),
...ncsEmitidasData.porRegimen.map(r => r.regimenClave).filter(c => c !== '605'),
...ncsRecibidasData.porRegimen.map(r => r.regimenClave).filter(c => c !== '605'),
]);
const catalogo = await prisma.regimen.findMany({ where: { activo: true } });
const descMap = new Map(catalogo.map(r => [r.clave, r.descripcion]));
for (const clave of regimenesConDatos) {
const ing = ingresosData.porRegimen.find(r => r.regimenClave === clave)?.monto || 0;
const ded = egresosData.porRegimen.find(r => r.regimenClave === clave)?.monto || 0;
const ncsEm = ncsEmitidasData.porRegimen.find(r => r.regimenClave === clave)?.monto || 0;
const ncsRec = ncsRecibidasData.porRegimen.find(r => r.regimenClave === clave)?.monto || 0;
const formula = determinarFormulaBaseGravable(clave, rfcLength);
// Para `ingresos-deducciones` (606, 612, 626 RESICO PM): la base gravable
// ajusta por NCs de ambos lados:
// ingresoNeto = ingresosNominales ncsEmitidas
// deduccionNeta = deducciones ncsRecibidas
// base = max(0, ingresoNeto deduccionNeta)
// = max(0, ingNominales ncsEm ded + ncsRec)
// Para `ingresos` (RESICO PF, RIF, Plataformas, PMs Grupo 3): no se aplica
// ajuste de NCs — esos regímenes no restan deducciones aquí (ver
// determinarFormulaBaseGravable).
const baseGravable = formula === 'ingresos-deducciones'
? Math.max(0, ing - ncsEm - ded + ncsRec)
: Math.max(0, ing);
if (baseGravable !== 0 || ing !== 0) {
baseGravablePorRegimen.push({
regimenClave: clave,
regimenDescripcion: descMap.get(clave) || clave,
ingresos: ing,
deducciones: formula === 'ingresos-deducciones' ? ded : 0,
baseGravable,
isrCausado: 0, // calculated below
formula,
});
}
}
// Exclude 605 from ISR totals (sueldos — ISR already withheld by employer)
const ingresosPorRegimen = ingresosData.porRegimen
.filter(r => r.regimenClave !== '605')
.map(r => ({ regimenClave: r.regimenClave, regimenDescripcion: r.regimenDescripcion, monto: r.monto }));
const ingresosAcumulados = ingresosPorRegimen.reduce((s, r) => s + r.monto, 0);
const deduccionesPorRegimen = egresosData.porRegimen
.filter(r => r.regimenClave !== '605')
.map(r => ({ regimenClave: r.regimenClave, regimenDescripcion: r.regimenDescripcion, monto: r.monto }));
const deducciones = deduccionesPorRegimen.reduce((s, r) => s + r.monto, 0);
const baseGravableTotal = baseGravablePorRegimen.reduce((s, r) => s + r.baseGravable, 0);
// ISR Retenido — filtro por RFC del contribuyente (cualquier lado).
const { rows: [retRow] } = await pool.query(`
SELECT COALESCE(SUM(COALESCE(isr_retencion_mxn,0) - (${EXCL_ISR_RET})), 0) as total
FROM cfdis
WHERE ${VIGENTE} AND ${FR}${extra}
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
`, [fechaInicio, fechaFin]);
// ISR Causado por régimen
const anio = new Date(fechaFin + 'T00:00:00').getFullYear();
// Coeficiente de utilidad del tenant (para PM y otros)
const coefData = await prisma.coeficienteUtilidad.findUnique({
where: { tenantId_anio: { tenantId, anio } },
});
const coeficiente = coefData ? Number(coefData.coeficiente) : 0;
let isrCausado = 0;
for (const reg of baseGravablePorRegimen) {
let regIsrCausado = 0;
if (reg.regimenClave === '626' && rfcLength === 13) {
// RESICO PF: tasa plana por bracket (Art. 113-E LISR)
regIsrCausado = await calcularIsrResicoPF(reg.baseGravable, anio);
} else if (reg.regimenClave === '626' && rfcLength === 12) {
// 626 RESICO PM: tasa fija 30% directa sobre la base gravable.
// Comparte la fórmula de base gravable con PF Empresarial
// (`ingresos ncsEm ded + ncsRec`) pero NO usa Art. 96 ni
// coeficiente de utilidad — aplicación directa del 30% por decisión
// del cliente (2026-05-02).
regIsrCausado = reg.baseGravable * 0.30;
} else if (['606', '612', '621', '625'].includes(reg.regimenClave)) {
// PF Empresarial: tarifa progresiva Art. 96
regIsrCausado = await calcularIsrProgresivo(reg.baseGravable, anio);
} else {
// PM Grupo 3: base × coeficiente × tasa (30%)
const basePM = reg.baseGravable * (coeficiente || 0.30);
regIsrCausado = basePM * 0.30;
}
reg.isrCausado = Math.round(regIsrCausado * 100) / 100;
isrCausado += regIsrCausado;
}
const isrRetenido = Number(retRow?.total || 0);
const isrAPagar = Math.max(0, isrCausado - isrRetenido);
// ncsEmitidasData + ncsRecibidasData se calcularon up-front en el Promise.all
// de arriba (necesarias para la fórmula de base gravable). Aquí solo se
// exponen en la respuesta para los KPIs surface-only "NCs Emitidas" /
// "NCs Recibidas".
return {
ingresosAcumulados,
ingresosPorRegimen,
deducciones,
deduccionesPorRegimen,
baseGravable: baseGravableTotal,
baseGravablePorRegimen,
isrCausado: Math.round(isrCausado * 100) / 100,
isrRetenido,
isrAPagar: Math.round(isrAPagar * 100) / 100,
ncsEmitidas: ncsEmitidasData.total,
ncsEmitidasPorRegimen: ncsEmitidasData.porRegimen,
ncsRecibidas: ncsRecibidasData.total,
ncsRecibidasPorRegimen: ncsRecibidasData.porRegimen,
gastosNoDeduciblesEfectivo: gastosNoDedData.total,
gastosNoDeduciblesEfectivoPorRegimen: gastosNoDedData.porRegimen,
};
}
/**
* Desglose del cálculo provisional ISR para el mes final del filtro.
*
* Tres llamadas a getResumenIsr con rangos distintos:
* - delPeriodo: solo el mes final del filtro (1 mes calendario)
* - anteriores: enero hasta el mes anterior al final (vacío si mesFinal=1)
* - total: enero hasta el mes final inclusive
*
* Si mesFinal === 1, la rama "anteriores" no llama al backend — retorna ceros
* para evitar un query inútil.
*/
export async function getResumenIsrDesglosado(
pool: Pool,
fechaFin: string,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<import('@horux/shared').ResumenIsrDesglosado> {
const fechaFinDate = new Date(fechaFin + 'T00:00:00');
const anio = fechaFinDate.getFullYear();
const mesFinal = fechaFinDate.getMonth() + 1; // 1-12
// Helper para construir rango fin de mes
const mmFinal = String(mesFinal).padStart(2, '0');
const ultDiaFinal = new Date(anio, mesFinal, 0).getDate();
const ultDiaFinalStr = String(ultDiaFinal).padStart(2, '0');
// delPeriodo: 1er a último día del mes final
const fiPeriodo = `${anio}-${mmFinal}-01`;
const ffPeriodo = `${anio}-${mmFinal}-${ultDiaFinalStr}`;
// anteriores: enero 1 al último día del (mesFinal - 1). Vacío si mesFinal=1.
let anteriores: import('@horux/shared').ResumenIsr;
if (mesFinal === 1) {
anteriores = emptyResumenIsr();
} else {
const mesAntes = mesFinal - 1;
const mmAntes = String(mesAntes).padStart(2, '0');
const ultDiaAntes = new Date(anio, mesAntes, 0).getDate();
const ultDiaAntesStr = String(ultDiaAntes).padStart(2, '0');
const fiAnt = `${anio}-01-01`;
const ffAnt = `${anio}-${mmAntes}-${ultDiaAntesStr}`;
anteriores = await getResumenIsr(pool, fiAnt, ffAnt, tenantId, conciliacion, contribuyenteId, considerarActivos, considerarNCs);
}
// total: enero 1 al último día del mes final
const fiTotal = `${anio}-01-01`;
const ffTotal = `${anio}-${mmFinal}-${ultDiaFinalStr}`;
const [delPeriodo, total] = await Promise.all([
getResumenIsr(pool, fiPeriodo, ffPeriodo, tenantId, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
getResumenIsr(pool, fiTotal, ffTotal, tenantId, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
]);
return { delPeriodo, anteriores, total, mesFinal, anio };
}
function emptyResumenIsr(): import('@horux/shared').ResumenIsr {
return {
ingresosAcumulados: 0,
ingresosPorRegimen: [],
deducciones: 0,
deduccionesPorRegimen: [],
baseGravable: 0,
baseGravablePorRegimen: [],
isrCausado: 0,
isrRetenido: 0,
isrAPagar: 0,
ncsEmitidas: 0,
ncsEmitidasPorRegimen: [],
ncsRecibidas: 0,
ncsRecibidasPorRegimen: [],
gastosNoDeduciblesEfectivo: 0,
gastosNoDeduciblesEfectivoPorRegimen: [],
};
}
/**
* Obtener coeficiente de utilidad del tenant para un año.
*/
export async function getCoeficiente(tenantId: string, anio: number) {
const data = await prisma.coeficienteUtilidad.findUnique({
where: { tenantId_anio: { tenantId, anio } },
});
return { anio, coeficiente: data ? Number(data.coeficiente) : null };
}
/**
* Establecer coeficiente de utilidad del tenant para un año.
*/
export async function setCoeficiente(tenantId: string, anio: number, coeficiente: number) {
const data = await prisma.coeficienteUtilidad.upsert({
where: { tenantId_anio: { tenantId, anio } },
update: { coeficiente },
create: { tenantId, anio, coeficiente },
});
return { anio: data.anio, coeficiente: Number(data.coeficiente) };
}