- 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
1194 lines
51 KiB
TypeScript
1194 lines
51 KiB
TypeScript
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) };
|
||
}
|