Files
HoruxDespachosNuevo/apps/api/src/services/dashboard.service.ts
Horux Dev 46846200da feat(sat): factura global + fecha_efectiva, fallback tenant-contribuyente, fix anio_global typo
Factura Global & fecha_efectiva:
- Migracion 045_factura_global.sql: periodicidad, meses_global, año_global, fecha_efectiva
- sat-parser.service.ts: extrae InformacionGlobal del XML
- sat.service.ts: calcFechaEfectiva con soporte bimestral (periodicidad 05)
- metricas-compute, dashboard, impuestos, cfdi, export, conciliacion, alertas:
  reemplaza fecha_emision-1h por COALESCE(fecha_efectiva, fecha_emision-1h)
- Script recalc-metricas.ts para recalculo manual

Fallback datos fiscales tenant → contribuyente:
- contribuyente.service.ts: fetchTenantFiscalData + mergeContribuyenteWithTenant
  rellena regimenFiscal, codigoPostal y domicilio cuando el contribuyente
  tiene el mismo RFC que el tenant y sus campos estan vacios
- contribuyente.controller.ts y contribuyente-config.controller.ts:
  pasan req.user!.tenantId al servicio

Fix critico SAT sync:
- sat.service.ts: anio_global → año_global en INSERT/UPDATE de CFDIs
  (la migracion creo 'año_global' con tilde; el codigo usaba 'anio_global',
   causando fallo en 100% de inserciones de CFDI)
- determineChunkMonths: salta sondeo si existe job previo con requestIds
- MAX_POLL_ATTEMPTS: 45 → 500 (~8h) para syncs iniciales grandes

Docs:
- docs/sessions/2026-05-22-factura-global-contribuyente-fallback.md
2026-05-22 15:52:10 +00:00

1254 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Pool } 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<string> {
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<string, string>): Promise<Map<string, string>> {
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<string[]> {
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<string, string>,
): 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<string, string>,
): 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<string, string>,
): 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<string, string>,
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<string, string>,
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<string, string>,
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<string, string>,
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<string, number>();
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<string, string>,
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<string, number>();
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<string, string>,
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<string, number>();
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<string, string>,
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<number> {
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<KpiData> {
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<IngresosEgresosData[]> {
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<Alerta[]> {
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;
}