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 { 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 { 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 { 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 { 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 { 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 { 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 - interval '1 hour') >= $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 { 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 { 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 { // 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 { 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 { 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 COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < $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 { 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 { 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 { 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 COALESCE(fecha_efectiva, fecha_emision - interval '1 hour')) = $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 { 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. * Cada alerta se envuelve en try/catch para que un fallo en una no * bloquee el resto (robustez ante timeouts o errores transitorios). */ export async function generarAlertasAutomaticas( pool: Pool, tenantId: string, contribuyenteId?: string | null, ): Promise { const generadores: { name: string; fn: () => Promise }[] = [ { name: 'lista-negra-propia', fn: () => alertaListaNegraPropia(pool, tenantId, contribuyenteId) }, { name: 'lista-negra-clientes', fn: () => alertaClienteListaNegra(pool, contribuyenteId) }, { name: 'lista-negra-proveedores', fn: () => alertaProveedorListaNegra(pool, contribuyenteId) }, { name: 'discrepancia-regimen', fn: () => alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId) }, { name: 'concentracion-clientes', fn: () => alertaConcentracionClientes(pool, contribuyenteId) }, { name: 'concentracion-proveedores', fn: () => alertaConcentracionProveedores(pool, contribuyenteId) }, { name: 'riesgo-cambiario', fn: () => alertaRiesgoCambiario(pool, contribuyenteId) }, { name: 'riesgo-cancelaciones', fn: () => alertaRiesgoCancelaciones(pool, contribuyenteId) }, { name: 'riesgo-transaccional', fn: () => alertaRiesgoTransaccional(pool, contribuyenteId) }, { name: 'cancelacion-periodo-anterior', fn: () => alertaCancelacionPeriodoAnterior(pool, contribuyenteId) }, { name: 'opinion-cumplimiento', fn: () => alertaOpinionCumplimiento(pool, contribuyenteId) }, { name: 'tipo-relacion-sospechosa', fn: () => alertaTipoRelacionSospechosa(pool, contribuyenteId) }, { name: 'tareas-proximas-vencer', fn: () => alertaTareasProximasVencer(pool, contribuyenteId) }, { name: 'resico-pf-limite-ingresos', fn: () => alertaResicoPfLimiteIngresos(pool, contribuyenteId) }, ]; const alertas: AlertaAuto[] = []; for (const g of generadores) { try { const a = await g.fn(); if (a) alertas.push(a); } catch (err: any) { console.error(`[AlertasAuto] Fallo ${g.name} (tenant=${tenantId}, contribuyente=${contribuyenteId}):`, err.message || err); } } if (alertas.length > 0) { console.log(`[AlertasAuto] tenant=${tenantId} contribuyente=${contribuyenteId || 'null'} generadas=${alertas.map(a => a.id).join(', ')}`); } return alertas; } /** * 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> { 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 COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'))::int as año, EXTRACT(MONTH FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'))::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}`, })); }