- 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.
562 lines
21 KiB
TypeScript
562 lines
21 KiB
TypeScript
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<Impuesto>();
|
|
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<string, { include: string[]; exclude: string[] }> = {
|
|
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<void> {
|
|
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<string[]> {
|
|
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<Array<{ id: string; nombre: string; catalogoId: string | null }>> {
|
|
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<string, string[]> = {
|
|
IVA: ['decl-iva'],
|
|
ISR: ['decl-isr'],
|
|
IEPS: ['decl-ieps'],
|
|
ISN: ['decl-isn'],
|
|
DIOT: ['diot'],
|
|
OTRO: [],
|
|
ISH: [],
|
|
};
|
|
const IMPUESTO_A_PREFIJO_PAGO: Record<string, string[]> = {
|
|
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<number> {
|
|
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<DeclaracionRow[]> {
|
|
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<void> {
|
|
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` };
|
|
}
|