300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
import type { Pool } from 'pg';
|
|
import { prisma } from '../config/database.js';
|
|
import { generarEventosFiscales } from './calendario-fiscal.service.js';
|
|
import { isDespachoTenant } from '@horux/shared';
|
|
|
|
interface AlertaManualGenerada {
|
|
tipo: string;
|
|
titulo: string;
|
|
mensaje: string;
|
|
prioridad: 'alta' | 'media';
|
|
fechaVencimiento: string;
|
|
}
|
|
|
|
// Mapeo de eventos del calendario a tipos de alerta (legacy Horux360)
|
|
const EVENTO_A_ALERTA: Record<string, { prefijo: string; prioridad: 'alta' | 'media' }> = {
|
|
'Declaración mensual ISR': { prefijo: 'decl-isr', prioridad: 'alta' },
|
|
'Declaración mensual IVA': { prefijo: 'decl-iva', prioridad: 'alta' },
|
|
'Declaración mensual IEPS': { prefijo: 'decl-ieps', prioridad: 'media' },
|
|
'Pago provisional ISR': { prefijo: 'pago-isr', prioridad: 'alta' },
|
|
'Pago provisional IVA': { prefijo: 'pago-iva', prioridad: 'alta' },
|
|
'Pago provisional IEPS': { prefijo: 'pago-ieps', prioridad: 'media' },
|
|
'Declaración de sueldos y salarios': { prefijo: 'decl-sueldos', prioridad: 'media' },
|
|
'DIOT': { prefijo: 'diot', prioridad: 'media' },
|
|
'Contabilidad electrónica': { prefijo: 'contabilidad', prioridad: 'media' },
|
|
'Declaración anual PM': { prefijo: 'decl-anual-pm', prioridad: 'alta' },
|
|
'Declaración anual PF': { prefijo: 'decl-anual-pf', prioridad: 'alta' },
|
|
'Informativa Sueldos y Salarios': { prefijo: 'inf-sueldos', prioridad: 'media' },
|
|
};
|
|
|
|
/**
|
|
* For despachos: generate alerts from the contribuyente's actual obligations
|
|
* (obligaciones_contribuyente) instead of the static fiscal calendar.
|
|
* Only generates alerts for obligations that the contribuyente actually has.
|
|
*/
|
|
async function sincronizarDesdeObligacionesContribuyente(
|
|
pool: Pool,
|
|
contribuyenteId: string,
|
|
): Promise<{ creadas: number; existentes: number }> {
|
|
const hoy = new Date();
|
|
const currentPeriodo = hoy.toISOString().substring(0, 7); // "2026-04"
|
|
|
|
// Get active obligations for this contribuyente
|
|
const { rows: obligaciones } = await pool.query(`
|
|
SELECT id, nombre, frecuencia, fecha_limite AS "fechaLimite", created_at AS "createdAt"
|
|
FROM obligaciones_contribuyente
|
|
WHERE contribuyente_id = $1 AND activa = true
|
|
`, [contribuyenteId]);
|
|
|
|
// Get existing completions
|
|
const { rows: completions } = await pool.query(`
|
|
SELECT op.obligacion_id, op.periodo
|
|
FROM obligacion_periodos op
|
|
JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id
|
|
WHERE oc.contribuyente_id = $1 AND op.completada = true
|
|
`, [contribuyenteId]);
|
|
const completionSet = new Set(completions.map(c => `${c.obligacion_id}:${c.periodo}`));
|
|
|
|
let creadas = 0;
|
|
let existentes = 0;
|
|
|
|
for (const ob of obligaciones) {
|
|
const obStartPeriodo = ob.createdAt
|
|
? new Date(ob.createdAt).toISOString().substring(0, 7)
|
|
: '2000-01';
|
|
|
|
// Check current and previous month
|
|
for (let offset = 0; offset <= 1; offset++) {
|
|
const d = new Date(hoy.getFullYear(), hoy.getMonth() - offset, 1);
|
|
const periodo = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
|
|
if (periodo < obStartPeriodo) continue;
|
|
if (!appliesToPeriod(ob.frecuencia, periodo)) continue;
|
|
if (completionSet.has(`${ob.id}:${periodo}`)) continue;
|
|
|
|
// Generate alert type unique per obligation+period
|
|
const tipoUnico = `ob-${ob.id}-${periodo}`;
|
|
|
|
const { rows: existing } = await pool.query(
|
|
`SELECT id FROM alertas WHERE tipo = $1`,
|
|
[tipoUnico],
|
|
);
|
|
|
|
if (existing.length > 0) {
|
|
existentes++;
|
|
continue;
|
|
}
|
|
|
|
// Determine deadline (day 17 of next month for mensual)
|
|
const [y, m] = periodo.split('-').map(Number);
|
|
const nextMonth = m === 12 ? 1 : m + 1;
|
|
const nextYear = m === 12 ? y + 1 : y;
|
|
const fechaVencimiento = `${nextYear}-${String(nextMonth).padStart(2, '0')}-17`;
|
|
|
|
const deadlineDate = new Date(fechaVencimiento + 'T23:59:59');
|
|
const isPastDue = deadlineDate < hoy;
|
|
const prioridad = isPastDue ? 'alta' : 'media';
|
|
const statusLabel = isPastDue ? 'Vencida' : 'Pendiente';
|
|
|
|
await pool.query(`
|
|
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
`, [
|
|
tipoUnico,
|
|
`${ob.nombre} - ${statusLabel}`,
|
|
`${ob.fechaLimite || 'Sin fecha límite especificada'}. Periodo: ${periodo}`,
|
|
prioridad,
|
|
fechaVencimiento,
|
|
]);
|
|
creadas++;
|
|
}
|
|
}
|
|
|
|
return { creadas, existentes };
|
|
}
|
|
|
|
function appliesToPeriod(frecuencia: string | null, periodo: string): boolean {
|
|
const [, month] = periodo.split('-').map(Number);
|
|
switch (frecuencia) {
|
|
case 'mensual': return true;
|
|
case 'bimestral': return month % 2 === 1;
|
|
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
|
case 'anual': return month === 3 || month === 4;
|
|
case 'eventual': return false;
|
|
default: return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Genera alertas manuales para eventos fiscales vencidos que no han sido resueltos.
|
|
* Para despachos: usa obligaciones per-contribuyente.
|
|
* Para Horux360: usa el calendario fiscal estático (legacy).
|
|
*/
|
|
export async function sincronizarAlertasManuales(
|
|
pool: Pool,
|
|
tenantId: string,
|
|
contribuyenteId?: string | null,
|
|
): Promise<{ creadas: number; existentes: number }> {
|
|
// Check if this is a despacho tenant
|
|
const tenant = await prisma.tenant.findUnique({
|
|
where: { id: tenantId },
|
|
select: { rfc: true, createdAt: true },
|
|
});
|
|
if (!tenant) return { creadas: 0, existentes: 0 };
|
|
|
|
// Despacho: use per-contribuyente obligations
|
|
if (isDespachoTenant(tenant.rfc)) {
|
|
if (contribuyenteId) {
|
|
return sincronizarDesdeObligacionesContribuyente(pool, contribuyenteId);
|
|
}
|
|
// "Todos los RFCs": don't generate new alerts — individual contribuyente alerts already exist
|
|
return { creadas: 0, existentes: 0 };
|
|
}
|
|
|
|
// Legacy Horux360: use static fiscal calendar
|
|
const hoy = new Date();
|
|
const añoActual = hoy.getFullYear();
|
|
const fechaCreacion = tenant.createdAt || hoy;
|
|
|
|
const eventosActual = await generarEventosFiscales(tenantId, añoActual);
|
|
const eventosAnterior = await generarEventosFiscales(tenantId, añoActual - 1);
|
|
const todosEventos = [...eventosAnterior, ...eventosActual];
|
|
|
|
const vencidos = todosEventos.filter(e => {
|
|
const fecha = new Date(e.fechaLimite + 'T23:59:59');
|
|
return fecha < hoy && fecha >= fechaCreacion;
|
|
});
|
|
|
|
let creadas = 0;
|
|
let existentes = 0;
|
|
|
|
for (const evento of vencidos) {
|
|
const config = EVENTO_A_ALERTA[evento.titulo];
|
|
if (!config) continue;
|
|
|
|
const tipoUnico = `${config.prefijo}-${evento.fechaLimite}`;
|
|
|
|
const { rows: existing } = await pool.query(
|
|
`SELECT id, resuelta FROM alertas WHERE tipo = $1`,
|
|
[tipoUnico]
|
|
);
|
|
|
|
if (existing.length > 0) {
|
|
existentes++;
|
|
continue;
|
|
}
|
|
|
|
const esPago = evento.titulo.startsWith('Pago');
|
|
const accion = esPago ? 'Pago pendiente' : 'No presentada';
|
|
|
|
await pool.query(`
|
|
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
`, [
|
|
tipoUnico,
|
|
`${evento.titulo} - ${accion}`,
|
|
`${evento.descripcion}. Fecha limite: ${new Date(evento.fechaLimite + 'T00:00:00').toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' })}`,
|
|
config.prioridad,
|
|
evento.fechaLimite,
|
|
]);
|
|
|
|
creadas++;
|
|
}
|
|
|
|
return { creadas, existentes };
|
|
}
|
|
|
|
/**
|
|
* Obtiene alertas manuales pendientes (no resueltas).
|
|
* Filters by contribuyente or by user's accessible contribuyentes (for clientes).
|
|
*/
|
|
export async function getAlertasManualesPendientes(
|
|
pool: Pool,
|
|
contribuyenteId?: string | null,
|
|
userId?: string | null,
|
|
role?: string | null,
|
|
): Promise<any[]> {
|
|
let contribuyenteFilter = '';
|
|
const params: unknown[] = [];
|
|
|
|
if (contribuyenteId) {
|
|
// Specific contribuyente selected
|
|
params.push(contribuyenteId);
|
|
contribuyenteFilter = `AND (
|
|
tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
|
SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id = $${params.length}
|
|
)
|
|
)`;
|
|
} else if (role === 'cliente' && userId) {
|
|
// Client with "Todos los RFCs" — only their accessible contribuyentes
|
|
params.push(userId);
|
|
contribuyenteFilter = `AND (
|
|
tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
|
SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id IN (
|
|
SELECT entidad_id FROM cliente_accesos WHERE user_id = $${params.length}
|
|
)
|
|
)
|
|
)`;
|
|
} else if (role === 'auxiliar' && userId) {
|
|
// Auxiliar: only their subcarteras' contribuyentes
|
|
params.push(userId);
|
|
contribuyenteFilter = `AND (
|
|
tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
|
SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id IN (
|
|
SELECT ce.entidad_id FROM cartera_entidades ce
|
|
JOIN carteras c ON c.id = ce.cartera_id
|
|
WHERE c.auxiliar_user_id = $${params.length}
|
|
UNION
|
|
SELECT ce.entidad_id FROM cartera_entidades ce
|
|
JOIN cartera_auxiliares ca ON ca.cartera_id = ce.cartera_id
|
|
WHERE ca.auxiliar_user_id = $${params.length}
|
|
)
|
|
)
|
|
)`;
|
|
} else if (role === 'supervisor' && userId) {
|
|
// Supervisor: only their carteras' contribuyentes
|
|
params.push(userId);
|
|
contribuyenteFilter = `AND (
|
|
tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
|
SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id IN (
|
|
SELECT ce.entidad_id FROM cartera_entidades ce
|
|
JOIN carteras c ON c.id = ce.cartera_id AND c.parent_id IS NULL
|
|
WHERE c.supervisor_user_id = $${params.length}
|
|
)
|
|
)
|
|
)`;
|
|
}
|
|
|
|
// Exclude alerts for inactive obligations
|
|
const inactiveFilter = `AND NOT (
|
|
tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
|
SELECT id::text FROM obligaciones_contribuyente WHERE activa = false
|
|
)
|
|
)`;
|
|
|
|
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
|
|
${inactiveFilter}
|
|
${contribuyenteFilter}
|
|
ORDER BY
|
|
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
|
|
fecha_vencimiento ASC
|
|
`, params);
|
|
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Marca una alerta como resuelta (presentada/pagada).
|
|
*/
|
|
export async function resolverAlerta(pool: Pool, id: string): Promise<void> {
|
|
await pool.query(
|
|
`UPDATE alertas SET resuelta = true, leida = true WHERE id = $1`,
|
|
[id]
|
|
);
|
|
}
|