import type { Pool } from 'pg'; import { createEvidencia } from './obligacion-evidencias.service.js'; function normalize(s: string): string { return s .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/[.,;:()]/g, '') .trim(); } /** * Dadas las obligaciones seleccionadas para una declaración, infiere los * impuestos que cubre. Se usa para mantener la resolución de alertas legacy * (decl-*, pago-*) sin exponer el campo en la UI. */ function inferirImpuestosDeObligaciones( obligaciones: Array<{ id: string; nombre: string; catalogoId?: string | null }>, ): Impuesto[] { const set = new Set(); for (const ob of obligaciones) { const nombre = normalize(ob.nombre); const catalogoId = normalize(ob.catalogoId || ''); if (nombre.includes('diot') || catalogoId.includes('diot')) { set.add('DIOT'); } else if (nombre.includes('iva') || catalogoId.includes('iva')) { set.add('IVA'); } if (nombre.includes('isr') || catalogoId.includes('isr')) set.add('ISR'); if (nombre.includes('ieps') || catalogoId.includes('ieps')) set.add('IEPS'); if (nombre.includes('isn') || catalogoId.includes('isn')) set.add('ISN'); if (nombre.includes('ish') || catalogoId.includes('ish')) set.add('ISH'); } return Array.from(set); } // Mapeo: impuesto de la declaración → reglas para matchear obligaciones del // contribuyente. `include` son substrings que DEBE contener el nombre de la // obligación; `exclude` son substrings que NO debe contener. El exclude // resuelve ambigüedades como "IVA" matcheando "Declaración de proveedores // de IVA" (DIOT) — cuando subes pago de IVA normal, NO debe cerrar DIOT. const IMPUESTO_A_OBLIGACION_KEYWORDS: Record = { IVA: { include: ['iva'], exclude: ['diot', 'proveedores de iva', 'informativa'] }, ISR: { include: ['isr'], exclude: ['retenciones', 'asimilados a salarios'] }, IEPS: { include: ['ieps'], exclude: [] }, ISN: { include: ['isn', 'sueldos', 'salarios', 'nómina'], exclude: [] }, ISH: { include: [], exclude: [] }, DIOT: { include: ['diot', 'proveedores de iva'], exclude: [] }, OTRO: { include: [], exclude: [] }, }; /** * After uploading a declaration, find matching obligations for the contribuyente * and mark them as completed for the period. Also resolve the ob-* alerts. * * Guarda `declaracion_id` en `obligacion_periodos` para que la UI pueda * mostrar "Completada via Declaración #123" y permitir cross-link. Si la * declaración se borra, el FK pasa a NULL (ON DELETE SET NULL) y el * periodo sigue marcado completado — el usuario decidirá si re-abrirlo * manualmente. */ /** * Al subir una declaración o comprobante de pago, registra una evidencia para * cada obligación del contribuyente que corresponda al impuesto declarado. * * - Obligaciones informativas (`requierePago = false`) se marcan completadas al * recibir cualquier documento de declaración/acuse. * - Obligaciones de pago (`requierePago = true`) se marcan completadas solo al * recibir un comprobante de pago (`tipo_documento = 'pago'`). */ async function registrarEvidenciasPorDeclaracion( pool: Pool, contribuyenteId: string, impuestos: string[], periodo: string, /** UUID del usuario que subió el documento. */ subidoPor: string, pdfBase64: string, pdfFilename: string, tipoDocumento: 'declaracion' | 'pago', /** Periodicidad de la declaración. Si no se provee, asume 'mensual'. */ periodicidad: string = 'mensual', ): Promise<{ count: number; obligacionesAfectadas: string[] }> { // Get active obligations for this contribuyente (incluye frecuencia para filtrar) const { rows: obligaciones } = await pool.query<{ id: string; nombre: string; frecuencia: string | null }>( `SELECT id, nombre, frecuencia FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND activa = true`, [contribuyenteId], ); let count = 0; const obligacionesAfectadas: string[] = []; for (const impuesto of impuestos) { const rules = IMPUESTO_A_OBLIGACION_KEYWORDS[impuesto]; if (!rules || rules.include.length === 0) continue; for (const ob of obligaciones) { const nombreLower = ob.nombre.toLowerCase(); const matches = rules.include.some(kw => nombreLower.includes(kw)) && !rules.exclude.some(kw => nombreLower.includes(kw)); if (!matches) continue; // Filtro por periodicidad/frecuencia: una declaración mensual no debe // cerrar obligaciones anuales del mismo impuesto. const obFrec = (ob.frecuencia || '').toLowerCase(); if (obFrec === 'eventual') continue; if (obFrec && obFrec !== periodicidad.toLowerCase()) continue; await createEvidencia(pool, { obligacionId: ob.id, periodo, contribuyenteId, tipoDocumento, pdfBase64, pdfFilename, notas: `${tipoDocumento === 'pago' ? 'Pago' : 'Declaración'} ${impuesto}`, subidoPor, }); if (!obligacionesAfectadas.includes(ob.id)) obligacionesAfectadas.push(ob.id); count++; } } return { count, obligacionesAfectadas }; } /** * Cuando una declaración tiene monto $0, no se requiere comprobante de pago. * Esta función marca `pago_presentado = true` (y `completada = true`) en los * periodos de las obligaciones afectadas para reflejar que el pago está saldado. */ async function confirmarPagoPeriodoSinComprobante( pool: Pool, obligacionesAfectadas: string[], periodo: string, userId: string, ): Promise { const now = new Date(); for (const obligacionId of obligacionesAfectadas) { await pool.query( `INSERT INTO obligacion_periodos (obligacion_id, periodo, declaracion_presentada, pago_presentado, completada, completada_at, completada_por) VALUES ($1, $2, true, true, true, $3, $4) ON CONFLICT (obligacion_id, periodo) DO UPDATE SET pago_presentado = true, completada = true, completada_at = COALESCE(obligacion_periodos.completada_at, $3), completada_por = COALESCE(obligacion_periodos.completada_por, $4)`, [obligacionId, periodo, now, userId], ); // Resolver alerta ob-* si existe await pool.query( `UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`, [`ob-${obligacionId}-${periodo}`], ); } } /** * Registra una evidencia por cada obligación seleccionada. * - Obligaciones informativas se completan con `declaracion`/`acuse`/`complemento`. * - Obligaciones de pago requieren evidencia `pago` para cerrarse. */ async function registrarEvidenciasPorObligaciones( pool: Pool, obligaciones: Array<{ id: string; nombre: string; catalogoId?: string | null }>, contribuyenteId: string, periodo: string, subidoPor: string, pdfBase64: string, pdfFilename: string, tipoDocumento: 'declaracion' | 'pago', notas?: string, ): Promise { const afectadas: string[] = []; for (const ob of obligaciones) { await createEvidencia(pool, { obligacionId: ob.id, periodo, contribuyenteId, tipoDocumento, pdfBase64, pdfFilename, notas: notas || `${tipoDocumento === 'pago' ? 'Comprobante de pago' : 'Declaración'}: ${ob.nombre}`, subidoPor, }); afectadas.push(ob.id); } return afectadas; } async function getObligacionesPorIds( pool: Pool, contribuyenteId: string, obligacionesIds: string[], ): Promise> { const { rows } = await pool.query<{ id: string; nombre: string; catalogo_id: string | null }>( `SELECT id, nombre, catalogo_id FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND id = ANY($2::uuid[]) AND activa = true`, [contribuyenteId, obligacionesIds], ); return rows.map(r => ({ id: r.id, nombre: r.nombre, catalogoId: r.catalogo_id })); } /** * Declaraciones provisionales: PDF subido por el contador con la declaración * presentada al SAT + opcionalmente comprobante de pago. Al subir, se marcan * como resueltas las alertas correspondientes en la tabla `alertas` del tenant. * * El método legacy "marcar como realizado" desde /alertas sigue funcionando * para usuarios que no quieran subir el documento. Esta automatización es * adicional, no reemplaza. */ export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'ISN' | 'DIOT' | 'OTRO' | 'ISH'; export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'cuatrimestral' | 'semestral' | 'anual'; export interface DeclaracionRow { id: number; año: number; mes: number; tipo: 'normal' | 'complementaria'; periodicidad: Periodicidad; impuestos: string[]; montoPago: number | null; pdfFilename: string | null; ligaPagoFilename: string | null; pdfPagoFilename: string | null; pagadoAt: string | null; creadoPor: string | null; notas: string | null; createdAt: string; updatedAt: string; tieneLigaPago: boolean; tienePagoPdf: boolean; } // Mapeo Impuesto → prefijo de tipo de alerta (debe coincidir con // EVENTO_A_ALERTA en alertas-manuales.service.ts). const IMPUESTO_A_PREFIJO_DECL: Record = { IVA: ['decl-iva'], ISR: ['decl-isr'], IEPS: ['decl-ieps'], ISN: ['decl-isn'], DIOT: ['diot'], OTRO: [], ISH: [], }; const IMPUESTO_A_PREFIJO_PAGO: Record = { IVA: ['pago-iva'], ISR: ['pago-isr'], IEPS: ['pago-ieps'], ISN: [], // ISN solo es declaración informativa, no tiene pago provisional DIOT: [], OTRO: [], ISH: [], }; /** * Marca como resueltas las alertas cuyo `tipo` empieza con cualquiera de los * prefijos dados Y cuyo `fecha_vencimiento` cae en el mes/año dados. * Idempotente: re-llamar no crea efectos secundarios extra. */ async function resolverAlertasPorPeriodo( pool: Pool, prefijos: string[], año: number, mes: number, ): Promise { if (prefijos.length === 0) return 0; // El tipo es `prefijo-YYYY-MM-DD`. Buscar por LIKE prefijo-año-mes-% const mesStr = String(mes).padStart(2, '0'); const conditions = prefijos.map((_, i) => `tipo LIKE $${i + 1}`).join(' OR '); const params = prefijos.map(p => `${p}-${año}-${mesStr}-%`); const { rowCount } = await pool.query( `UPDATE alertas SET resuelta = true WHERE (${conditions}) AND resuelta = false`, params, ); return rowCount ?? 0; } function rowToDeclaracion(r: any): DeclaracionRow { return { id: r.id, año: r.año, mes: r.mes, tipo: r.tipo, periodicidad: r.periodicidad || 'mensual', impuestos: r.impuestos || [], montoPago: r.monto_pago != null ? Number(r.monto_pago) : null, pdfFilename: r.pdf_filename, ligaPagoFilename: r.pdf_liga_pago_filename, pdfPagoFilename: r.pdf_pago_filename, pagadoAt: r.pagado_at?.toISOString() ?? null, creadoPor: r.creado_por, notas: r.notas, createdAt: r.created_at.toISOString(), updatedAt: r.updated_at.toISOString(), tieneLigaPago: !!r.pdf_liga_pago_filename, tienePagoPdf: !!r.pdf_pago_filename, }; } export async function listDeclaraciones( pool: Pool, fechaDesde?: string, fechaHasta?: string, contribuyenteId?: string | null, ): Promise { const conditions: string[] = []; const params: unknown[] = []; if (fechaDesde) { params.push(fechaDesde); conditions.push(`created_at >= $${params.length}::date`); } if (fechaHasta) { params.push(fechaHasta); conditions.push(`created_at < ($${params.length}::date + interval '1 day')`); } if (contribuyenteId) { // Sanitize UUID (hex + hyphens only) const safe = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); if (safe) { params.push(safe); conditions.push(`contribuyente_id = $${params.length}`); } } const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const { rows } = await pool.query( `SELECT id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename, pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas, created_at, updated_at FROM declaraciones_provisionales ${where} ORDER BY created_at DESC, año DESC, mes DESC`, params, ); return rows.map(rowToDeclaracion); } export async function createDeclaracion( pool: Pool, data: { año: number; mes: number; tipo: 'normal' | 'complementaria'; periodicidad?: Periodicidad; /** Legacy: se infiere de obligacionesIds si no se envía. */ impuestos?: string[]; /** Obligaciones fiscales que cubre esta declaración. */ obligacionesIds?: string[]; montoPago?: number | null; pdfBase64: string; // PDF de la declaración (base64) pdfFilename: string; ligaPagoBase64?: string; // PDF de la liga de pago (opcional, base64) ligaPagoFilename?: string; notas?: string; /** Email del usuario (para declaraciones_provisionales.creado_por VARCHAR). */ creadoPor: string; /** UUID del usuario (para obligacion_periodos.completada_por UUID). Opcional. */ creadoPorUserId?: string; contribuyenteId?: string; }, ): Promise<{ declaracion: DeclaracionRow; alertasResueltas: number }> { const buf = Buffer.from(data.pdfBase64, 'base64'); const ligaBuf = data.ligaPagoBase64 ? Buffer.from(data.ligaPagoBase64, 'base64') : null; const periodicidad = data.periodicidad || 'mensual'; const montoPago = data.montoPago ?? null; // If monto_pago is exactly 0, auto-mark as paid (no payment receipt needed) const pagadoAt = montoPago === 0 ? new Date() : null; // Resolvemos obligaciones e impuestos. let obligacionesSeleccionadas: Array<{ id: string; nombre: string; catalogoId: string | null }> = []; let impuestos: string[] = data.impuestos ?? []; if (data.contribuyenteId && data.obligacionesIds && data.obligacionesIds.length > 0) { obligacionesSeleccionadas = await getObligacionesPorIds(pool, data.contribuyenteId, data.obligacionesIds); if (impuestos.length === 0) { impuestos = inferirImpuestosDeObligaciones(obligacionesSeleccionadas); } } try { const { rows } = await pool.query( `INSERT INTO declaraciones_provisionales (año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_declaracion, pdf_filename, pdf_liga_pago, pdf_liga_pago_filename, notas, creado_por, pagado_at, contribuyente_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename, pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas, created_at, updated_at`, [data.año, data.mes, data.tipo, periodicidad, impuestos, montoPago, buf, data.pdfFilename, ligaBuf, data.ligaPagoFilename ?? null, data.notas ?? null, data.creadoPor, pagadoAt, data.contribuyenteId ?? null], ); const declaracion = rowToDeclaracion(rows[0]); // Guardar relación con obligaciones para que el comprobante de pago // posterior se aplique a las mismas obligaciones. if (obligacionesSeleccionadas.length > 0) { const values = obligacionesSeleccionadas.map((_, i) => `($1, $${i + 2})`).join(','); await pool.query( `INSERT INTO declaracion_obligaciones (declaracion_id, obligacion_id) VALUES ${values}`, [declaracion.id, ...obligacionesSeleccionadas.map(o => o.id)], ); } // Auto-resolver alertas legacy (decl-*, pago-*). const prefijosDecl = impuestos.flatMap(i => IMPUESTO_A_PREFIJO_DECL[i] || []); let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosDecl, data.año, data.mes); if (data.tipo === 'complementaria' || montoPago === 0) { const prefijosPago = impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []); alertasResueltas += await resolverAlertasPorPeriodo(pool, prefijosPago, data.año, data.mes); } // Registrar evidencias de declaración en las obligaciones seleccionadas. // Fallback legacy: si no se enviaron obligaciones, se usa el keyword matching // anterior a partir de impuestos. let obligacionesAfectadas: string[] = obligacionesSeleccionadas.map(o => o.id); if (data.contribuyenteId && data.creadoPorUserId) { const periodo = `${data.año}-${String(data.mes).padStart(2, '0')}`; if (obligacionesSeleccionadas.length > 0) { await registrarEvidenciasPorObligaciones( pool, obligacionesSeleccionadas, data.contribuyenteId, periodo, data.creadoPorUserId, data.pdfBase64, data.pdfFilename, 'declaracion', data.notas, ); } else if (impuestos.length > 0) { const { obligacionesAfectadas: afectadas } = await registrarEvidenciasPorDeclaracion( pool, data.contribuyenteId, impuestos, periodo, data.creadoPorUserId, data.pdfBase64, data.pdfFilename, 'declaracion', periodicidad, ); obligacionesAfectadas = afectadas; } // Si la declaración es por $0, no se requiere comprobante de pago: // marcar el pago como presentado automáticamente. if (montoPago === 0 && obligacionesAfectadas.length > 0) { await confirmarPagoPeriodoSinComprobante(pool, obligacionesAfectadas, periodo, data.creadoPorUserId); } } return { declaracion, alertasResueltas }; } catch (err: any) { if (err?.code === '23505') { throw new Error(`Ya existe una declaración tipo "normal" para ${data.mes}/${data.año}. Solo se permite una normal por mes; agrega una complementaria si necesitas corregirla.`); } throw err; } } export async function uploadComprobantePago( pool: Pool, id: number, data: { pdfBase64: string; pdfFilename: string; /** UUID del usuario que sube el comprobante (para obligacion_periodos.completada_por). */ uploadedByUserId?: string; }, ): Promise<{ declaracion: DeclaracionRow; alertasResueltas: number }> { const buf = Buffer.from(data.pdfBase64, 'base64'); const { rows } = await pool.query( `UPDATE declaraciones_provisionales SET pdf_pago = $1, pdf_pago_filename = $2, pagado_at = NOW(), updated_at = NOW() WHERE id = $3 RETURNING id, año, mes, tipo, periodicidad, impuestos, pdf_filename, pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas, created_at, updated_at, contribuyente_id`, [buf, data.pdfFilename, id], ); if (rows.length === 0) throw new Error('Declaración no encontrada'); const row = rows[0]; const declaracion = rowToDeclaracion(row); // Auto-resolver alertas de pago legacy. const prefijosPago = declaracion.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []); let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosPago, declaracion.año, declaracion.mes); // Registrar evidencias de pago en las obligaciones vinculadas a esta declaración. // Fallback legacy: si no hay relaciones, se usa keyword matching por impuestos. if (row.contribuyente_id && data.uploadedByUserId) { const periodo = `${declaracion.año}-${String(declaracion.mes).padStart(2, '0')}`; const { rows: relaciones } = await pool.query<{ obligacion_id: string }>( `SELECT obligacion_id FROM declaracion_obligaciones WHERE declaracion_id = $1`, [id], ); if (relaciones.length > 0) { const obligaciones = await getObligacionesPorIds( pool, row.contribuyente_id, relaciones.map(r => r.obligacion_id), ); await registrarEvidenciasPorObligaciones( pool, obligaciones, row.contribuyente_id, periodo, data.uploadedByUserId, data.pdfBase64, data.pdfFilename, 'pago', declaracion.notas ?? undefined, ); } else if (declaracion.impuestos.length > 0) { const periodicidad = row.periodicidad || 'mensual'; await registrarEvidenciasPorDeclaracion( pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId, data.pdfBase64, data.pdfFilename, 'pago', periodicidad, ); } } return { declaracion, alertasResueltas }; } export async function deleteDeclaracion(pool: Pool, id: number): Promise { const { rowCount } = await pool.query( `DELETE FROM declaraciones_provisionales WHERE id = $1`, [id], ); if (rowCount === 0) throw new Error('Declaración no encontrada'); } /** * Cleanup: borra declaraciones con created_at < hoy - 5 años. Cumple con * el plazo de retención del Art. 30 del CFF (contabilidad por 5 años). * Llamado por cron diario. Idempotente: si no hay viejas, no-op. * * Se ejecuta por-tenant (caller pasa el pool). Returns { deleted } para log. */ export async function purgeDeclaracionesAntiguas(pool: Pool): Promise<{ deleted: number }> { const { rowCount } = await pool.query( `DELETE FROM declaraciones_provisionales WHERE created_at < NOW() - INTERVAL '5 years'`, ); return { deleted: rowCount ?? 0 }; } export async function getDeclaracionPdf( pool: Pool, id: number, variant: 'declaracion' | 'liga' | 'pago', ): Promise<{ buffer: Buffer; filename: string } | null> { const col = variant === 'declaracion' ? 'pdf_declaracion' : variant === 'liga' ? 'pdf_liga_pago' : 'pdf_pago'; const colName = variant === 'declaracion' ? 'pdf_filename' : variant === 'liga' ? 'pdf_liga_pago_filename' : 'pdf_pago_filename'; const { rows } = await pool.query( `SELECT ${col} as data, ${colName} as filename FROM declaraciones_provisionales WHERE id = $1`, [id], ); if (rows.length === 0 || !rows[0].data) return null; return { buffer: Buffer.from(rows[0].data), filename: rows[0].filename || `declaracion-${id}.pdf` }; }