Initial commit - Horux Despachos NL
This commit is contained in:
271
apps/api/src/services/calendario-fiscal.service.ts
Normal file
271
apps/api/src/services/calendario-fiscal.service.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
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 === '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;
|
||||
}
|
||||
Reference in New Issue
Block a user