Files
HoruxDespachosNuevo/apps/api/src/services/obligaciones.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

532 lines
19 KiB
TypeScript

import type { Pool } from 'pg';
import { OBLIGACIONES_CATALOGO, getRecomendaciones, type ObligacionFiscal } from '../constants/obligaciones-fiscales.js';
function requierePagoPorCatalogo(catalogoId: string | null): boolean {
if (!catalogoId) return true;
return OBLIGACIONES_CATALOGO.find((o) => o.id === catalogoId)?.requierePago ?? true;
}
/**
* Keyword-based matching: each catalog entry has discriminant keywords
* that must ALL appear in the SAT description (normalized, lowercase, no accents).
* Multiple keyword sets per entry allow for variant phrasings.
*/
const CATALOG_MATCH_RULES: Array<{ id: string; keywords: string[][] }> = [
// ISR provisionales
{ id: 'isr-provisional', keywords: [
['pago provisional', 'isr', 'actividades empresariales'],
['pago provisional', 'isr personas morales', 'general'],
['pago provisional mensual de isr personas morales'],
]},
{ id: 'isr-resico-pm', keywords: [
['isr', 'simplificado de confianza', 'pago provisional'],
['isr', 'simplificado de confianza', 'pago provisional mensual'],
]},
{ id: 'isr-resico-pf', keywords: [
['isr', 'simplificado de confianza', 'ajuste anual'],
// Note: PF RESICO "pago provisional" is same id as PM; differentiate by RFC length at runtime
]},
// IVA
{ id: 'iva-mensual', keywords: [
['pago definitivo', 'iva', 'mensual'],
['pago definitivo mensual de iva'],
]},
// DIOT
{ id: 'diot', keywords: [
['proveedores', 'iva'],
['diot'],
]},
// Anuales
{ id: 'anual-isr-pm', keywords: [
['declaracion anual', 'isr', 'personas morales'],
['anual de isr del regimen', 'simplificado', 'personas morales'],
]},
{ id: 'anual-isr-pf', keywords: [
['declaracion anual', 'isr', 'personas fisicas'],
['ajuste anual', 'isr', 'declaracion anual', 'simplificado'],
]},
// Retenciones ISR
{ id: 'ret-isr-sueldos', keywords: [
['retenciones', 'isr', 'sueldos y salarios'],
['retenciones mensuales de isr por sueldos'],
]},
{ id: 'ret-isr-asimilados', keywords: [
['retenciones', 'isr', 'asimilados a salarios'],
['retenciones mensuales de isr por ingresos asimilados'],
]},
{ id: 'ret-isr-honorarios', keywords: [
['retencion', 'isr', 'servicios profesionales'],
['retenciones de isr por servicios profesionales'], // TPR variant (missing accent)
]},
// Retenciones IVA
{ id: 'ret-iva', keywords: [
['retenciones de iva'],
['retenciones', 'iva', 'mensual'],
]},
// IEPS
{ id: 'ieps', keywords: [
['ieps'],
]},
// RIF bimestral
{ id: 'isr-provisional', keywords: [
['bimestral del rif'],
['pago definitivo bimestral del rif'],
]},
// Arrendamiento
{ id: 'isr-provisional', keywords: [
['isr', 'arrendamiento de inmuebles', 'pago provisional'],
['isr por arrendamiento de inmuebles pf'],
]},
// Informativas (no tienen match directo en catálogo pero agrupar con DIM)
{ id: 'dim', keywords: [
['declaracion informativa anual', 'pagos y retenciones'],
['declaracion informativa anual de clientes y proveedores'],
['declaracion informativa anual de retenciones'],
['declaracion informativa de iva con la anual'],
]},
];
function normalizeForMatch(s: string): string {
return s
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[.,;:()]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function matchCsfToCatalog(descripcion: string, rfc: string): ObligacionFiscal | undefined {
const norm = normalizeForMatch(descripcion);
const esPM = rfc.length === 12;
for (const rule of CATALOG_MATCH_RULES) {
for (const kwSet of rule.keywords) {
const allMatch = kwSet.every(kw => norm.includes(normalizeForMatch(kw)));
if (allMatch) {
// Special case: RESICO ISR provisional — PM vs PF
if (rule.id === 'isr-resico-pm' && !esPM) {
return OBLIGACIONES_CATALOGO.find(c => c.id === 'isr-resico-pf');
}
if (rule.id === 'isr-resico-pf' && esPM) {
return OBLIGACIONES_CATALOGO.find(c => c.id === 'isr-resico-pm');
}
return OBLIGACIONES_CATALOGO.find(c => c.id === rule.id);
}
}
}
return undefined;
}
export interface ObligacionContribuyente {
id: string;
contribuyenteId: string;
catalogoId: string | null;
nombre: string;
fundamento: string | null;
frecuencia: string | null;
fechaLimite: string | null;
categoria: string | null;
activa: boolean;
esRecomendada: boolean;
esCustom: boolean;
completada: boolean;
completadaAt: string | null;
completadaPor: string | null;
periodoCompletado: string | null;
createdAt?: string;
auxiliarAsignadoId?: string | null;
auxiliarAsignadoNombre?: string | null;
}
export function getCatalogo(): ObligacionFiscal[] {
return OBLIGACIONES_CATALOGO;
}
export async function getObligaciones(pool: Pool, contribuyenteId: string): Promise<ObligacionContribuyente[]> {
const { rows } = await pool.query(`
SELECT
oc.id, oc.contribuyente_id AS "contribuyenteId", oc.catalogo_id AS "catalogoId",
oc.nombre, oc.fundamento, oc.frecuencia, oc.fecha_limite AS "fechaLimite", oc.categoria,
oc.activa, oc.es_recomendada AS "esRecomendada", oc.es_custom AS "esCustom",
oc.completada, oc.completada_at AS "completadaAt", oc.completada_por AS "completadaPor",
oc.periodo_completado AS "periodoCompletado",
oc.created_at AS "createdAt",
oa.auxiliar_user_id AS "auxiliarAsignadoId"
FROM obligaciones_contribuyente oc
LEFT JOIN obligacion_asignaciones oa ON oa.obligacion_id = oc.id
WHERE oc.contribuyente_id = $1
ORDER BY oc.categoria, oc.nombre
`, [contribuyenteId]);
return rows;
}
/**
* Reads obligations from the latest Constancia de Situación Fiscal (CSF)
* and populates obligaciones_contribuyente. Falls back to catalog-based
* recommendations if no CSF exists.
*/
export async function initRecomendaciones(
pool: Pool,
contribuyenteId: string,
rfc: string,
regimenes: string[],
tieneNomina: boolean
): Promise<number> {
// Clean up alerts and periodos for existing recommended obligations before replacing
await pool.query(
`DELETE FROM alertas WHERE tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND es_recomendada = true
)`,
[contribuyenteId],
);
await pool.query(
`DELETE FROM obligacion_periodos WHERE obligacion_id IN (
SELECT id FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND es_recomendada = true
)`,
[contribuyenteId],
);
// Clear previous recommended obligations (re-init replaces them)
await pool.query(
`DELETE FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND es_recomendada = true`,
[contribuyenteId],
);
// Try to get obligations from the latest CSF
const { rows: csfRows } = await pool.query(`
SELECT datos->'obligaciones' as obligaciones
FROM constancias_situacion_fiscal
WHERE rfc = $1
ORDER BY created_at DESC LIMIT 1
`, [rfc]);
const csfObligaciones = csfRows[0]?.obligaciones as Array<{
descripcion: string;
fechaInicio: string;
fechaFin?: string;
descripcionVencimiento: string;
}> | null;
let count = 0;
if (csfObligaciones && csfObligaciones.length > 0) {
// Use CSF obligations directly — these are the official SAT obligations
// Only import ACTIVE obligations (no fechaFin = still in effect)
const activeCsf = csfObligaciones.filter(ob => !ob.fechaFin);
for (const ob of activeCsf) {
// Keyword-based matching against catalog for enrichment (fundamento, categoria)
const catalogMatch = matchCsfToCatalog(ob.descripcion, rfc);
const { rowCount } = await pool.query(`
INSERT INTO obligaciones_contribuyente (
contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, es_recomendada
) VALUES ($1, $2, $3, $4, $5, $6, $7, true)
ON CONFLICT DO NOTHING
`, [
contribuyenteId,
catalogMatch?.id || null,
ob.descripcion,
catalogMatch?.fundamento || null,
catalogMatch?.frecuencia || inferirFrecuencia(ob.descripcionVencimiento),
ob.descripcionVencimiento,
catalogMatch?.categoria || 'SAT',
]);
count += rowCount ?? 0;
}
} else {
// Fallback: use catalog-based recommendations
const recomendadas = getRecomendaciones(rfc, regimenes, tieneNomina);
for (const ob of recomendadas) {
const { rowCount } = await pool.query(`
INSERT INTO obligaciones_contribuyente (contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, es_recomendada)
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
ON CONFLICT DO NOTHING
`, [contribuyenteId, ob.id, ob.nombre, ob.fundamento, ob.frecuencia, ob.fechaLimite, ob.categoria]);
count += rowCount ?? 0;
}
}
return count;
}
function inferirFrecuencia(vencimiento: string): string {
const lower = vencimiento.toLowerCase();
if (lower.includes('mensual') || lower.includes('mes')) return 'mensual';
if (lower.includes('cuatrimest')) return 'cuatrimestral';
if (lower.includes('bimest')) return 'bimestral';
if (lower.includes('trimest')) return 'trimestral';
if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual';
return 'mensual';
}
export async function completeObligacion(pool: Pool, obligacionId: string, userId: string, periodo: string): Promise<boolean> {
const { rowCount } = await pool.query(
'UPDATE obligaciones_contribuyente SET completada = true, completada_at = now(), completada_por = $2, periodo_completado = $3 WHERE id = $1',
[obligacionId, userId, periodo]
);
return (rowCount ?? 0) > 0;
}
export async function uncompleteObligacion(pool: Pool, obligacionId: string): Promise<boolean> {
const { rowCount } = await pool.query(
'UPDATE obligaciones_contribuyente SET completada = false, completada_at = null, completada_por = null, periodo_completado = null WHERE id = $1',
[obligacionId]
);
return (rowCount ?? 0) > 0;
}
export async function addObligacion(pool: Pool, contribuyenteId: string, data: {
catalogoId?: string;
nombre: string;
fundamento?: string;
frecuencia?: string;
fechaLimite?: string;
categoria?: string;
}): Promise<ObligacionContribuyente> {
const isFromCatalog = !!data.catalogoId;
const { rows: [row] } = await pool.query(`
INSERT INTO obligaciones_contribuyente (contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, es_custom)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, contribuyente_id AS "contribuyenteId", catalogo_id AS "catalogoId",
nombre, fundamento, frecuencia, fecha_limite AS "fechaLimite", categoria,
activa, es_recomendada AS "esRecomendada", es_custom AS "esCustom"
`, [contribuyenteId, data.catalogoId || null, data.nombre, data.fundamento || null,
data.frecuencia || null, data.fechaLimite || null, data.categoria || 'Custom',
!isFromCatalog]);
return row;
}
export async function removeObligacion(pool: Pool, obligacionId: string): Promise<boolean> {
const { rowCount } = await pool.query(
'UPDATE obligaciones_contribuyente SET activa = false WHERE id = $1',
[obligacionId]
);
// Clean up alerts generated for this obligation (tipo format: 'ob-{obligacionId}-{periodo}')
await pool.query(
`DELETE FROM alertas WHERE tipo LIKE $1`,
[`ob-${obligacionId}-%`],
);
// Clean up completion records
await pool.query(
'DELETE FROM obligacion_periodos WHERE obligacion_id = $1',
[obligacionId],
);
return (rowCount ?? 0) > 0;
}
export async function restoreObligacion(pool: Pool, obligacionId: string): Promise<boolean> {
const { rowCount } = await pool.query(
'UPDATE obligaciones_contribuyente SET activa = true WHERE id = $1',
[obligacionId]
);
return (rowCount ?? 0) > 0;
}
/**
* Returns obligations for a specific period (YYYY-MM) for a contribuyente.
* Includes:
* - All active obligations that apply to this period (based on frequency)
* - Completion status from obligacion_periodos table
* - Past-due obligations from previous periods that were NOT completed
*/
export interface DeclaracionLink {
id: number;
año: number;
mes: number;
tipo: 'normal' | 'complementaria';
pdfFilename: string | null;
}
export async function getObligacionesPorPeriodo(
pool: Pool,
contribuyenteId: string,
periodo: string, // "2026-04"
incluirAtrasados: boolean = true
): Promise<Array<ObligacionContribuyente & { periodStatus: 'pendiente' | 'completada' | 'atrasada'; periodoAplica: string; declaracion: DeclaracionLink | null }>> {
// Get all active obligations for this contribuyente
const obligaciones = await getObligaciones(pool, contribuyenteId);
const activas = obligaciones.filter(o => o.activa);
const [year, month] = periodo.split('-').map(Number);
const currentPeriodo = new Date().toISOString().substring(0, 7);
const results: Array<ObligacionContribuyente & {
periodStatus: string;
periodoAplica: string;
declaracion: DeclaracionLink | null;
declaracionPresentada: boolean;
pagoPresentado: boolean;
requierePago: boolean;
}> = [];
// Get all completion records + associated declaration info for this contribuyente
const { rows: completions } = await pool.query<{
obligacion_id: string;
periodo: string;
completada: boolean;
declaracion_presentada: boolean;
pago_presentado: boolean;
declaracion_id: number | null;
decl_año: number | null;
decl_mes: number | null;
decl_tipo: 'normal' | 'complementaria' | null;
decl_pdf_filename: string | null;
}>(`
SELECT op.obligacion_id, op.periodo, op.completada,
op.declaracion_presentada, op.pago_presentado,
op.declaracion_id,
dp.año AS decl_año,
dp.mes AS decl_mes,
dp.tipo AS decl_tipo,
dp.pdf_filename AS decl_pdf_filename
FROM obligacion_periodos op
JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id
LEFT JOIN declaraciones_provisionales dp ON dp.id = op.declaracion_id
WHERE oc.contribuyente_id = $1
`, [contribuyenteId]);
const completionMap = new Map<string, boolean>();
const declaracionPresentadaMap = new Map<string, boolean>();
const pagoPresentadoMap = new Map<string, boolean>();
const declaracionMap = new Map<string, DeclaracionLink | null>();
for (const c of completions) {
const key = `${c.obligacion_id}:${c.periodo}`;
completionMap.set(key, c.completada);
declaracionPresentadaMap.set(key, c.declaracion_presentada);
pagoPresentadoMap.set(key, c.pago_presentado);
if (c.declaracion_id && c.decl_año != null && c.decl_mes != null && c.decl_tipo) {
declaracionMap.set(key, {
id: c.declaracion_id,
año: c.decl_año,
mes: c.decl_mes,
tipo: c.decl_tipo,
pdfFilename: c.decl_pdf_filename,
});
}
}
for (const ob of activas) {
// Obligations only apply from the month they were created forward
const obStartPeriodo = ob.createdAt
? new Date(ob.createdAt).toISOString().substring(0, 7)
: '2000-01';
// Check if this obligation applies to the requested period
if (periodo >= obStartPeriodo && appliesTo(ob.frecuencia, periodo)) {
const key = `${ob.id}:${periodo}`;
const isCompleted = completionMap.get(key) === true;
results.push({
...ob,
periodStatus: isCompleted ? 'completada' : 'pendiente',
periodoAplica: periodo,
declaracion: declaracionMap.get(key) ?? null,
declaracionPresentada: declaracionPresentadaMap.get(key) === true,
pagoPresentado: pagoPresentadoMap.get(key) === true,
requierePago: requierePagoPorCatalogo(ob.catalogoId),
});
}
// Check past-due (previous periods not completed) — only if requested
if (incluirAtrasados && periodo >= currentPeriodo) {
// Look back up to 12 months for overdue items
for (let i = 1; i <= 12; i++) {
let pm = month - i;
let py = year;
while (pm < 1) { pm += 12; py--; }
const pastPeriodo = `${py}-${String(pm).padStart(2, '0')}`;
if (pastPeriodo >= currentPeriodo) continue; // only past periods
if (pastPeriodo < obStartPeriodo) continue; // don't go before obligation was created
if (!appliesTo(ob.frecuencia, pastPeriodo)) continue;
const pastKey = `${ob.id}:${pastPeriodo}`;
const pastCompleted = completionMap.get(pastKey) === true;
if (!pastCompleted) {
// Don't add duplicates
if (!results.find(r => r.id === ob.id && r.periodoAplica === pastPeriodo)) {
results.push({
...ob,
periodStatus: 'atrasada',
periodoAplica: pastPeriodo,
declaracion: null,
declaracionPresentada: declaracionPresentadaMap.get(pastKey) === true,
pagoPresentado: pagoPresentadoMap.get(pastKey) === true,
requierePago: requierePagoPorCatalogo(ob.catalogoId),
});
}
}
}
}
}
// Sort: atrasadas first, then by name
results.sort((a, b) => {
if (a.periodStatus === 'atrasada' && b.periodStatus !== 'atrasada') return -1;
if (b.periodStatus === 'atrasada' && a.periodStatus !== 'atrasada') return 1;
return a.nombre.localeCompare(b.nombre);
});
return results as Array<ObligacionContribuyente & {
periodStatus: 'pendiente' | 'completada' | 'atrasada';
periodoAplica: string;
declaracion: DeclaracionLink | null;
declaracionPresentada: boolean;
pagoPresentado: boolean;
requierePago: boolean;
}>;
}
function appliesTo(frecuencia: string | null, periodo: string): boolean {
const [, month] = periodo.split('-').map(Number);
switch (frecuencia) {
case 'mensual': return true;
case 'bimestral': return month % 2 === 1; // Jan, Mar, May...
case 'trimestral': return [1, 4, 7, 10].includes(month);
case 'cuatrimestral': return [1, 5, 9].includes(month);
case 'anual': return month === 3 || month === 4; // March (PM) or April (PF) — show in both
case 'eventual': return false; // Don't auto-show
default: return true;
}
}
/**
* Mark an obligation as completed for a specific period
*/
export async function completePeriodo(
pool: Pool,
obligacionId: string,
periodo: string,
userId: string,
notas?: string
): Promise<boolean> {
await pool.query(`
INSERT INTO obligacion_periodos (obligacion_id, periodo, completada, completada_at, completada_por, notas)
VALUES ($1, $2, true, now(), $3, $4)
ON CONFLICT (obligacion_id, periodo)
DO UPDATE SET completada = true, completada_at = now(), completada_por = $3, notas = COALESCE($4, obligacion_periodos.notas)
`, [obligacionId, periodo, userId, notas || null]);
return true;
}
/**
* Unmark an obligation completion for a specific period
*/
export async function uncompletePeriodo(
pool: Pool,
obligacionId: string,
periodo: string
): Promise<boolean> {
await pool.query(`
DELETE FROM obligacion_periodos WHERE obligacion_id = $1 AND periodo = $2
`, [obligacionId, periodo]);
return true;
}