Files
HoruxDespachosNuevo/apps/api/src/services/calendario-fiscal.service.ts
Horux Dev 7df27ce66d chore: catálogo obligaciones, cierre automático, fixes SAT y facturación
- Catálogo de obligaciones fiscales expandido a 30 entradas con campo requierePago.
- Soporte de frecuencia cuatrimestral en obligaciones y declaraciones.
- Automatización de cierre de obligaciones fiscales desde Documentos › Declaraciones.
- Nuevas tablas obligacion_evidencias, obligacion_periodos estados y declaracion_obligaciones.
- Nuevo servicio obligacion-evidencias.service.ts y endpoints REST.
- Refactor de declaraciones.service.ts para vincular obligaciones y crear evidencias.
- Notificaciones por email para evidencias de obligaciones.
- Adjuntar PDFs en correo de declaración subida.
- Fix drill-down de CFDIs: carga completa al visualizar.
- Fix sincronización SAT: tipos P/N, UUID case-insensitive, no reutilizar requestId.
- Fix suscripciones pending en /configuracion/planes-despacho.
- Fix sugerencias de Clave Producto SAT: importar catálogo y robustecer autocomplete.
- Quitar toggle manual de completado en Configuración › Obligaciones fiscales › Tareas.
- Scripts de soporte para Demo Ventas y utilerías (change-user-email, resend-welcome, import-clave-prod-serv).
- Documentación de cambios en docs/CAMBIOS-2026-05-04.md.
2026-06-22 04:53:59 +00:00

273 lines
9.1 KiB
TypeScript

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<Set<string>> {
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<string>): 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<string>): 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<EventoGenerado[]> {
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<EventoGenerado[]> {
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<string, boolean>();
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;
}