import type { Pool } from 'pg'; import type { KpiData, IngresoRegimen, EgresoRegimen, IvaBalanceRegimen, IngresosEgresosData, ResumenFiscal, Alerta } from '@horux/shared'; import { getRegimenesIgnoradosClaves } from './regimen.service.js'; import { prisma } from '../config/database.js'; import { planCache, type CacheRange } from '../utils/metricas-cache.js'; import { resolveContribuyenteContext } from '../utils/contribuyente-context.js'; import { buildExtraFilters } from './_shared/cfdi-filters.js'; // Status vigente const VIGENTE = `status NOT IN ('Cancelado', '0')`; // Impuestos trasladados del comprobante const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`; // Impuestos trasladados del pago. // El IVA se clampa a `monto_pago_mxn × 0.16` (tasa SAT máxima) como defensa // contra XMLs malformados donde el proveedor reporta el IVA de la factura // original completa en vez del proporcional al pago parcial. Caso real: // CFDI 079ace7d con monto_pago=$43,611 e iva_traslado_pago=$30,076 cuando // el proporcional sería ~$6,017. IEPS NO se clampa (rates van hasta 53%). const IVA_TRAS_PAGO_CLAMPED = `LEAST(COALESCE(iva_traslado_pago_mxn, 0), COALESCE(monto_pago_mxn, 0) * 0.16)`; const IVA_RET_PAGO_CLAMPED = `LEAST(COALESCE(iva_retencion_pago_mxn, 0), COALESCE(monto_pago_mxn, 0) * 0.16)`; const IMP_TRAS_PAGO = `${IVA_TRAS_PAGO_CLAMPED} + COALESCE(ieps_traslado_pago_mxn, 0)`; // Claves de producto/servicio excluidas de cálculos fiscales const CLAVES_EXCLUIDAS = `('84121603','93161608','85101501','85121800')`; // Art. 27 fracción III LISR — gastos > $2,000 pagados en efectivo NO son // deducibles. Aplica al lado RECEPTOR. Se filtran de las deducciones y se // surface aparte en card "No Deducibles". // // Para CFDIs tipo I PUE: el monto a comparar es `total_mxn`. // Para complementos P: el monto a comparar es `monto_pago_mxn` (cada P es // pago independiente; un P de $3k efectivo aplicado a una I PPD bloquea // solo esos $3k, no la I PPD entera). // // `forma_pago = '01'` = Efectivo (catálogo SAT c_FormaPago). // // EXPORTADOS — `impuestos.service.ts` los reutiliza para excluir el IVA // acreditable de esos mismos gastos (Art. 5 LIVA fracción I: el IVA solo es // acreditable si el gasto cumple los requisitos de deducibilidad ISR). // // IMPORTANTE: COALESCE(forma_pago, '') hace el predicado NULL-safe. Sin esto, // `forma_pago = '01'` retorna NULL cuando forma_pago es NULL, y `NOT (NULL)` // también es NULL — Postgres trata WHERE NULL como exclusión del row. Eso // haría que TODOS los CFDIs sin forma_pago se excluyeran de las deducciones // vía `AND NOT NO_DEDUCIBLE_EFECTIVO_*`. Los CFDIs de complemento P sin // forma_pago explícito son comunes; sin el COALESCE, deducciones colapsa. export const NO_DEDUCIBLE_EFECTIVO_I_PUE = `(COALESCE(forma_pago, '') = '01' AND COALESCE(total_mxn, 0) > 2000)`; export const NO_DEDUCIBLE_EFECTIVO_P = `(COALESCE(forma_pago, '') = '01' AND COALESCE(monto_pago_mxn, 0) > 2000)`; // Subtotal de conceptos excluidos por CFDI (importe - descuento) const EXCL_MONTO = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0) - COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS}), 0)`; // IVA trasladado de conceptos excluidos 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)`; // IVA retenido de conceptos excluidos 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)`; // IVA neto excluido (trasladado - retenido) const EXCL_IVA_NETO = `(${EXCL_IVA_TRAS}) - (${EXCL_IVA_RET})`; // "Total sin impuestos" custom usado para I/07 (aplicación de anticipo) en // Grupo 1. Fórmula: total − traslados + retenciones. Semánticamente // representa la base gravable del anticipo considerando que las retenciones // sí son ingreso (aunque el retenedor se las haya quedado). const NETO_CUSTOM = (alias: string) => `( COALESCE(${alias}.total_mxn, 0) - COALESCE(${alias}.iva_traslado_mxn, 0) + COALESCE(${alias}.iva_retencion_mxn, 0) + COALESCE(${alias}.isr_retencion_mxn, 0) - COALESCE(${alias}.ieps_traslado_mxn, 0) + COALESCE(${alias}.ieps_retencion_mxn, 0) - COALESCE(${alias}.impuestos_locales_trasladado_mxn, 0) + COALESCE(${alias}.impuestos_locales_retenidos_mxn, 0) )`; // EXCL_MONTO parametrizado por alias — para aplicarlo tanto al CFDI base // como a las facturas relacionadas dentro de un subquery. const EXCL_MONTO_ALIAS = (alias: string) => `COALESCE(( SELECT SUM(COALESCE(cc.importe_mxn, 0) - COALESCE(cc.descuento_mxn, 0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = ${alias}.id AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS} ), 0)`; // EXCL_IVA parametrizado por alias (para compensación I/07 en IVA). 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 EXCL_IVA_NETO_ALIAS = (alias: string) => `((${EXCL_IVA_TRAS_ALIAS(alias)}) - (${EXCL_IVA_RET_ALIAS(alias)}))`; // IVA neto por fila, parametrizado por alias (iva_traslado - iva_retencion). const IVA_NETO_ALIAS = (alias: string) => `(COALESCE(${alias}.iva_traslado_mxn, 0) - COALESCE(${alias}.iva_retencion_mxn, 0))`; // Grupos de regímenes por lógica de cálculo export const GRUPO_PF_EMPRESARIAL = ['606', '612', '621', '625', '626']; export const GRUPO_SUELDOS = ['605']; export const GRUPO_PM_OTROS = ['601', '603', '607', '608', '610', '611', '614', '615', '620', '622', '623', '624']; const TODOS_REGIMENES = [...GRUPO_PF_EMPRESARIAL, ...GRUPO_SUELDOS, ...GRUPO_PM_OTROS]; // Filtro de fecha por rango — normal o conciliación const FECHA_RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`; // Para CFDIs tipo P (complementos de pago): el ingreso/gasto se reconoce en la // fecha_pago_p (cuándo el cliente realmente pagó), no cuando se emitió el // complemento — el CFDI P puede emitirse hasta el día 5 del mes siguiente al // pago, o incluso después, y cruzar meses (ej. pago de noviembre 2024 con // complemento emitido en mayo 2025). const FECHA_PAGO_RANGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($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 getFechaRango(conciliacion?: boolean): string { return conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO; } /** Igual que getFechaRango pero para CFDIs tipo P: filtra por fecha_pago_p. */ function getFechaPagoRango(conciliacion?: boolean): string { return conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_PAGO_RANGO; } /** * Filtro de contribuyente **inclusivo**: matchea por `contribuyente_id` * directo O por RFC en cualquiera de los dos lados (emisor/receptor). * * Necesario porque cuando dos contribuyentes del mismo tenant tienen * relación emisor-receptor, el sync SAT del primero inserta el CFDI con * su `contribuyente_id` y el sync del segundo (UPSERT) NO actualiza el * campo. Resultado: el CFDI queda asignado al primer contribuyente aunque * desde la perspectiva del segundo es "su" CFDI con el `type` inverso. * * Solución: en vez de filtrar `contribuyente_id = X`, filtrar por RFC del * contribuyente en ambos lados. El `type` del CFDI + el lado del query * (EMITIDO vs RECIBIDO) ya determina si es ingreso o gasto de ese * contribuyente — no requiere `contribuyente_id` para la correcta * atribución. * * Devuelve fragmento SQL con `AND` prefijo; string vacío si no hay * `contribuyenteId` provisto. */ async function getContribFilter(pool: Pool, contribuyenteId: string | null | undefined): Promise { if (!contribuyenteId) return ''; const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); if (!safeId) return ''; const { rows } = await pool.query<{ rfc: string | null }>( `SELECT rfc FROM contribuyentes WHERE entidad_id = $1`, [safeId], ); if (rows.length === 0 || !rows[0].rfc) { // Fallback: solo contribuyente_id (contribuyente sin RFC registrado). return `AND contribuyente_id = '${safeId}'`; } const rfc = rows[0].rfc.replace(/[^A-Z0-9]/gi, '').toUpperCase(); return `AND (contribuyente_id = '${safeId}' OR UPPER(rfc_emisor) = '${rfc}' OR UPPER(rfc_receptor) = '${rfc}')`; } /** * Calcula "Ingresos del Mes" desglosados por régimen fiscal. */ async function getDescMap(cache?: Map): Promise> { if (cache) return cache; const catalogo = await prisma.regimen.findMany({ where: { activo: true } }); return new Map(catalogo.map(r => [r.clave, r.descripcion])); } async function getIgnorados(tenantId: string, cache?: string[]): Promise { if (cache) return cache; return getRegimenesIgnoradosClaves(tenantId); } // ──────────────────────────────────────────────────────────────────── // Read-through cache (Tanda B hot/cold) // // Para contribuyentes con datos en `metricas_mensuales`, un rango que cubre // meses completos dentro de años pasados (previos al actual) se puede leer // directo de la tabla en vez de recomputar desde raw CFDIs. El año actual y // los rangos parciales siguen on-the-fly. // // Requisitos para usar cache: // - `contribuyenteId` presente (sin él no hay filas en la tabla) // - `conciliacion` desactivada (la tabla guarda flujo normal, no usa id_conciliacion) // - `fechaFin` antes del primer día del año actual // - `fechaInicio` es día 1 del mes, `fechaFin` es último día del mes (rango // de meses completos; parciales no mapean a filas de la tabla) // ──────────────────────────────────────────────────────────────────── /** Lee ingresos_cobrados agregados por régimen desde metricas_mensuales. */ async function readIngresosFromCache( pool: Pool, range: CacheRange, ignorados: string[], descMap: Map, ): Promise<{ total: number; porRegimen: IngresoRegimen[] } | null> { const { rows } = await pool.query<{ regimen: string; monto: string; rows_n: string }>(` SELECT regimen_fiscal AS regimen, COALESCE(SUM(ingresos_cobrados), 0)::numeric(14,2) AS monto, COUNT(*) AS rows_n 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]); // Si no hay filas cacheadas, señal al caller de hacer fallback on-the-fly. if (rows.length === 0) return null; const porRegimen: IngresoRegimen[] = []; for (const r of rows) { if (ignorados.includes(r.regimen)) continue; const monto = Number(r.monto); if (monto !== 0) { porRegimen.push({ regimenClave: r.regimen, regimenDescripcion: descMap.get(r.regimen) || r.regimen, monto, }); } } return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; } /** Lee egresos_pagados agregados por régimen desde metricas_mensuales. */ async function readEgresosFromCache( pool: Pool, range: CacheRange, ignorados: string[], descMap: Map, ): Promise<{ total: number; porRegimen: EgresoRegimen[] } | null> { const { rows } = await pool.query<{ regimen: string; monto: string }>(` SELECT regimen_fiscal AS regimen, COALESCE(SUM(egresos_pagados), 0)::numeric(14,2) AS monto 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 porRegimen: EgresoRegimen[] = []; for (const r of rows) { if (ignorados.includes(r.regimen)) continue; const monto = Number(r.monto); if (monto !== 0) { porRegimen.push({ regimenClave: r.regimen, regimenDescripcion: descMap.get(r.regimen) || r.regimen, monto, }); } } return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; } /** * Lee IVA balance por régimen desde metricas_mensuales. * Fórmula: monto = iva_trasladado_total − iva_acreditable − iva_retenido_cobrado * (alineada con impuestos.resultado tras el refactor que separó retención). */ async function readIvaBalanceFromCache( pool: Pool, range: CacheRange, ignorados: string[], descMap: Map, ): Promise<{ total: number; porRegimen: IvaBalanceRegimen[] } | null> { const { rows } = await pool.query<{ regimen: string; causado: string; acreditable: string; retenido: string }>(` SELECT regimen_fiscal AS regimen, COALESCE(SUM(iva_trasladado_total), 0)::numeric(14,2) AS causado, 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 porRegimen: IvaBalanceRegimen[] = []; for (const r of rows) { if (ignorados.includes(r.regimen)) continue; const causado = Number(r.causado); const acreditable = Number(r.acreditable); const retenido = Number(r.retenido); const monto = causado - acreditable - retenido; if (monto !== 0 || causado !== 0 || acreditable !== 0 || retenido !== 0) { porRegimen.push({ regimenClave: r.regimen, regimenDescripcion: descMap.get(r.regimen) || r.regimen, monto, }); } } return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; } export async function calcularIngresosPorRegimen( pool: Pool, tenantId: string, fechaInicio: string, fechaFin: string, _ignorados?: string[], _descMap?: Map, conciliacion?: boolean, contribuyenteId?: string | null, considerarActivos: boolean = true, considerarNCs: boolean = true, ): Promise<{ total: number; porRegimen: IngresoRegimen[] }> { const ignorados = await getIgnorados(tenantId, _ignorados); const descMap = await getDescMap(_descMap); // Read-through cache: si el rango cae en años pasados con meses completos // y hay un contribuyente seleccionado, lee de metricas_mensuales. Si hit, // retorna de inmediato (evita ~3 queries SQL por régimen). Solo aplica // cuando los toggles están en default (true) — el cache se escribió con // esos valores y aplicar otra combinación devolvería datos stale. const cacheRange = considerarActivos && considerarNCs ? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId) : null; if (cacheRange) { const cached = await readIngresosFromCache(pool, cacheRange, ignorados, descMap); if (cached) return cached; } const FR = getFechaRango(conciliacion); const FR_PAGO = getFechaPagoRango(conciliacion); const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); const esEmisor = ctx.esEmisor; const esReceptor = ctx.esReceptor; const extra = buildExtraFilters(considerarActivos, considerarNCs); const porRegimen: IngresoRegimen[] = []; // ─── GRUPO 1: PF Empresarial (606, 612, 621, 625, 626) ─── // Suman I PUE + P (pagos) + I/07 PPD compensación. Las notas de crédito tipo E // se contabilizan del lado del receptor (gastos) y se exhiben aparte como // "Egresos Emitidos" en /impuestos > ISR (surface-only) — no restan aquí. // // I/07 PPD compensación (lado EMISOR): cuando el contribuyente emite I/07 PPD // (aplicación de anticipo) y emite también una E en el mismo mes/año cuya // cfdis_relacionados contiene esa I/07 PPD, la I/07 PPD aporta el equivalente // de la base de la E. Se preserva por interpretación fiscal explícita aunque // la E ya no se reste — refleja que la porción del servicio asociada al // anticipo se reconoce como ingreso al emitir la I/07 PPD. // // Filtro por RFC del emisor (`${esEmisor}`) en vez de `type='EMITIDO' AND // contribuyente_id=X` — el RFC es fuente de verdad, type/contribuyente_id // pueden ser inconsistentes cuando dos contribuyentes del tenant se facturan. const { rows: g1Facturas } = await pool.query(` SELECT regimen_fiscal_emisor as regimen, COALESCE(SUM(COALESCE(total_mxn, 0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto FROM cfdis WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' AND ${VIGENTE} AND ${FR}${extra} AND regimen_fiscal_emisor = ANY($3) GROUP BY regimen_fiscal_emisor `, [fechaInicio, fechaFin, GRUPO_PF_EMPRESARIAL]); const { rows: g1Pagos } = await pool.query(` SELECT regimen_fiscal_emisor as regimen, COALESCE(SUM(COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO})), 0) as monto FROM cfdis WHERE ${esEmisor} AND tipo_comprobante = 'P' AND ${VIGENTE} AND ${FR_PAGO}${extra} AND regimen_fiscal_emisor = ANY($3) GROUP BY regimen_fiscal_emisor `, [fechaInicio, fechaFin, GRUPO_PF_EMPRESARIAL]); // NOTA: la compensación I/07 PPD ↔ E se eliminó por decisión del cliente // (2026-05-02). No es un cálculo oficial del SAT y confundía a contadores — // el contador hace la conciliación manual del ciclo anticipo→I/07 PPD→E si // aplica. Mantener esta nota para evitar reintroducirla por intuición fiscal. for (const clave of GRUPO_PF_EMPRESARIAL) { if (ignorados.includes(clave)) continue; const facturas = Number(g1Facturas.find((r: any) => r.regimen === clave)?.monto || 0); const pagos = Number(g1Pagos.find((r: any) => r.regimen === clave)?.monto || 0); const monto = facturas + pagos; if (monto !== 0 || facturas !== 0 || pagos !== 0) { porRegimen.push({ regimenClave: clave, regimenDescripcion: descMap.get(clave) || clave, monto }); } } // ─── GRUPO 2: Sueldos y Salarios (605) ─── // Nómina recibida por el contribuyente (lado RECEPTOR). if (!ignorados.includes('605')) { const { rows: g2 } = await pool.query(` SELECT COALESCE(SUM(COALESCE(total_mxn,0)), 0) as monto FROM cfdis WHERE ${esReceptor} AND tipo_comprobante = 'N' AND metodo_pago = 'PUE' AND ${VIGENTE} AND ${FR}${extra} AND regimen_fiscal_receptor = '605' `, [fechaInicio, fechaFin]); const monto = Number(g2[0]?.monto || 0); if (monto !== 0) { porRegimen.push({ regimenClave: '605', regimenDescripcion: descMap.get('605') || 'Sueldos y Salarios', monto }); } } // ─── GRUPO 3: Resto de regímenes (PM y otros) ─── // Suman I (PUE+PPD) sin restar E. Las notas de crédito tipo E se contabilizan // del lado del receptor (gastos), no como reducción del ingreso del emisor — // criterio fiscal vigente para PMs y otros regímenes en este grupo. const { rows: g3Facturas } = await pool.query(` SELECT regimen_fiscal_emisor as regimen, COALESCE(SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto FROM cfdis WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago IN ('PUE', 'PPD') AND ${VIGENTE} AND ${FR}${extra} AND regimen_fiscal_emisor = ANY($3) GROUP BY regimen_fiscal_emisor `, [fechaInicio, fechaFin, GRUPO_PM_OTROS]); for (const clave of GRUPO_PM_OTROS) { if (ignorados.includes(clave)) continue; const facturas = Number(g3Facturas.find((r: any) => r.regimen === clave)?.monto || 0); if (facturas !== 0) { porRegimen.push({ regimenClave: clave, regimenDescripcion: descMap.get(clave) || clave, monto: facturas }); } } return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; } /** * Calcula el monto neto de notas de crédito tipo E PUE emitidas por el * contribuyente en el período, agrupado por régimen del emisor. * * Misma fórmula neta que ingresos (`total_mxn − IMP_TRAS − EXCL_MONTO`), * excluyendo conceptos con `clave_prod_serv` en `CLAVES_EXCLUIDAS`. * * No participa en el cálculo de ISR ni de ingresos — surface-only para que * el contador vea las NCs emitidas (que ya no se restan del ingreso) sin * perder visibilidad de la información. Mirror de `calcularNcsRecibidasPorRegimen`. */ export async function calcularNcsEmitidasPorRegimen( pool: Pool, tenantId: string, fechaInicio: string, fechaFin: string, _ignorados?: string[], _descMap?: Map, conciliacion?: boolean, contribuyenteId?: string | null, considerarActivos: boolean = true, considerarNCs: boolean = true, ): Promise<{ total: number; porRegimen: IngresoRegimen[] }> { const ignorados = await getIgnorados(tenantId, _ignorados); const descMap = await getDescMap(_descMap); const FR = getFechaRango(conciliacion); const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); const esEmisor = ctx.esEmisor; const extra = buildExtraFilters(considerarActivos, considerarNCs); const { rows } = await pool.query(` SELECT regimen_fiscal_emisor as regimen, COALESCE(SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto FROM cfdis WHERE ${esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND ${VIGENTE} AND ${FR}${extra} GROUP BY regimen_fiscal_emisor `, [fechaInicio, fechaFin]); const porRegimen: IngresoRegimen[] = []; for (const row of rows) { const clave = row.regimen as string | null; if (!clave || ignorados.includes(clave)) continue; const monto = Number(row.monto || 0); if (monto === 0) continue; porRegimen.push({ regimenClave: clave, regimenDescripcion: descMap.get(clave) || clave, monto }); } return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; } /** * Calcula el monto neto de notas de crédito tipo E PUE RECIBIDAS por el * contribuyente en el período, agrupado por régimen del receptor. * * Misma fórmula neta que ingresos/deducciones (`total_mxn − IMP_TRAS − EXCL_MONTO`), * excluyendo conceptos con `clave_prod_serv` en `CLAVES_EXCLUIDAS`. * * No participa en el cálculo de ISR ni de deducciones — surface-only para que * el contador vea las E recibidas (que ya no se restan de la deducción) sin * perder visibilidad de la información. Mirror del lado receptor de * `calcularEgresosEmitidosPorRegimen`. */ export async function calcularNcsRecibidasPorRegimen( pool: Pool, tenantId: string, fechaInicio: string, fechaFin: string, _ignorados?: string[], _descMap?: Map, conciliacion?: boolean, contribuyenteId?: string | null, considerarActivos: boolean = true, considerarNCs: boolean = true, ): Promise<{ total: number; porRegimen: IngresoRegimen[] }> { const ignorados = await getIgnorados(tenantId, _ignorados); const descMap = await getDescMap(_descMap); const FR = getFechaRango(conciliacion); const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); const esReceptor = ctx.esReceptor; const extra = buildExtraFilters(considerarActivos, considerarNCs); const { rows } = await pool.query(` SELECT regimen_fiscal_receptor as regimen, COALESCE(SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto FROM cfdis WHERE ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND ${VIGENTE} AND ${FR}${extra} GROUP BY regimen_fiscal_receptor `, [fechaInicio, fechaFin]); const porRegimen: IngresoRegimen[] = []; for (const row of rows) { const clave = row.regimen as string | null; if (!clave || ignorados.includes(clave)) continue; const monto = Number(row.monto || 0); if (monto === 0) continue; porRegimen.push({ regimenClave: clave, regimenDescripcion: descMap.get(clave) || clave, monto }); } return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; } /** * Calcula gastos NO deducibles por Art. 27 fracción III LISR — facturas * recibidas pagadas en efectivo con monto > $2,000. Por régimen del receptor. * * Suma I PUE recibidas (forma_pago='01' AND total_mxn > 2000) + complementos * P recibidos (forma_pago='01' AND monto_pago_mxn > 2000), monto neto sin * impuestos. Misma fórmula que deducciones (que las EXCLUYE), por lo que * deducciones + noDeducibles = gastos brutos del periodo (excluyendo NCs). * * Surface-only — no entra en cálculo de ISR; sirve para que el contador vea * cuánto está "perdiendo" por pagos en efectivo. */ export async function calcularGastosNoDeduciblesEfectivoPorRegimen( pool: Pool, tenantId: string, fechaInicio: string, fechaFin: string, _ignorados?: string[], _descMap?: Map, conciliacion?: boolean, contribuyenteId?: string | null, considerarActivos: boolean = true, considerarNCs: boolean = true, ): Promise<{ total: number; porRegimen: IngresoRegimen[] }> { const ignorados = await getIgnorados(tenantId, _ignorados); const descMap = await getDescMap(_descMap); const FR = getFechaRango(conciliacion); const FR_PAGO = getFechaPagoRango(conciliacion); const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); const esReceptor = ctx.esReceptor; const extra = buildExtraFilters(considerarActivos, considerarNCs); const [{ rows: facturas }, { rows: pagos }] = await Promise.all([ pool.query(` SELECT regimen_fiscal_receptor as regimen, COALESCE(SUM(COALESCE(total_mxn, 0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto FROM cfdis WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' AND ${VIGENTE} AND ${FR}${extra} AND ${NO_DEDUCIBLE_EFECTIVO_I_PUE} AND regimen_fiscal_receptor IS NOT NULL GROUP BY regimen_fiscal_receptor `, [fechaInicio, fechaFin]), pool.query(` SELECT regimen_fiscal_receptor as regimen, COALESCE(SUM(COALESCE(monto_pago_mxn, 0) - (${IMP_TRAS_PAGO})), 0) as monto FROM cfdis WHERE ${esReceptor} AND tipo_comprobante = 'P' AND ${VIGENTE} AND ${FR_PAGO}${extra} AND ${NO_DEDUCIBLE_EFECTIVO_P} AND regimen_fiscal_receptor IS NOT NULL GROUP BY regimen_fiscal_receptor `, [fechaInicio, fechaFin]), ]); const map = new Map(); for (const r of facturas) { const k = r.regimen as string; if (ignorados.includes(k)) continue; map.set(k, (map.get(k) || 0) + Number(r.monto || 0)); } for (const r of pagos) { const k = r.regimen as string; if (ignorados.includes(k)) continue; map.set(k, (map.get(k) || 0) + Number(r.monto || 0)); } const porRegimen: IngresoRegimen[] = []; for (const [k, v] of map.entries()) { if (v === 0) continue; porRegimen.push({ regimenClave: k, regimenDescripcion: descMap.get(k) || k, monto: v }); } return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; } /** * Calcula el IVA NO acreditable por Art. 5 LIVA fracción I + Art. 27 fracción * III LISR — IVA neto (trasladado − retenido) de las facturas recibidas * pagadas en efectivo > $2,000. * * Mirror del lado IVA de `calcularGastosNoDeduciblesEfectivoPorRegimen`. Se * excluye del IVA Acreditable (vía filtro en `calcularIvaBalancePorRegimen` * y `getResumenIva`) y se exhibe aparte como card "IVA No Acreditable". * * Surface-only — no entra en cálculo de IVA Resultado; sirve para que el * contador vea cuánto IVA está "perdiendo" por pagos en efectivo. */ export async function calcularIvaNoAcreditableEfectivoPorRegimen( pool: Pool, tenantId: string, fechaInicio: string, fechaFin: string, _ignorados?: string[], _descMap?: Map, conciliacion?: boolean, contribuyenteId?: string | null, considerarActivos: boolean = true, considerarNCs: boolean = true, ): Promise<{ total: number; porRegimen: IngresoRegimen[] }> { const ignorados = await getIgnorados(tenantId, _ignorados); const descMap = await getDescMap(_descMap); const FR = getFechaRango(conciliacion); const FR_PAGO = getFechaPagoRango(conciliacion); const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); const esReceptor = ctx.esReceptor; const extra = buildExtraFilters(considerarActivos, considerarNCs); const [{ rows: facturas }, { rows: pagos }] = await Promise.all([ pool.query(` SELECT regimen_fiscal_receptor as regimen, COALESCE(SUM(${IVA_NETO} - (${EXCL_IVA_NETO})), 0) as monto FROM cfdis WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' AND ${VIGENTE} AND ${FR}${extra} AND ${NO_DEDUCIBLE_EFECTIVO_I_PUE} AND regimen_fiscal_receptor IS NOT NULL GROUP BY regimen_fiscal_receptor `, [fechaInicio, fechaFin]), pool.query(` SELECT regimen_fiscal_receptor as regimen, COALESCE(SUM(${IVA_NETO_PAGO}), 0) as monto FROM cfdis WHERE ${esReceptor} AND tipo_comprobante = 'P' AND ${VIGENTE} AND ${FR_PAGO}${extra} AND ${NO_DEDUCIBLE_EFECTIVO_P} AND regimen_fiscal_receptor IS NOT NULL GROUP BY regimen_fiscal_receptor `, [fechaInicio, fechaFin]), ]); const map = new Map(); for (const r of facturas) { const k = r.regimen as string; if (ignorados.includes(k)) continue; map.set(k, (map.get(k) || 0) + Number(r.monto || 0)); } for (const r of pagos) { const k = r.regimen as string; if (ignorados.includes(k)) continue; map.set(k, (map.get(k) || 0) + Number(r.monto || 0)); } const porRegimen: IngresoRegimen[] = []; for (const [k, v] of map.entries()) { if (v === 0) continue; porRegimen.push({ regimenClave: k, regimenDescripcion: descMap.get(k) || k, monto: v }); } return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; } /** * Calcula "Gastos del Mes" desglosados por régimen fiscal del receptor. */ export async function calcularEgresosPorRegimen( pool: Pool, tenantId: string, fechaInicio: string, fechaFin: string, _ignorados?: string[], _descMap?: Map, conciliacion?: boolean, contribuyenteId?: string | null, considerarActivos: boolean = true, considerarNCs: boolean = true, ): Promise<{ total: number; porRegimen: EgresoRegimen[] }> { const ignorados = await getIgnorados(tenantId, _ignorados); const descMap = await getDescMap(_descMap); // Read-through cache: ver nota en calcularIngresosPorRegimen. Solo cachea // cuando los toggles están en default — escribir/leer con flags distintos // devolvería valores stale. const cacheRange = considerarActivos && considerarNCs ? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId) : null; if (cacheRange) { const cached = await readEgresosFromCache(pool, cacheRange, ignorados, descMap); if (cached) return cached; } const FR = getFechaRango(conciliacion); const FR_PAGO = getFechaPagoRango(conciliacion); const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); const esReceptor = ctx.esReceptor; const extra = buildExtraFilters(considerarActivos, considerarNCs); const porRegimen: EgresoRegimen[] = []; // Gastos: lado RECEPTOR (el contribuyente recibe). Suman I PUE + P (pagos) // + I/07 PPD compensación + nómina emitida. Las notas de crédito tipo E que // el contribuyente recibe ya NO se restan — simétrico con el cambio en // ingresos. // // I/07 PPD compensación (lado RECEPTOR): cuando el contribuyente recibe una // I/07 PPD (aplicación de anticipo) y recibe también una E en el mismo // mes/año cuya cfdis_relacionados contiene el UUID de esa I/07 PPD, la I/07 // PPD aporta el equivalente de la base de la E. Se preserva por // interpretación fiscal explícita aunque la E ya no se reste — refleja que // la porción del servicio asociada al anticipo se reconoce como gasto al // recibir la I/07 PPD. // // Filtro por RFC (esReceptor) en vez de `type` — el RFC es fuente de verdad. // Art. 27 fracción III LISR: excluimos del cálculo de deducciones las I PUE // y los P recibidos pagados en efectivo > $2,000. Esos gastos se exhiben // aparte en card "No Deducibles" (calcularGastosNoDeduciblesEfectivoPorRegimen). const { rows: facturas } = await pool.query(` SELECT regimen_fiscal_receptor as regimen, COALESCE(SUM(COALESCE(total_mxn, 0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto FROM cfdis WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' AND ${VIGENTE} AND ${FR}${extra} AND NOT ${NO_DEDUCIBLE_EFECTIVO_I_PUE} AND regimen_fiscal_receptor = ANY($3) GROUP BY regimen_fiscal_receptor `, [fechaInicio, fechaFin, TODOS_REGIMENES]); const { rows: pagos } = await pool.query(` SELECT regimen_fiscal_receptor as regimen, COALESCE(SUM(COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO})), 0) as monto FROM cfdis WHERE ${esReceptor} AND tipo_comprobante = 'P' AND ${VIGENTE} AND ${FR_PAGO}${extra} AND NOT ${NO_DEDUCIBLE_EFECTIVO_P} AND regimen_fiscal_receptor = ANY($3) GROUP BY regimen_fiscal_receptor `, [fechaInicio, fechaFin, TODOS_REGIMENES]); // NOTA: la compensación I/07 PPD ↔ E se eliminó por decisión del cliente // (2026-05-02). No es un cálculo oficial del SAT y confundía a contadores — // el contador hace la conciliación manual del ciclo anticipo→I/07 PPD→E si // aplica. Mantener esta nota para evitar reintroducirla por intuición fiscal. // Nómina emitida (lado EMISOR — el contribuyente como patrón paga a empleados). // Suma `total_mxn` completo: sin restar impuestos trasladados (la nómina típicamente // no lleva IVA), sin restar conceptos excluidos (los códigos excluidos no aplican // a nómina), sin filtros `considerarActivos`/`considerarNCs` (no aplican al // concepto). Siempre por `fecha_emision` (no toca toggle de Conciliación). // Agrupa por `regimen_fiscal_emisor` — el régimen donde está catalogado el // contribuyente al emitir, que es donde se imputan estas deducciones. const esEmisor = ctx.esEmisor; const { rows: nomina } = await pool.query(` SELECT regimen_fiscal_emisor as regimen, COALESCE(SUM(COALESCE(total_mxn, 0)), 0) as monto FROM cfdis WHERE ${esEmisor} AND tipo_comprobante = 'N' AND ${VIGENTE} AND ${FECHA_RANGO} AND regimen_fiscal_emisor = ANY($3) GROUP BY regimen_fiscal_emisor `, [fechaInicio, fechaFin, TODOS_REGIMENES]); for (const clave of TODOS_REGIMENES) { if (ignorados.includes(clave)) continue; const montoF = Number(facturas.find((r: any) => r.regimen === clave)?.monto || 0); const montoP = Number(pagos.find((r: any) => r.regimen === clave)?.monto || 0); const montoN = Number(nomina.find((r: any) => r.regimen === clave)?.monto || 0); const monto = montoF + montoP + montoN; if (monto !== 0 || montoF !== 0 || montoP !== 0 || montoN !== 0) { porRegimen.push({ regimenClave: clave, regimenDescripcion: descMap.get(clave) || clave, monto }); } } return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; } /** * Calcula "Adquisición de Mercancías" — misma lógica que egresos pero solo CFDIs con uso_cfdi = 'G01' */ export async function calcularAdquisicionesMercancias( pool: Pool, tenantId: string, fechaInicio: string, fechaFin: string, conciliacion?: boolean, contribuyenteId?: string | null, ): Promise<{ total: number; porRegimen: { regimenClave: string; monto: number }[] }> { const FR = getFechaRango(conciliacion); const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); const esReceptor = ctx.esReceptor; // Adquisiciones G01 = subset de gastos con uso_cfdi='G01'. Lado receptor. // Método A (ingenuo), consistente con calcularEgresosPorRegimen. const { rows: facturas } = await pool.query(` SELECT regimen_fiscal_receptor as regimen, COALESCE(SUM(COALESCE(total_mxn, 0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto FROM cfdis WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' AND uso_cfdi = 'G01' AND ${VIGENTE} AND ${FR} GROUP BY regimen_fiscal_receptor `, [fechaInicio, fechaFin]); const { rows: nc } = await pool.query(` SELECT regimen_fiscal_receptor as regimen, COALESCE(SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto FROM cfdis WHERE ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND uso_cfdi = 'G01' AND ${VIGENTE} AND ${FR} GROUP BY regimen_fiscal_receptor `, [fechaInicio, fechaFin]); // Consolidar por régimen const regimenMap = new Map(); for (const r of facturas) { const clave = r.regimen || 'sin'; regimenMap.set(clave, (regimenMap.get(clave) || 0) + Number(r.monto)); } for (const r of nc) { const clave = r.regimen || 'sin'; regimenMap.set(clave, (regimenMap.get(clave) || 0) - Number(r.monto)); } const porRegimen = Array.from(regimenMap.entries()).map(([regimenClave, monto]) => ({ regimenClave, monto })); const total = porRegimen.reduce((s, r) => s + r.monto, 0); return { total, porRegimen }; } // IVA neto del comprobante: trasladado - retenido const IVA_NETO = `COALESCE(iva_traslado_mxn,0) - COALESCE(iva_retencion_mxn,0)`; // IVA neto del pago. Refactor 2026-04-26: campos directos, sin clamp. // Alineado con impuestos.service.ts post-refactor (ver doc 2026-04-26-iva-refactor.md). const IVA_NETO_PAGO = `COALESCE(iva_traslado_pago_mxn, 0) - COALESCE(iva_retencion_pago_mxn, 0)`; /** * Calcula "Balance IVA" desglosado por régimen fiscal del receptor. */ export async function calcularIvaBalancePorRegimen( pool: Pool, tenantId: string, fechaInicio: string, fechaFin: string, _ignorados?: string[], _descMap?: Map, conciliacion?: boolean, contribuyenteId?: string | null, ): Promise<{ total: number; porRegimen: IvaBalanceRegimen[] }> { const ignorados = await getIgnorados(tenantId, _ignorados); const descMap = await getDescMap(_descMap); // Read-through cache: años pasados con contribuyente seleccionado leen de // metricas_mensuales (iva_trasladado_total, iva_acreditable). El año actual // y rangos parciales siguen on-the-fly. const cacheRange = planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId); if (cacheRange) { const cached = await readIvaBalanceFromCache(pool, cacheRange, ignorados, descMap); if (cached) return cached; } const FR = getFechaRango(conciliacion); const FR_PAGO = getFechaPagoRango(conciliacion); const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); const esEmisor = ctx.esEmisor; const esReceptor = ctx.esReceptor; const porRegimen: IvaBalanceRegimen[] = []; // 6 buckets — 3 causados (emisor) + 3 acreditables (receptor). // Filtro por RFC (esEmisor/esReceptor) en vez de type. // s1 — Emisor + I + PUE. // Refactor 2026-04-26: removida la compensación I PUE/07. Las I PUE/07 // ahora aportan IVA neto completo. La E (cualquier tipoRelación) que // las cancele resta vía s3/r3 — fidelidad al XML, sin interpretación. const { rows: s1 } = await pool.query(` SELECT regimen_fiscal_emisor as regimen, COALESCE(SUM(${IVA_NETO} - (${EXCL_IVA_NETO})), 0) as monto FROM cfdis WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' AND ${VIGENTE} AND ${FR} AND regimen_fiscal_emisor = ANY($3) GROUP BY regimen_fiscal_emisor `, [fechaInicio, fechaFin, TODOS_REGIMENES]); // s2 — Emisor + P const { rows: s2 } = await pool.query(` SELECT regimen_fiscal_emisor as regimen, COALESCE(SUM(${IVA_NETO_PAGO}), 0) as monto FROM cfdis WHERE ${esEmisor} AND tipo_comprobante = 'P' AND ${VIGENTE} AND ${FR_PAGO} AND regimen_fiscal_emisor = ANY($3) GROUP BY regimen_fiscal_emisor `, [fechaInicio, fechaFin, TODOS_REGIMENES]); // s3 — Receptor + E + PUE (NC recibida) — resta de acreditable. // Refactor 2026-04-26: removido filtro `<> '07'`. Todas las E PUE entran. const { rows: s3 } = await pool.query(` SELECT regimen_fiscal_receptor as regimen, COALESCE(SUM(${IVA_NETO} - (${EXCL_IVA_NETO})), 0) as monto FROM cfdis WHERE ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND ${VIGENTE} AND ${FR} AND regimen_fiscal_receptor = ANY($3) GROUP BY regimen_fiscal_receptor `, [fechaInicio, fechaFin, TODOS_REGIMENES]); // r1 — Receptor + I + PUE. Excluye gastos en efectivo > $2k (Art. 5 LIVA // fracción I — el IVA acreditable requiere que el gasto cumpla los requisitos // de deducibilidad ISR; gastos en efectivo > $2k no son deducibles ni su IVA // acreditable). const { rows: r1 } = await pool.query(` SELECT regimen_fiscal_receptor as regimen, COALESCE(SUM(${IVA_NETO} - (${EXCL_IVA_NETO})), 0) as monto FROM cfdis WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' AND ${VIGENTE} AND ${FR} AND NOT ${NO_DEDUCIBLE_EFECTIVO_I_PUE} AND regimen_fiscal_receptor = ANY($3) GROUP BY regimen_fiscal_receptor `, [fechaInicio, fechaFin, TODOS_REGIMENES]); // r2 — Receptor + P. Excluye P en efectivo > $2k (mismo razonamiento r1). const { rows: r2 } = await pool.query(` SELECT regimen_fiscal_receptor as regimen, COALESCE(SUM(${IVA_NETO_PAGO}), 0) as monto FROM cfdis WHERE ${esReceptor} AND tipo_comprobante = 'P' AND ${VIGENTE} AND ${FR_PAGO} AND NOT ${NO_DEDUCIBLE_EFECTIVO_P} AND regimen_fiscal_receptor = ANY($3) GROUP BY regimen_fiscal_receptor `, [fechaInicio, fechaFin, TODOS_REGIMENES]); // r3 — Emisor + E + PUE (NC emitida resta de causado). // Refactor 2026-04-26: removido filtro `<> '07'`. Todas las E PUE entran. const { rows: r3 } = await pool.query(` SELECT regimen_fiscal_emisor as regimen, COALESCE(SUM(${IVA_NETO} - (${EXCL_IVA_NETO})), 0) as monto FROM cfdis WHERE ${esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND ${VIGENTE} AND ${FR} AND regimen_fiscal_emisor = ANY($3) GROUP BY regimen_fiscal_emisor `, [fechaInicio, fechaFin, TODOS_REGIMENES]); // s4 — Emisor I PPD/07 hereda IVA neto de E que la cancelan en mismo mes. // Mirror de SUM_E_REFERENCING en impuestos.service.ts. La I PPD/07 normalmente // no aporta IVA (espera al P), pero si una E la referencia en su mismo mes // hereda el IVA de la E para netear el efecto del NEG (caso PPD ↔ E). const { rows: s4 } = await pool.query(` SELECT i.regimen_fiscal_emisor as regimen, COALESCE(SUM(( SELECT COALESCE(SUM(${IVA_NETO_ALIAS('e')} - (${EXCL_IVA_NETO_ALIAS('e')})), 0) FROM cfdis e WHERE e.tipo_comprobante = 'E' AND e.metodo_pago = 'PUE' AND e.status NOT IN ('Cancelado', '0') AND ${esEmisor.replace(/\brfc_emisor\b/g, 'e.rfc_emisor')} AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) = date_trunc('month', COALESCE(i.fecha_efectiva, i.fecha_emision - interval '1 hour')) )), 0) as monto FROM cfdis i WHERE ${esEmisor.replace(/\brfc_emisor\b/g, 'i.rfc_emisor')} AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD' AND COALESCE(i.cfdi_tipo_relacion, '') = '07' AND i.status NOT IN ('Cancelado','0') AND ${FR.replace(/\bfecha_efectiva\b/g, 'i.fecha_efectiva').replace(/\bfecha_emision\b/g, 'i.fecha_emision')} AND i.regimen_fiscal_emisor = ANY($3) GROUP BY i.regimen_fiscal_emisor `, [fechaInicio, fechaFin, TODOS_REGIMENES]); // r4 — Receptor I PPD/07 hereda IVA neto de E recibidas que la cancelan. const { rows: r4 } = await pool.query(` SELECT i.regimen_fiscal_receptor as regimen, COALESCE(SUM(( SELECT COALESCE(SUM(${IVA_NETO_ALIAS('e')} - (${EXCL_IVA_NETO_ALIAS('e')})), 0) FROM cfdis e WHERE e.tipo_comprobante = 'E' AND e.metodo_pago = 'PUE' AND e.status NOT IN ('Cancelado', '0') AND ${esReceptor.replace(/\brfc_receptor\b/g, 'e.rfc_receptor')} AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) = date_trunc('month', COALESCE(i.fecha_efectiva, i.fecha_emision - interval '1 hour')) )), 0) as monto FROM cfdis i WHERE ${esReceptor.replace(/\brfc_receptor\b/g, 'i.rfc_receptor')} AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD' AND COALESCE(i.cfdi_tipo_relacion, '') = '07' AND i.status NOT IN ('Cancelado','0') AND ${FR.replace(/\bfecha_efectiva\b/g, 'i.fecha_efectiva').replace(/\bfecha_emision\b/g, 'i.fecha_emision')} AND i.regimen_fiscal_receptor = ANY($3) GROUP BY i.regimen_fiscal_receptor `, [fechaInicio, fechaFin, TODOS_REGIMENES]); const find = (rows: any[], clave: string) => Number(rows.find((r: any) => r.regimen === clave)?.monto || 0); // Atribución directa por lado (refactor 2026-04-26): // Causado = (EMIT I PUE) + (EMIT P) + (EMIT I PPD/07 hereda E) − (EMIT E PUE) // Acreditable = (RECIB I PUE) + (RECIB P) + (RECIB I PPD/07 hereda E) − (RECIB E PUE) // Balance = Causado − Acreditable. Sin compensación I PUE/07; sin filtro // tipoRel en E. Las I PPD/07 con E que las cancelan heredan el IVA neto de // la E para netear dentro del mes. for (const clave of TODOS_REGIMENES) { if (ignorados.includes(clave)) continue; const causado = find(s1, clave) + find(s2, clave) + find(s4, clave) - find(r3, clave); const acreditable = find(r1, clave) + find(r2, clave) + find(r4, clave) - find(s3, clave); const monto = causado - acreditable; if (monto !== 0 || causado !== 0 || acreditable !== 0) { porRegimen.push({ regimenClave: clave, regimenDescripcion: descMap.get(clave) || clave, monto }); } } return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; } /** * Calcula IVA a favor acumulado mes a mes desde añoDesde/enero hasta fechaFin. * Lógica SAT: saldo positivo se paga (no acumula), saldo negativo se arrastra. */ async function calcularIvaAFavorAcumulado( pool: Pool, tenantId: string, fechaFin: string, añoDesde?: number, conciliacion?: boolean, contribuyenteId?: string | null, ): Promise { const añoFin = new Date(fechaFin + 'T00:00:00').getFullYear(); const mesFin = new Date(fechaFin + 'T00:00:00').getMonth() + 1; const inicio = añoDesde ?? añoFin; // Precachear const ignorados = await getRegimenesIgnoradosClaves(tenantId); const catalogo = await prisma.regimen.findMany({ where: { activo: true } }); const descMap = new Map(catalogo.map(r => [r.clave, r.descripcion])); let saldoAFavor = 0; for (let y = inicio; y <= añoFin; y++) { const ultimoMes = y === añoFin ? mesFin : 12; for (let m = 1; m <= ultimoMes; m++) { const lastDay = new Date(y, m, 0).getDate(); const fi = `${y}-${String(m).padStart(2, '0')}-01`; const ff = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; const ivaMes = await calcularIvaBalancePorRegimen(pool, tenantId, fi, ff, ignorados, descMap, conciliacion, contribuyenteId); const balanceMes = ivaMes.total; if (balanceMes >= 0) { if (saldoAFavor >= balanceMes) { saldoAFavor = saldoAFavor - balanceMes; } else { saldoAFavor = 0; } } else { saldoAFavor = saldoAFavor + Math.abs(balanceMes); } } } return saldoAFavor; } export async function getKpis( pool: Pool, fechaInicio: string, fechaFin: string, tenantId: string, conciliacion?: boolean, contribuyenteId?: string | null, ): Promise { const FR = getFechaRango(conciliacion); const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); const esEmisor = ctx.esEmisor; const esReceptor = ctx.esReceptor; const ingresosData = await calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId); const egresosData = await calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId); const adquisicionData = await calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId); const ivaData = await calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId); // IVA a favor año actual: desde enero del año en curso const ivaAFavorAcumulado = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, undefined, conciliacion, contribuyenteId); // IVA a favor histórico: desde 5 años atrás const añoFin = new Date(fechaFin + 'T00:00:00').getFullYear(); const ivaAFavorHistorico = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, añoFin - 5, conciliacion, contribuyenteId); // Conteos por lado: derivamos el "type" efectivo del RFC del contribuyente // en vez de la columna `type` (que puede ser inconsistente). const { rows: countRows } = await pool.query(` SELECT CASE WHEN ${esEmisor} THEN 'EMITIDO' WHEN ${esReceptor} THEN 'RECIBIDO' ELSE NULL END AS type, COALESCE(CASE WHEN ${esEmisor} THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END, '') as regimen, COUNT(*)::int as total FROM cfdis WHERE ${VIGENTE} AND ${FR} AND (${esEmisor} OR ${esReceptor}) GROUP BY 1, regimen `, [fechaInicio, fechaFin]); const ingresosVal = ingresosData.total; const egresosVal = egresosData.total; const utilidad = ingresosVal - egresosVal; const margen = ingresosVal > 0 ? (utilidad / ingresosVal) * 100 : 0; const emitidosPorRegimen = countRows .filter((r: any) => r.type === 'EMITIDO') .map((r: any) => ({ regimen: r.regimen, total: r.total })); const recibidosPorRegimen = countRows .filter((r: any) => r.type === 'RECIBIDO') .map((r: any) => ({ regimen: r.regimen, total: r.total })); return { ingresos: ingresosVal, ingresosPorRegimen: ingresosData.porRegimen, egresos: egresosVal, egresosPorRegimen: egresosData.porRegimen, adquisicionMercancias: adquisicionData.total, adquisicionMercanciasPorRegimen: adquisicionData.porRegimen, utilidad, margen: Math.round(margen * 100) / 100, ivaBalance: ivaData.total, ivaBalancePorRegimen: ivaData.porRegimen, ivaAFavorAcumulado, ivaAFavorHistorico, cfdisEmitidos: emitidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0), cfdisEmitidosPorRegimen: emitidosPorRegimen, cfdisRecibidos: recibidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0), cfdisRecibidosPorRegimen: recibidosPorRegimen, }; } export async function getIngresosEgresos(pool: Pool, año: number, tenantId: string, conciliacion?: boolean, contribuyenteId?: string | null): Promise { const mesesLabel = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']; // Precachear catálogo e ignorados para no consultar 24 veces const ignorados = await getRegimenesIgnoradosClaves(tenantId); const catalogo = await prisma.regimen.findMany({ where: { activo: true } }); const descMap = new Map(catalogo.map(r => [r.clave, r.descripcion])); const result: IngresosEgresosData[] = []; for (let m = 1; m <= 12; m++) { const lastDay = new Date(año, m, 0).getDate(); const fi = `${año}-${String(m).padStart(2, '0')}-01`; const ff = `${año}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; const ing = await calcularIngresosPorRegimen(pool, tenantId, fi, ff, ignorados, descMap, conciliacion, contribuyenteId); const egr = await calcularEgresosPorRegimen(pool, tenantId, fi, ff, ignorados, descMap, conciliacion, contribuyenteId); result.push({ mes: mesesLabel[m - 1], ingresos: ing.total, egresos: egr.total, }); } return result; } /** * Devuelve los regímenes fiscales presentes en los CFDIs del rango de fechas. */ export async function getRegimenesDelPeriodo( pool: Pool, fechaInicio: string, fechaFin: string, conciliacion?: boolean, contribuyenteId?: string | null, tenantId?: string, ): Promise<{ clave: string; descripcion: string }[]> { const FR = getFechaRango(conciliacion); const ctx = await resolveContribuyenteContext(pool, tenantId || '', contribuyenteId); const esEmisor = ctx.esEmisor; const esReceptor = ctx.esReceptor; // Régimen del contribuyente: emisor cuando él emitió, receptor cuando él recibió. const { rows } = await pool.query(` SELECT DISTINCT regimen FROM ( SELECT regimen_fiscal_emisor AS regimen FROM cfdis WHERE regimen_fiscal_emisor IS NOT NULL AND ${esEmisor} AND ${FR} UNION SELECT regimen_fiscal_receptor AS regimen FROM cfdis WHERE regimen_fiscal_receptor IS NOT NULL AND ${esReceptor} AND ${FR} ) sub ORDER BY regimen `, [fechaInicio, fechaFin]); if (rows.length === 0) return []; const claves = rows.map((r: any) => r.regimen); const catalogo = await prisma.regimen.findMany({ where: { clave: { in: claves }, activo: true }, orderBy: { clave: 'asc' }, }); return catalogo.map(r => ({ clave: r.clave, descripcion: r.descripcion })); } export async function getAlertas(pool: Pool, limit = 5): Promise { const { rows } = await pool.query(` SELECT id, tipo, titulo, mensaje, prioridad, fecha_vencimiento as "fechaVencimiento", leida, resuelta, created_at as "createdAt" FROM alertas WHERE resuelta = false ORDER BY CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END, created_at DESC LIMIT $1 `, [limit]); return rows; }