import { prisma } from '../config/database.js'; import { getRegimenesActivosClaves } from './regimen.service.js'; import { getObligaciones } from './obligaciones.service.js'; import type { Pool } from 'pg'; interface EventoGenerado { titulo: string; tipo: string; fechaLimite: string; recurrencia: string; completado: boolean; descripcion: string; } /** * Obtener días inhábiles del año como Set de strings 'YYYY-MM-DD' */ async function getDiasInhabiles(año: number): Promise> { const rows = await prisma.diaInhabil.findMany({ where: { fecha: { gte: new Date(`${año}-01-01`), lte: new Date(`${año}-12-31`), }, }, }); return new Set(rows.map(r => r.fecha.toISOString().split('T')[0])); } /** * Si la fecha cae en día inhábil (sábado, domingo, festivo), recorrer al siguiente día hábil */ function siguienteDiaHabil(fecha: Date, inhabiles: Set): Date { const d = new Date(fecha); while (true) { const dow = d.getDay(); const str = d.toISOString().split('T')[0]; if (dow !== 0 && dow !== 6 && !inhabiles.has(str)) { return d; } d.setDate(d.getDate() + 1); } } /** * Agregar N días hábiles a una fecha */ function agregarDiasHabiles(fecha: Date, dias: number, inhabiles: Set): Date { const d = new Date(fecha); let added = 0; while (added < dias) { d.setDate(d.getDate() + 1); const dow = d.getDay(); const str = d.toISOString().split('T')[0]; if (dow !== 0 && dow !== 6 && !inhabiles.has(str)) { added++; } } return d; } /** * Calcula días adicionales por RFC según Resolución Miscelánea Fiscal * Sexto dígito numérico del RFC: * 1-2: +1 día, 3-4: +2, 5-6: +3, 7-8: +4, 9-0: +5 */ function diasExtensionRfc(rfc: string): number { // Extraer sexto dígito numérico const numeros = rfc.replace(/[^0-9]/g, ''); if (numeros.length < 6) return 0; const sexto = parseInt(numeros[5]); if (sexto === 1 || sexto === 2) return 1; if (sexto === 3 || sexto === 4) return 2; if (sexto === 5 || sexto === 6) return 3; if (sexto === 7 || sexto === 8) return 4; return 5; // 9 o 0 } /** * Genera los eventos fiscales para un tenant en un año dado, * basándose en el catálogo central y las reglas del SAT. */ export async function generarEventosFiscales( tenantId: string, año: number, ): Promise { const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { rfc: true }, }); if (!tenant) return []; const rfc = tenant.rfc; const inhabiles = await getDiasInhabiles(año); // Regímenes activos del tenant const activos = await getRegimenesActivosClaves(tenantId); const activosSet = new Set(activos); const catalogo = await prisma.eventoFiscalCatalogo.findMany({ where: { activo: true }, }); const eventos: EventoGenerado[] = []; const hoy = new Date(); for (const cat of catalogo) { // Filtrar por régimen: si el evento es para regímenes específicos, // verificar que el tenant tenga al menos uno de ellos activo if (cat.regimenes !== 'todos' && activos.length > 0) { const regimenesEvento = cat.regimenes.split(',').map(r => r.trim()); const aplica = regimenesEvento.some(r => activosSet.has(r)); if (!aplica) continue; } if (cat.recurrencia === 'mensual') { for (let mes = 1; mes <= 12; mes++) { // Mes relativo: 1 = mes posterior al que se declara const mesObligacion = mes; // mes que se declara const mesVencimiento = mes + cat.mesRelativo; const añoVencimiento = mesVencimiento > 12 ? año + 1 : año; const mesReal = mesVencimiento > 12 ? mesVencimiento - 12 : mesVencimiento; // Fecha base const lastDay = new Date(añoVencimiento, mesReal, 0).getDate(); const dia = Math.min(cat.diaBase, lastDay); let fechaLimite = new Date(añoVencimiento, mesReal - 1, dia); // Ajustar a día hábil fechaLimite = siguienteDiaHabil(fechaLimite, inhabiles); // Extensión por RFC if (cat.usaExtensionRfc) { const diasExtra = diasExtensionRfc(rfc); fechaLimite = agregarDiasHabiles(fechaLimite, diasExtra, inhabiles); } const completado = fechaLimite < hoy; const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']; eventos.push({ titulo: cat.titulo, tipo: cat.tipo, fechaLimite: fechaLimite.toISOString().split('T')[0], recurrencia: cat.recurrencia, completado, descripcion: `${cat.titulo} — ${meses[mesObligacion - 1]} ${año}`, }); } } else if (cat.recurrencia === 'anual' && cat.mesFijo) { const lastDay = new Date(año, cat.mesFijo, 0).getDate(); const dia = Math.min(cat.diaBase, lastDay); let fechaLimite = new Date(año, cat.mesFijo - 1, dia); fechaLimite = siguienteDiaHabil(fechaLimite, inhabiles); eventos.push({ titulo: cat.titulo, tipo: cat.tipo, fechaLimite: fechaLimite.toISOString().split('T')[0], recurrencia: cat.recurrencia, completado: fechaLimite < hoy, descripcion: `${cat.titulo} — Ejercicio ${año - 1}`, }); } } // Ordenar por fecha eventos.sort((a, b) => a.fechaLimite.localeCompare(b.fechaLimite)); return eventos; } /** * Genera eventos de calendario a partir de las obligaciones reales de un contribuyente. * Usado en tenants despacho — reemplaza el catálogo estático por las obligaciones * registradas en obligaciones_contribuyente con su estado de cumplimiento en obligacion_periodos. */ export async function generarEventosDesdeObligaciones( pool: Pool, contribuyenteId: string | null, año: number, ): Promise { if (!contribuyenteId) return []; const inhabiles = await getDiasInhabiles(año); const obligaciones = await getObligaciones(pool, contribuyenteId); const activas = obligaciones.filter(o => o.activa); const eventos: EventoGenerado[] = []; // Get completion records for this contribuyente const { rows: completions } = await pool.query(` SELECT op.obligacion_id, op.periodo, op.completada FROM obligacion_periodos op JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id WHERE oc.contribuyente_id = $1 `, [contribuyenteId]); const completionMap = new Map(); for (const c of completions) { completionMap.set(`${c.obligacion_id}:${c.periodo}`, c.completada); } const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']; for (const ob of activas) { const freq = ob.frecuencia || 'mensual'; // Determine which months this obligation applies to const monthsToGenerate: number[] = []; for (let m = 1; m <= 12; m++) { if (freq === 'mensual') monthsToGenerate.push(m); else if (freq === 'bimestral' && m % 2 === 1) monthsToGenerate.push(m); else if (freq === 'trimestral' && [1, 4, 7, 10].includes(m)) monthsToGenerate.push(m); else if (freq === 'cuatrimestral' && [1, 5, 9].includes(m)) monthsToGenerate.push(m); else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m); // 'eventual' and unknown: skip auto-generation } for (const mes of monthsToGenerate) { // Parse day from fechaLimite text; default to 17 let diaBase = 17; if (ob.fechaLimite) { const matchDia = ob.fechaLimite.match(/d[íi]a?\s*(\d+)/i); if (matchDia) diaBase = parseInt(matchDia[1]); // "Último día" → last day of month if (ob.fechaLimite.toLowerCase().includes('ltimo d')) diaBase = 0; } // Deadline is usually next month for mensual/bimestral/trimestral obligations let mesVencimiento = mes + 1; let añoVencimiento = año; if (mesVencimiento > 12) { mesVencimiento = 1; añoVencimiento++; } // For annual obligations the deadline month IS the month (marzo/abril) if (freq === 'anual') { mesVencimiento = mes; añoVencimiento = año; } const lastDayOfMonth = new Date(añoVencimiento, mesVencimiento, 0).getDate(); const dia = diaBase === 0 ? lastDayOfMonth : Math.min(diaBase, lastDayOfMonth); let fechaLimite = new Date(añoVencimiento, mesVencimiento - 1, dia); fechaLimite = siguienteDiaHabil(fechaLimite, inhabiles); const periodo = `${año}-${String(mes).padStart(2, '0')}`; const isCompleted = completionMap.get(`${ob.id}:${periodo}`) === true; const isPastDue = !isCompleted && fechaLimite < new Date(); // Type encodes the status for calendar coloring const tipoEvento = isCompleted ? 'obligacion-completada' : isPastDue ? 'obligacion-atrasada' : 'obligacion-pendiente'; eventos.push({ titulo: ob.nombre, tipo: tipoEvento, fechaLimite: fechaLimite.toISOString().split('T')[0], recurrencia: freq, completado: isCompleted, descripcion: `${ob.nombre} — ${meses[mes - 1]} ${año}`, }); } } eventos.sort((a, b) => a.fechaLimite.localeCompare(b.fechaLimite)); return eventos; }