import type { Pool } from 'pg'; // 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: [] }, SUELDOS: { include: ['sueldos', 'salarios', 'nómina'], 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. */ async function completarObligacionesPorDeclaracion( pool: Pool, contribuyenteId: string, impuestos: string[], periodo: string, /** UUID del usuario que subió la declaración (obligacion_periodos.completada_por es uuid). */ completadaPor: string, declaracionId: number, /** Periodicidad de la declaración. Si no se provee, se asume 'mensual'. */ periodicidad: string = 'mensual', ): Promise { // 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; 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 (ej. ISR mensual no // cubre "Declaración anual de ISR"). Si la obligación tiene frecuencia // explícita y no coincide con la periodicidad de la declaración, skip. // `eventual` obligaciones no se tocan automáticamente. const obFrec = (ob.frecuencia || '').toLowerCase(); if (obFrec === 'eventual') continue; if (obFrec && obFrec !== periodicidad.toLowerCase()) continue; // Mark obligation as completed for this period, with FK a la declaración await pool.query(` INSERT INTO obligacion_periodos (obligacion_id, periodo, completada, completada_at, completada_por, notas, declaracion_id) VALUES ($1, $2, true, now(), $3, $4, $5) ON CONFLICT (obligacion_id, periodo) DO UPDATE SET completada = true, completada_at = now(), completada_por = $3, declaracion_id = $5 `, [ob.id, periodo, completadaPor, `Declaración ${impuesto} subida`, declaracionId]); // Resolve the ob-* alert for this obligation+period await pool.query( `UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`, [`ob-${ob.id}-${periodo}`], ); count++; } } return count; } /** * 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' | 'SUELDOS' | 'DIOT' | 'OTRO'; export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | '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'], SUELDOS: ['decl-sueldos'], DIOT: ['diot'], OTRO: [], }; const IMPUESTO_A_PREFIJO_PAGO: Record = { IVA: ['pago-iva'], ISR: ['pago-isr'], IEPS: ['pago-ieps'], SUELDOS: [], // sueldos solo es declaración informativa, no tiene pago provisional DIOT: [], OTRO: [], }; /** * 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; impuestos: 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; 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, data.impuestos, montoPago, buf, data.pdfFilename, ligaBuf, data.ligaPagoFilename ?? null, data.notas ?? null, data.creadoPor, pagadoAt, data.contribuyenteId ?? null], ); const declaracion = rowToDeclaracion(rows[0]); // Auto-resolver alertas. Reglas: // - tipo='normal': resuelve alertas de declaración (decl-*) del mes. // El pago se resuelve por separado al subir comprobante. // - tipo='complementaria': sustituye a la normal en términos de // obligación de pago — al subirla se resuelven AMBAS (decl-* y // pago-*) porque el cliente pagará usando la complementaria, // no la normal. La alerta de declaración ya estaría resuelta // si la normal se subió antes; el resolver es idempotente. const prefijosDecl = data.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) { // complementaria: sustituye normal para pago → resolver ambas // monto 0: nada que pagar → resolver alertas de pago también const prefijosPago = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []); alertasResueltas += await resolverAlertasPorPeriodo(pool, prefijosPago, data.año, data.mes); } // Auto-complete obligaciones del contribuyente SOLO si la declaración // también cubre el pago (complementaria sustituye a la normal para el // pago; monto=0 significa "nada que pagar"). Una declaración normal con // monto>0 solo presenta el acuse — la obligación de pago sigue abierta // y se marca completada hasta que se suba el comprobante via // `uploadComprobantePago`. Esto mantiene las alertas `pago-*` y `ob-*` // visibles hasta que realmente se cierre el ciclo. const cubrePago = data.tipo === 'complementaria' || montoPago === 0; if (data.contribuyenteId && cubrePago) { if (!data.creadoPorUserId) { console.warn('[createDeclaracion] Sin creadoPorUserId — no se auto-completan obligaciones del contribuyente'); } else { const periodo = `${data.año}-${String(data.mes).padStart(2, '0')}`; alertasResueltas += await completarObligacionesPorDeclaracion( pool, data.contribuyenteId, data.impuestos, periodo, data.creadoPorUserId, declaracion.id, periodicidad, ); } } 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 para los impuestos del periodo const prefijosPago = declaracion.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []); let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosPago, declaracion.año, declaracion.mes); // Al subirse el comprobante de pago, la obligación ahora SÍ está completada // (declaración + pago). Marcar `obligacion_periodos.completada=true` y // resolver los `ob-*` alerts. Requires contribuyenteId (guardado en la // declaración) y userId (del caller). if (row.contribuyente_id && data.uploadedByUserId) { const periodo = `${declaracion.año}-${String(declaracion.mes).padStart(2, '0')}`; const periodicidad = row.periodicidad || 'mensual'; alertasResueltas += await completarObligacionesPorDeclaracion( pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId, declaracion.id, 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` }; }