Files
HoruxDespachosNuevo/apps/api/src/services/alertas-manuales.service.ts

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]
);
}