Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,683 @@
import type { Pool } from 'pg';
import { prisma } from '../config/database.js';
import { getRegimenesActivosClavesEfectivos } from './regimen.service.js';
import { contarTareasProximasVencer } from './tareas.service.js';
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
/** Sanitize a contribuyente UUID for safe inline SQL injection */
function sanitizeUuid(id: string): string {
return id.replace(/[^a-f0-9-]/gi, '');
}
export interface AlertaAuto {
id: string;
tipo: string;
titulo: string;
mensaje: string;
prioridad: 'alta' | 'media' | 'baja';
detalle?: string; // ruta para drill-down
valor?: number;
}
/**
* CFDI con discrepancia: facturas recibidas donde regimen_fiscal_receptor
* no coincide con los regímenes activos del tenant.
*/
async function alertaDiscrepanciaRegimen(
pool: Pool,
tenantId: string,
contribuyenteId?: string | null,
): Promise<AlertaAuto | null> {
const activos = await getRegimenesActivosClavesEfectivos(tenantId, pool, contribuyenteId);
if (activos.length === 0) return null;
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows: [r] } = await pool.query(`
SELECT COUNT(*)::int as total
FROM cfdis
WHERE type = 'RECIBIDO' AND status = 'Vigente'
AND fecha_cancelacion IS NULL
AND regimen_fiscal_receptor IS NOT NULL
AND regimen_fiscal_receptor != ALL($1)
AND id NOT IN (SELECT cfdi_id FROM cfdi_descartados WHERE tipo_alerta = 'discrepancia-regimen')
${cf}
`, [activos]);
const total = r?.total || 0;
if (total === 0) return null;
return {
id: 'discrepancia-regimen',
tipo: 'discrepancia',
titulo: 'CFDI con Discrepancia de Regimen',
mensaje: `${total} factura(s) recibida(s) con regimen fiscal del receptor que no coincide con los regimenes activos.`,
prioridad: 'alta',
detalle: '/alertas/discrepancia-regimen',
valor: total,
};
}
/**
* Calcula el Índice de Herfindahl-Hirschman (IHH).
* IHH = Σ (cuota_de_mercado_i)^2 × 10000
*/
async function calcularIHH(
pool: Pool,
type: 'EMITIDO' | 'RECIBIDO',
contribuyenteId?: string | null,
): Promise<number> {
const rfcField = type === 'EMITIDO' ? 'rfc_receptor' : 'rfc_emisor';
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows } = await pool.query(`
SELECT ${rfcField} as rfc, SUM(total_mxn) as total
FROM cfdis
WHERE type = $1 AND tipo_comprobante = 'I' AND ${VIGENTE}
AND total_mxn > 0
${cf}
GROUP BY ${rfcField}
`, [type]);
if (rows.length === 0) return 0;
const totalGeneral = rows.reduce((s: number, r: any) => s + Number(r.total), 0);
if (totalGeneral === 0) return 0;
let ihh = 0;
for (const row of rows) {
const cuota = Number(row.total) / totalGeneral;
ihh += cuota * cuota;
}
return Math.round(ihh * 10000);
}
/**
* Concentración de clientes: IHH >= 2500 en facturas emitidas
*/
async function alertaConcentracionClientes(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
const ihh = await calcularIHH(pool, 'EMITIDO', contribuyenteId);
if (ihh < 2500) return null;
return {
id: 'concentracion-clientes',
tipo: 'concentracion',
titulo: 'Concentracion de Clientes',
mensaje: `El indice HHI de clientes es ${ihh.toLocaleString()} (>=2500 indica alta concentracion). Dependencia excesiva en pocos clientes.`,
prioridad: ihh >= 5000 ? 'alta' : 'media',
detalle: '/alertas/concentracion-clientes',
valor: ihh,
};
}
/**
* Concentración de proveedores: IHH >= 2500 en facturas recibidas
*/
async function alertaConcentracionProveedores(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
const ihh = await calcularIHH(pool, 'RECIBIDO', contribuyenteId);
if (ihh < 2500) return null;
return {
id: 'concentracion-proveedores',
tipo: 'concentracion',
titulo: 'Concentracion de Proveedores',
mensaje: `El indice HHI de proveedores es ${ihh.toLocaleString()} (>=2500 indica alta concentracion). Dependencia excesiva en pocos proveedores.`,
prioridad: ihh >= 5000 ? 'alta' : 'media',
detalle: '/alertas/concentracion-proveedores',
valor: ihh,
};
}
/**
* Riesgo cambiario: >10% de facturas en moneda != MXN
*/
async function alertaRiesgoCambiario(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows: [r] } = await pool.query(`
SELECT
COUNT(*)::int as total,
COUNT(CASE WHEN moneda IS NOT NULL AND moneda != 'MXN' THEN 1 END)::int as no_mxn
FROM cfdis
WHERE ${VIGENTE} AND tipo_comprobante = 'I'
${cf}
`);
const total = r?.total || 0;
const noMxn = r?.no_mxn || 0;
if (total === 0 || noMxn === 0) return null;
const porcentaje = Math.round((noMxn / total) * 10000) / 100;
if (porcentaje <= 10) return null;
return {
id: 'riesgo-cambiario',
tipo: 'riesgo',
titulo: 'Riesgo Cambiario',
mensaje: `${porcentaje}% de las facturas (${noMxn} de ${total}) estan en moneda extranjera. Exposicion a fluctuaciones del tipo de cambio.`,
prioridad: porcentaje > 30 ? 'alta' : 'media',
valor: porcentaje,
};
}
/**
* Riesgo de cancelaciones: >10% de facturas canceladas en últimos 5 años
*/
async function alertaRiesgoCancelaciones(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
const hace5 = new Date();
hace5.setFullYear(hace5.getFullYear() - 5);
const fechaDesde = hace5.toISOString().split('T')[0];
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows: [r] } = await pool.query(`
SELECT
COUNT(*)::int as total,
COUNT(CASE WHEN status IN ('Cancelado', '0') THEN 1 END)::int as cancelados
FROM cfdis
WHERE fecha_emision >= $1::date
${cf}
`, [fechaDesde]);
const total = r?.total || 0;
const cancelados = r?.cancelados || 0;
if (total === 0 || cancelados === 0) return null;
const porcentaje = Math.round((cancelados / total) * 10000) / 100;
if (porcentaje <= 10) return null;
return {
id: 'riesgo-cancelaciones',
tipo: 'riesgo',
titulo: 'Riesgo de Cancelaciones',
mensaje: `${porcentaje}% de las facturas (${cancelados} de ${total}) en los ultimos 5 años han sido canceladas.`,
prioridad: porcentaje > 25 ? 'alta' : 'media',
detalle: '/alertas/cancelaciones',
valor: porcentaje,
};
}
/**
* Gastos recibidos pagados en efectivo > $2,000 — Art. 27 fracción III LISR.
* No son deducibles. Surface el total + conteo para que el contador entienda
* el impacto y, si aplica, gestione cambio de forma de pago con el proveedor.
*/
async function alertaRiesgoTransaccional(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows: [r] } = await pool.query(`
SELECT
COUNT(*)::int AS facturas,
COALESCE(SUM(COALESCE(total_mxn, 0)), 0)::numeric(14,2) AS monto
FROM cfdis
WHERE ${VIGENTE}
AND type = 'RECIBIDO'
AND tipo_comprobante = 'I'
AND metodo_pago = 'PUE'
AND forma_pago = '01'
AND COALESCE(total_mxn, 0) > 2000
${cf}
`);
const facturas = r?.facturas || 0;
const monto = Number(r?.monto || 0);
if (facturas === 0) return null;
return {
id: 'gastos-no-deducibles-efectivo',
tipo: 'riesgo',
titulo: 'Gastos no deducibles (efectivo > $2,000)',
mensaje: `${facturas} factura${facturas === 1 ? '' : 's'} recibida${facturas === 1 ? '' : 's'} por $${monto.toLocaleString('es-MX')} MXN se pagaron en efectivo (>$2,000). Art. 27 fracción III LISR las hace NO deducibles para ISR.`,
prioridad: monto > 50000 ? 'alta' : 'media',
detalle: '/impuestos',
valor: monto,
};
}
/**
* Estatus lista negra: si el RFC del tenant/contribuyente aparece en la lista negra
*/
async function alertaListaNegraPropia(
pool: Pool,
tenantId: string,
contribuyenteId?: string | null,
): Promise<AlertaAuto | null> {
let rfc: string | undefined;
if (contribuyenteId) {
const safeId = sanitizeUuid(contribuyenteId);
const { rows } = await pool.query(
'SELECT rfc FROM contribuyentes WHERE entidad_id = $1',
[safeId],
);
rfc = rows[0]?.rfc;
} else {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { rfc: true },
});
rfc = tenant?.rfc;
}
if (!rfc) return null;
const registro = await prisma.listaNegra.findUnique({
where: { rfc },
});
if (!registro) return null;
return {
id: 'lista-negra-propia',
tipo: 'lista-negra',
titulo: 'RFC en Lista Negra del SAT',
mensaje: `Tu RFC (${rfc}) aparece en la lista del Art. 69-B del CFF con situacion: ${registro.situacion}.`,
prioridad: 'alta',
};
}
/**
* Factura emitida a cliente en lista negra
*/
async function alertaClienteListaNegra(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
// Fallback: consultar directo si dblink no funciona
const listaRfcs = await prisma.listaNegra.findMany({
where: { situacion: { in: ['Definitivo', 'Presunto'] } },
select: { rfc: true },
});
const rfcSet = new Set(listaRfcs.map(l => l.rfc));
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows } = await pool.query(`
SELECT DISTINCT rfc_receptor as rfc
FROM cfdis
WHERE type = 'EMITIDO' AND ${VIGENTE} AND tipo_comprobante = 'I'
${cf}
`);
const clientesEnLista = rows.filter((r: any) => rfcSet.has(r.rfc));
if (clientesEnLista.length === 0) return null;
return {
id: 'lista-negra-clientes',
tipo: 'lista-negra',
titulo: 'Facturas Emitidas a Clientes en Lista Negra',
mensaje: `${clientesEnLista.length} cliente(s) a los que has facturado aparecen en la lista negra del SAT (Art. 69-B).`,
prioridad: 'alta',
detalle: '/alertas/lista-negra-clientes',
valor: clientesEnLista.length,
};
}
/**
* Factura recibida de proveedor en lista negra
*/
async function alertaProveedorListaNegra(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
const listaRfcs = await prisma.listaNegra.findMany({
where: { situacion: { in: ['Definitivo', 'Presunto'] } },
select: { rfc: true },
});
const rfcSet = new Set(listaRfcs.map(l => l.rfc));
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows } = await pool.query(`
SELECT DISTINCT rfc_emisor as rfc
FROM cfdis
WHERE type = 'RECIBIDO' AND ${VIGENTE} AND tipo_comprobante = 'I'
${cf}
`);
const proveedoresEnLista = rows.filter((r: any) => rfcSet.has(r.rfc));
if (proveedoresEnLista.length === 0) return null;
return {
id: 'lista-negra-proveedores',
tipo: 'lista-negra',
titulo: 'Facturas Recibidas de Proveedores en Lista Negra',
mensaje: `${proveedoresEnLista.length} proveedor(es) de los que has recibido facturas aparecen en la lista negra del SAT (Art. 69-B).`,
prioridad: 'alta',
detalle: '/alertas/lista-negra-proveedores',
valor: proveedoresEnLista.length,
};
}
/**
* Facturas de periodos anteriores canceladas este mes.
* Detecta CFDIs cuya fecha_cancelacion cae en el mes actual pero
* cuya fecha_emision es de un mes anterior.
*/
async function alertaCancelacionPeriodoAnterior(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
const ahora = new Date();
const inicioMes = `${ahora.getFullYear()}-${String(ahora.getMonth() + 1).padStart(2, '0')}-01`;
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows: [r] } = await pool.query(`
SELECT COUNT(*)::int as total,
COALESCE(SUM(COALESCE(total_mxn, 0)), 0) as monto
FROM cfdis
WHERE status IN ('Cancelado', '0')
AND fecha_cancelacion >= $1::date
AND fecha_emision < $1::date
${cf}
`, [inicioMes]);
const total = r?.total || 0;
if (total === 0) return null;
const monto = Number(r.monto);
const montoFmt = monto.toLocaleString('es-MX', { style: 'currency', currency: 'MXN' });
return {
id: 'cancelacion-periodo-anterior',
tipo: 'cancelacion-retroactiva',
titulo: 'Facturas de Periodos Anteriores Canceladas',
mensaje: `${total} factura(s) emitida(s) en meses anteriores fueron canceladas este mes por un total de ${montoFmt}. Esto puede afectar declaraciones ya presentadas.`,
prioridad: 'alta',
detalle: '/alertas/cancelaciones-periodo-anterior',
valor: total,
};
}
/**
* CFDIs tipo E (Egreso / nota de crédito) con cfdi_tipo_relacion != '07'
* cuya(s) referencia(s) en cfdis_relacionados también aparecen en otro CFDI
* con cfdi_tipo_relacion = '07'. Señal de que el emisor debió usar 07
* (aplicación de anticipo) pero puso 01/02/03/04: inflá gastos e IVA
* acreditable contra un anticipo ya consumido.
*
* La detección usa overlap de arrays (`&&`) sobre cfdis_relacionados
* pipe-separados — si X y Y comparten al menos un UUID referenciado y Y
* es 07, X es sospechoso.
*/
const SOSPECHOSA_TIPO_RELACION_WHERE = `
c.tipo_comprobante = 'E'
AND c.status NOT IN ('Cancelado', '0')
AND c.cfdi_tipo_relacion IS NOT NULL
AND c.cfdi_tipo_relacion <> '07'
AND c.cfdis_relacionados IS NOT NULL
AND c.cfdis_relacionados <> ''
AND EXISTS (
SELECT 1 FROM cfdis y
WHERE y.id <> c.id
AND y.cfdi_tipo_relacion = '07'
AND y.status NOT IN ('Cancelado', '0')
AND y.cfdis_relacionados IS NOT NULL
AND y.cfdis_relacionados <> ''
AND string_to_array(LOWER(y.cfdis_relacionados), '|')
&& string_to_array(LOWER(c.cfdis_relacionados), '|')
)
AND c.id NOT IN (
SELECT cfdi_id FROM cfdi_descartados WHERE tipo_alerta = 'tipo-relacion-sospechosa'
)
`;
async function alertaTipoRelacionSospechosa(
pool: Pool,
contribuyenteId?: string | null,
): Promise<AlertaAuto | null> {
const cf = contribuyenteId ? `AND c.contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows: [r] } = await pool.query(`
SELECT COUNT(*)::int AS total
FROM cfdis c
WHERE ${SOSPECHOSA_TIPO_RELACION_WHERE}
${cf}
`);
const total = r?.total || 0;
if (total === 0) return null;
return {
id: 'tipo-relacion-sospechosa',
tipo: 'cfdi-inconsistente',
titulo: 'Nota de Crédito con Tipo de Relación sospechoso',
mensaje: `${total} CFDI(s) tipo E con TipoRelacion distinto de 07 referencian un CFDI tratado como anticipo por otra factura. Revisa si deberían haberse emitido como 07 (aplicación de anticipo).`,
prioridad: 'alta',
detalle: '/alertas/tipo-relacion-sospechosa',
valor: total,
};
}
/** Exportado para reutilizar en el controller de drill-down. */
export const SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT = SOSPECHOSA_TIPO_RELACION_WHERE;
/**
* Tareas operativas próximas a vencer (≤3 días). Solo aplica cuando hay un
* contribuyente seleccionado — sin contribuyente, no se puede contar
* porque las tareas son siempre per-contribuyente.
*/
async function alertaTareasProximasVencer(
pool: Pool,
contribuyenteId?: string | null,
): Promise<AlertaAuto | null> {
if (!contribuyenteId) return null;
const { total } = await contarTareasProximasVencer(pool, contribuyenteId);
if (total === 0) return null;
return {
id: 'tareas-proximas-vencer',
tipo: 'tareas',
titulo: 'Tareas próximas a vencer',
mensaje: `${total} tarea(s) operativa(s) vencen en los próximos 3 días.`,
prioridad: 'media',
detalle: '/configuracion/obligaciones',
valor: total,
};
}
/**
* RESICO PF cerca de salir del régimen por exceso de ingresos anuales.
*
* Art. 113-E LISR — el contribuyente PF en RESICO debe salir del régimen si
* sus ingresos del ejercicio exceden $3,500,000. **Importante:** el SAT
* considera ingresos acumulados de TODOS los regímenes del contribuyente, no
* solo los del 626. Por eso este query no filtra por `regimen_fiscal_emisor`.
*
* Umbrales:
* - $2,500,000 → alerta media (margen ~$1M)
* - $3,000,000 → alerta alta (margen ~$500k al límite)
* - $3,500,000 → alerta alta crítica ("ya superaste")
*
* Aplica solo cuando:
* 1. Hay un contribuyente seleccionado (per-tenant no tiene sentido — la
* alerta es por entidad fiscal individual)
* 2. RFC de 13 caracteres (Persona Física — RESICO PM no tiene este límite)
* 3. Régimen 626 está en su lista de regímenes activos
*
* Cálculo de ingresos: agregado de CFDIs emitidos vigentes del año en curso:
* + I PUE (cobradas al emitir)
* + P (complementos de pago — cobros de PPD anteriores)
* - E PUE (notas de crédito netan)
*
* Sin desglose por régimen ni filtro de conciliación. Se usa `total_mxn` como
* proxy de ingreso (incluye IVA — sobreestima ~16%, conservador para alerta).
*/
async function alertaResicoPfLimiteIngresos(
pool: Pool,
contribuyenteId?: string | null,
): Promise<AlertaAuto | null> {
if (!contribuyenteId) return null;
const safeId = sanitizeUuid(contribuyenteId);
// Verificar elegibilidad: PF (RFC 13) + régimen 626 activo
const { rows: contribRows } = await pool.query(
`SELECT rfc, regimen_fiscal FROM contribuyentes WHERE entidad_id = $1`,
[safeId],
);
const contrib = contribRows[0];
if (!contrib) return null;
const rfc: string = contrib.rfc || '';
if (rfc.length !== 13) return null; // PM no aplica
const regimenesCsv: string = contrib.regimen_fiscal || '';
const regimenes = regimenesCsv.split(',').map((s: string) => s.trim()).filter(Boolean);
if (!regimenes.includes('626')) return null;
// Suma ingresos del año en curso, agregado de TODOS los regímenes
const año = new Date().getFullYear();
const { rows: [r] } = await pool.query(`
SELECT COALESCE(SUM(
CASE
WHEN tipo_comprobante = 'I' AND metodo_pago = 'PUE' THEN COALESCE(total_mxn, 0)
WHEN tipo_comprobante = 'P' THEN COALESCE(monto_pago_mxn, 0)
WHEN tipo_comprobante = 'E' AND metodo_pago = 'PUE' THEN -COALESCE(total_mxn, 0)
ELSE 0
END
), 0)::numeric AS ingresos
FROM cfdis
WHERE type = 'EMITIDO'
AND status NOT IN ('Cancelado', '0')
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND contribuyente_id = $2
`, [año, safeId]);
const ingresos = Number(r?.ingresos || 0);
const UMBRAL_AVISO = 2_500_000;
const UMBRAL_ALTO = 3_000_000;
const LIMITE_LEGAL = 3_500_000; // Art. 113-E LISR
if (ingresos < UMBRAL_AVISO) return null;
const ingresosFmt = ingresos.toLocaleString('es-MX', {
style: 'currency', currency: 'MXN', maximumFractionDigits: 0,
});
let prioridad: 'alta' | 'media' = 'media';
let titulo = `RESICO PF cerca del límite anual`;
let mensaje = `Ingresos acumulados ${año} (todos los regímenes): ${ingresosFmt}. Límite RESICO PF: $3,500,000 (Art. 113-E LISR). Se considera ingresos de TODOS los regímenes, no solo del 626.`;
if (ingresos >= LIMITE_LEGAL) {
prioridad = 'alta';
titulo = `RESICO PF: límite anual EXCEDIDO`;
mensaje = `Ingresos acumulados ${año} (todos los regímenes): ${ingresosFmt}. Excede el límite de $3,500,000 del Art. 113-E LISR. El contribuyente debe salir de RESICO PF y tributar bajo régimen general (PF Empresarial).`;
} else if (ingresos >= UMBRAL_ALTO) {
prioridad = 'alta';
titulo = `RESICO PF: cerca del límite ($3M+)`;
}
return {
id: 'resico-pf-limite-ingresos',
tipo: 'limite-regimen',
titulo,
mensaje,
prioridad,
valor: ingresos,
};
}
/**
* Alerta si la última Opinión de Cumplimiento no es Positiva.
*/
async function alertaOpinionCumplimiento(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
let rfcFilter = '';
if (contribuyenteId) {
const safeId = sanitizeUuid(contribuyenteId);
const { rows: rfcRows } = await pool.query(
'SELECT rfc FROM contribuyentes WHERE entidad_id = $1',
[safeId],
);
const rfc: string | undefined = rfcRows[0]?.rfc;
if (rfc) rfcFilter = `WHERE rfc = '${rfc.replace(/'/g, "''")}'`;
}
const { rows } = await pool.query(`
SELECT estatus, fecha_consulta
FROM opiniones_cumplimiento
${rfcFilter}
ORDER BY fecha_consulta DESC
LIMIT 1
`);
if (rows.length === 0) return null;
const { estatus, fecha_consulta } = rows[0];
if (estatus === 'Positiva') return null;
const fecha = new Date(fecha_consulta).toLocaleDateString('es-MX');
return {
id: 'opinion-cumplimiento-negativa',
tipo: 'opinion-cumplimiento',
titulo: `Opinión de Cumplimiento: ${estatus}`,
mensaje: `Tu Opinión de Cumplimiento ante el SAT es ${estatus}. Última consulta: ${fecha}. Revisa tus obligaciones fiscales.`,
prioridad: 'alta',
valor: 1,
};
}
/**
* Genera todas las alertas automáticas para un tenant.
*/
export async function generarAlertasAutomaticas(
pool: Pool,
tenantId: string,
contribuyenteId?: string | null,
): Promise<AlertaAuto[]> {
const alertas = await Promise.all([
alertaListaNegraPropia(pool, tenantId, contribuyenteId),
alertaClienteListaNegra(pool, contribuyenteId),
alertaProveedorListaNegra(pool, contribuyenteId),
alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId),
alertaConcentracionClientes(pool, contribuyenteId),
alertaConcentracionProveedores(pool, contribuyenteId),
alertaRiesgoCambiario(pool, contribuyenteId),
alertaRiesgoCancelaciones(pool, contribuyenteId),
alertaRiesgoTransaccional(pool, contribuyenteId),
alertaCancelacionPeriodoAnterior(pool, contribuyenteId),
alertaOpinionCumplimiento(pool, contribuyenteId),
alertaTipoRelacionSospechosa(pool, contribuyenteId),
alertaTareasProximasVencer(pool, contribuyenteId),
alertaResicoPfLimiteIngresos(pool, contribuyenteId),
]);
return alertas.filter((a): a is AlertaAuto => a !== null);
}
/**
* Breakdown mensual de discrepancias de régimen de los últimos N meses.
* Cuenta facturas RECIBIDAS donde regimen_fiscal_receptor no coincide con
* los regímenes activos del tenant. Útil para el correo semanal — el cliente
* ve cuántas facturas con error le emitieron mes por mes.
*/
export async function getDiscrepanciasPorMes(
pool: Pool,
tenantId: string,
monthsBack = 6,
contribuyenteId?: string | null,
): Promise<Array<{ año: number; mes: number; count: number; label: string }>> {
const activos = await getRegimenesActivosClavesEfectivos(tenantId, pool, contribuyenteId);
if (activos.length === 0) return [];
const desde = new Date();
desde.setMonth(desde.getMonth() - (monthsBack - 1));
desde.setDate(1);
const desdeStr = desde.toISOString().split('T')[0];
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows } = await pool.query(`
SELECT
EXTRACT(YEAR FROM fecha_emision)::int as año,
EXTRACT(MONTH FROM fecha_emision)::int as mes,
COUNT(*)::int as count
FROM cfdis
WHERE type = 'RECIBIDO' AND ${VIGENTE}
AND regimen_fiscal_receptor IS NOT NULL
AND regimen_fiscal_receptor != ALL($1)
AND fecha_emision >= $2::date
${cf}
GROUP BY año, mes
ORDER BY año DESC, mes DESC
`, [activos, desdeStr]);
const NOMBRES_MES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
return rows.map((r: any) => ({
año: r.año,
mes: r.mes,
count: r.count,
label: `${NOMBRES_MES[r.mes - 1]} ${r.año}`,
}));
}