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 | 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(); 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 { // 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 | 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 { // 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([ ...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 { 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(pool: Pool, fn: (client: PoolClient) => Promise): Promise { 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 { 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(); 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 { 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 { 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 { 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 { 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) }; }