- 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.
273 lines
9.1 KiB
TypeScript
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;
|
|
}
|