700 lines
25 KiB
TypeScript
700 lines
25 KiB
TypeScript
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 - 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<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 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<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 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<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.
|
||
* 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<AlertaAuto[]> {
|
||
const generadores: { name: string; fn: () => Promise<AlertaAuto | null> }[] = [
|
||
{ 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<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 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}`,
|
||
}));
|
||
}
|