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 = { '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 { 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 { await pool.query( `UPDATE alertas SET resuelta = true, leida = true WHERE id = $1`, [id] ); }