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 { 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 { // 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 { 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 { 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 { 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 { 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 { 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> { // 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 = []; // 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(); const declaracionPresentadaMap = new Map(); const pagoPresentadoMap = new Map(); const declaracionMap = new Map(); 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; } 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 { 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 { await pool.query(` DELETE FROM obligacion_periodos WHERE obligacion_id = $1 AND periodo = $2 `, [obligacionId, periodo]); return true; }