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.
This commit is contained in:
@@ -1,4 +1,38 @@
|
||||
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
|
||||
@@ -25,17 +59,28 @@ const IMPUESTO_A_OBLIGACION_KEYWORDS: Record<string, { include: string[]; exclud
|
||||
* periodo sigue marcado completado — el usuario decidirá si re-abrirlo
|
||||
* manualmente.
|
||||
*/
|
||||
async function completarObligacionesPorDeclaracion(
|
||||
/**
|
||||
* 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ó 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'. */
|
||||
/** 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<number> {
|
||||
): 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`,
|
||||
@@ -43,6 +88,7 @@ async function completarObligacionesPorDeclaracion(
|
||||
);
|
||||
|
||||
let count = 0;
|
||||
const obligacionesAfectadas: string[] = [];
|
||||
|
||||
for (const impuesto of impuestos) {
|
||||
const rules = IMPUESTO_A_OBLIGACION_KEYWORDS[impuesto];
|
||||
@@ -55,33 +101,109 @@ async function completarObligacionesPorDeclaracion(
|
||||
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.
|
||||
// cerrar obligaciones anuales del mismo impuesto.
|
||||
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}`],
|
||||
);
|
||||
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;
|
||||
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 }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,7 +218,7 @@ async function completarObligacionesPorDeclaracion(
|
||||
|
||||
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'ISN' | 'DIOT' | 'OTRO' | 'ISH';
|
||||
|
||||
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual';
|
||||
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'cuatrimestral' | 'semestral' | 'anual';
|
||||
|
||||
export interface DeclaracionRow {
|
||||
id: number;
|
||||
@@ -232,7 +354,10 @@ export async function createDeclaracion(
|
||||
mes: number;
|
||||
tipo: 'normal' | 'complementaria';
|
||||
periodicidad?: Periodicidad;
|
||||
impuestos: string[];
|
||||
/** 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;
|
||||
@@ -253,6 +378,16 @@ export async function createDeclaracion(
|
||||
// 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
|
||||
@@ -262,46 +397,55 @@ export async function createDeclaracion(
|
||||
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,
|
||||
[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]);
|
||||
|
||||
// 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] || []);
|
||||
// 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) {
|
||||
// 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] || []);
|
||||
const prefijosPago = 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,
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,20 +484,35 @@ export async function uploadComprobantePago(
|
||||
const row = rows[0];
|
||||
const declaracion = rowToDeclaracion(row);
|
||||
|
||||
// Auto-resolver alertas de pago para los impuestos del periodo
|
||||
// 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);
|
||||
|
||||
// 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).
|
||||
// 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 periodicidad = row.periodicidad || 'mensual';
|
||||
alertasResueltas += await completarObligacionesPorDeclaracion(
|
||||
pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId, declaracion.id, periodicidad,
|
||||
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user