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
1254 lines
54 KiB
TypeScript
1254 lines
54 KiB
TypeScript
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;
|
||
}
|