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:
Horux Dev
2026-06-22 04:53:59 +00:00
parent b217342a96
commit 7df27ce66d
39 changed files with 2791 additions and 191 deletions

View File

@@ -2,53 +2,67 @@ export interface ObligacionFiscal {
id: string;
nombre: string;
fundamento: string;
frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'anual' | 'eventual';
frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'cuatrimestral' | 'anual' | 'eventual';
fechaLimite: string;
aplica: 'PM' | 'PF' | 'ambos';
regimenes: string[] | null; // null = all regimes
condicion: string | null;
categoria: string;
recomendadaPorDefecto: boolean;
/** Si true, la obligación requiere comprobante de pago para cerrarse. */
requierePago: boolean;
}
export const OBLIGACIONES_CATALOGO: ObligacionFiscal[] = [
// === FEDERALES MENSUALES (día 17) ===
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true },
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true },
{ id: 'ret-isr-sueldos', nombre: 'Retenciones de ISR por sueldos y salarios', fundamento: 'Art. 96 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false },
{ id: 'ret-isr-asimilados', nombre: 'Retenciones de ISR por asimilados a salarios', fundamento: 'Art. 94 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false },
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'PM que contrate PF', categoria: 'Federal mensual', recomendadaPorDefecto: false },
{ id: 'ret-iva', nombre: 'Retenciones de IVA (servicios, fletes, outsourcing)', fundamento: 'Art. 1-A LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Según supuesto', categoria: 'Federal mensual', recomendadaPorDefecto: false },
{ id: 'ieps', nombre: 'Pago definitivo de IEPS', fundamento: 'Art. 5 LIEPS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Productores/importadores', categoria: 'Federal mensual', recomendadaPorDefecto: false },
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: true, recomendadaPorDefecto: true },
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: true, recomendadaPorDefecto: true },
{ id: 'actividades-vulnerables', nombre: 'Aviso de actividades vulnerables', fundamento: 'LFPIORPI', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'ret-isr-sueldos', nombre: 'Retenciones de ISR por sueldos y salarios', fundamento: 'Art. 96 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'ret-isr-asimilados', nombre: 'Retenciones de ISR por asimilados a salarios', fundamento: 'Art. 94 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'PM que contrate PF', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'ret-iva', nombre: 'Retenciones de IVA (servicios, fletes, outsourcing)', fundamento: 'Art. 1-A LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Según supuesto', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'ieps', nombre: 'Pago definitivo de IEPS', fundamento: 'Art. 5 LIEPS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Productores/importadores', categoria: 'Federal mensual', requierePago: true, recomendadaPorDefecto: false },
// === INFORMATIVAS MENSUALES ===
{ id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
{ id: 'cont-balanza', nombre: 'Contabilidad Electrónica - Balanza de comprobación', fundamento: 'CFF Art. 28', frecuencia: 'mensual', fechaLimite: 'Día 3 del segundo mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
{ id: 'cont-catalogo', nombre: 'Contabilidad Electrónica - Catálogo de cuentas', fundamento: 'CFF Art. 28', frecuencia: 'eventual', fechaLimite: 'Cuando haya modificación', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
{ id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'cont-balanza', nombre: 'Contabilidad Electrónica - Balanza de comprobación', fundamento: 'CFF Art. 28', frecuencia: 'mensual', fechaLimite: 'Día 3 del segundo mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'cont-catalogo', nombre: 'Contabilidad Electrónica - Catálogo de cuentas', fundamento: 'CFF Art. 28', frecuencia: 'eventual', fechaLimite: 'Cuando haya modificación', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', requierePago: false, recomendadaPorDefecto: false },
// === FEDERALES TRIMESTRALES ===
{ id: 'ieps-trimestral', nombre: 'Declaración Informativa Múltiple del IEPS', fundamento: 'LIEPS', frecuencia: 'trimestral', fechaLimite: 'Día 17 de abril, julio, octubre y enero', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal trimestral', requierePago: false, recomendadaPorDefecto: false },
// === RESICO PM ===
{ id: 'isr-resico-pm', nombre: 'Pago provisional ISR RESICO-PM', fundamento: 'Art. 206 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: ['626'], condicion: null, categoria: 'RESICO PM', recomendadaPorDefecto: true },
{ id: 'isr-resico-pm', nombre: 'Pago provisional ISR RESICO-PM', fundamento: 'Art. 206 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: ['626'], condicion: null, categoria: 'RESICO PM', requierePago: true, recomendadaPorDefecto: true },
// === RESICO PF ===
{ id: 'isr-resico-pf', nombre: 'Pago mensual ISR RESICO PF (1%-2.5%)', fundamento: 'Art. 113-E LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PF', regimenes: ['626'], condicion: null, categoria: 'RESICO PF', recomendadaPorDefecto: true },
{ id: 'isr-resico-pf', nombre: 'Pago mensual ISR RESICO PF (1%-2.5%)', fundamento: 'Art. 113-E LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PF', regimenes: ['626'], condicion: null, categoria: 'RESICO PF', requierePago: true, recomendadaPorDefecto: true },
// === ANUALES PM ===
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true },
{ id: 'issif', nombre: 'ISSIF (Información sobre Situación Fiscal)', fundamento: 'CFF Art. 32-H', frecuencia: 'anual', fechaLimite: 'Con la declaración anual', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false },
{ id: 'dictamen-fiscal', nombre: 'Dictamen Fiscal', fundamento: 'CFF Art. 32-A', frecuencia: 'anual', fechaLimite: '15 de mayo', aplica: 'PM', regimenes: null, condicion: 'Ingresos > $1,855M o grupos', categoria: 'Anual', recomendadaPorDefecto: false },
{ id: 'dim', nombre: 'DIM - Declaraciones Informativas Múltiples', fundamento: 'CFF', frecuencia: 'anual', fechaLimite: '15 de febrero', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false },
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', requierePago: true, recomendadaPorDefecto: true },
{ id: 'declaracion-transparencia', nombre: 'Declaración Informativa de transparencia', fundamento: 'LFTAIPG', frecuencia: 'anual', fechaLimite: 'Día 31 de mayo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Federal anual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'issif', nombre: 'ISSIF (Información sobre Situación Fiscal)', fundamento: 'CFF Art. 32-H', frecuencia: 'anual', fechaLimite: 'Con la declaración anual', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'dictamen-fiscal', nombre: 'Dictamen Fiscal', fundamento: 'CFF Art. 32-A', frecuencia: 'anual', fechaLimite: '15 de mayo', aplica: 'PM', regimenes: null, condicion: 'Ingresos > $1,855M o grupos', categoria: 'Anual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'dim', nombre: 'DIM - Declaraciones Informativas Múltiples', fundamento: 'CFF', frecuencia: 'anual', fechaLimite: '15 de febrero', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', requierePago: false, recomendadaPorDefecto: false },
// === ANUALES PF ===
{ id: 'anual-isr-pf', nombre: 'Declaración Anual PF', fundamento: 'Art. 150 LISR', frecuencia: 'anual', fechaLimite: '30 de abril', aplica: 'PF', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true },
{ id: 'anual-isr-pf', nombre: 'Declaración Anual PF', fundamento: 'Art. 150 LISR', frecuencia: 'anual', fechaLimite: '30 de abril', aplica: 'PF', regimenes: null, condicion: null, categoria: 'Anual', requierePago: true, recomendadaPorDefecto: true },
// === SEGURIDAD SOCIAL ===
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
{ id: 'infonavit', nombre: 'Aportaciones INFONAVIT + amortizaciones', fundamento: 'LINFONAVIT', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
{ id: 'sar-retiro', nombre: 'SAR / Retiro', fundamento: 'LSS', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
{ id: 'prima-riesgo', nombre: 'Determinación Prima de Riesgo de Trabajo', fundamento: 'LSS Art. 74', frecuencia: 'anual', fechaLimite: 'Febrero', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
{ id: 'sipare', nombre: 'SIPARE - Cuotas obrero-patronales', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 15 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
{ id: 'infonavit', nombre: 'Aportaciones INFONAVIT + amortizaciones', fundamento: 'LINFONAVIT', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
{ id: 'sar-retiro', nombre: 'SAR / Retiro', fundamento: 'LSS', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
{ id: 'sisub', nombre: 'Sistema de Información de Subcontratación', fundamento: 'LFT', frecuencia: 'cuatrimestral', fechaLimite: 'Día 17 de enero, mayo y septiembre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: false, recomendadaPorDefecto: false },
{ id: 'prima-riesgo', nombre: 'Determinación Prima de Riesgo de Trabajo', fundamento: 'LSS Art. 74', frecuencia: 'anual', fechaLimite: 'Febrero', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
// === CRÉDITOS DE LOS TRABAJADORES ===
{ id: 'fonacot', nombre: 'Crédito FONACOT', fundamento: 'Ley FONACOT', frecuencia: 'mensual', fechaLimite: 'Día 5 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Créditos de los trabajadores', requierePago: true, recomendadaPorDefecto: false },
// === ESTATALES ===
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Estatal', recomendadaPorDefecto: false },
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
{ id: 'isrtp', nombre: 'Impuesto sobre remuneración al trabajo', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 10 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
{ id: 'ish', nombre: 'ISH - Impuesto Sobre Hospedaje', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 15 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
];
/**

View File

@@ -36,6 +36,10 @@ export async function getClavesUnidad(req: Request, res: Response, next: NextFun
} catch (error) { next(error); }
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export async function searchClaveProdServ(req: Request, res: Response, next: NextFunction) {
try {
const q = (req.query.q as string || '').trim();
@@ -44,11 +48,10 @@ export async function searchClaveProdServ(req: Request, res: Response, next: Nex
}
// Buscar por clave o descripción
// Primero buscar por clave, luego por texto
const data = await prisma.catClaveProdServ.findMany({
where: {
OR: [
{ clave: { startsWith: q } },
{ clave: { startsWith: q, mode: 'insensitive' } },
{ descripcion: { contains: q, mode: 'insensitive' } },
],
},
@@ -68,8 +71,8 @@ export async function searchClaveProdServ(req: Request, res: Response, next: Nex
return res.json(fallback);
}
// Buscar con variantes comunes de acentos
const withAccents = normalized
// Buscar con variantes comunes de acentos, escapando caracteres regex primero
const withAccents = escapeRegex(normalized)
.replace(/a/gi, '[aá]').replace(/e/gi, '[eé]')
.replace(/i/gi, '[ií]').replace(/o/gi, '[oó]').replace(/u/gi, '[uú]')
.replace(/n/gi, '[nñ]');

View File

@@ -4,6 +4,7 @@ import { getOpiniones, getOpinionPdf, consultarOpinion, consultarOpinionContribu
import * as declaracionesService from '../services/declaraciones.service.js';
import * as constanciaService from '../services/constancia.service.js';
import * as extrasService from '../services/documentos-extras.service.js';
import * as obligacionEvidenciasService from '../services/obligacion-evidencias.service.js';
import { notifyDocumentoSubido } from '../services/notify-upload.service.js';
import { AppError } from '../middlewares/error.middleware.js';
@@ -81,8 +82,9 @@ const createDeclaracionSchema = z.object({
año: z.number().int().min(2020).max(2100),
mes: z.number().int().min(1).max(12),
tipo: z.enum(['normal', 'complementaria']),
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'semestral', 'anual']).optional(),
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'])).min(1, 'Selecciona al menos un impuesto'),
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'cuatrimestral', 'semestral', 'anual']).optional(),
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'])).optional(),
obligacionesIds: z.array(z.string().uuid()).optional(),
montoPago: z.number().min(0).optional(),
pdfBase64: z.string().min(100),
pdfFilename: z.string().min(1).max(255),
@@ -92,6 +94,9 @@ const createDeclaracionSchema = z.object({
}).refine(
d => !d.ligaPagoBase64 || !!d.ligaPagoFilename,
{ message: 'Si incluyes liga de pago, también debes mandar su nombre de archivo', path: ['ligaPagoFilename'] },
).refine(
d => (d.obligacionesIds && d.obligacionesIds.length > 0) || (d.impuestos && d.impuestos.length > 0),
{ message: 'Selecciona al menos una obligación fiscal o un impuesto', path: ['obligacionesIds'] },
);
export async function listarDeclaraciones(req: Request, res: Response, next: NextFunction) {
@@ -119,6 +124,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
});
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
// Incluye como adjuntos el acuse de declaración y la liga de pago (si se subió).
// No bloquea la respuesta ni falla la creación si SMTP no está configurado.
notifyDocumentoSubido({
pool: req.tenantPool!,
@@ -126,6 +132,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
contribuyenteId: contribuyenteId ?? null,
subidoPor: req.user!.email,
kind: 'declaracion',
declaracionId: result.declaracion.id,
declaracion: {
periodo: `${MESES[data.mes - 1]} ${data.año}`,
tipo: data.tipo,
@@ -334,3 +341,91 @@ export async function listarCategoriasExtras(req: Request, res: Response, next:
res.json(data);
} catch (error) { next(error); }
}
// ═══════════════════════════════════════════════════════════════════════════
// Obligación evidencias — documentos que cierran obligaciones fiscales
// ═══════════════════════════════════════════════════════════════════════════
const createEvidenciaObligacionSchema = z.object({
contribuyenteId: z.string().uuid('contribuyenteId inválido'),
obligacionId: z.string().uuid('obligacionId inválido'),
periodo: z.string().regex(/^\d{4}-\d{2}$/, 'periodo debe ser YYYY-MM'),
tipoDocumento: z.enum(['declaracion', 'pago', 'acuse', 'complemento']),
pdfBase64: z.string().min(100, 'PDF requerido'),
pdfFilename: z.string().min(1).max(255),
notas: z.string().max(2000).optional(),
});
export async function listarEvidenciasObligacion(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido'));
const periodo = req.query.periodo as string | undefined;
const obligacionId = req.query.obligacionId as string | undefined;
const data = await obligacionEvidenciasService.listEvidencias(req.tenantPool!, contribuyenteId, {
periodo,
obligacionId,
});
res.json(data);
} catch (error) { next(error); }
}
export async function crearEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
try {
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir documentos' });
const data = createEvidenciaObligacionSchema.parse(req.body);
const result = await obligacionEvidenciasService.createEvidencia(req.tenantPool!, {
...data,
subidoPor: req.user!.userId,
subidoPorEmail: req.user!.email,
});
// Notificación fire-and-forget a owners + supervisor del contribuyente.
const { rows: obRows } = await req.tenantPool!.query<{ nombre: string }>(
'SELECT nombre FROM obligaciones_contribuyente WHERE id = $1',
[data.obligacionId],
);
notifyDocumentoSubido({
pool: req.tenantPool!,
tenantId: req.viewingTenantId ?? req.user!.tenantId,
contribuyenteId: data.contribuyenteId,
subidoPor: req.user!.email,
kind: 'obligacion_evidencia',
evidencia: {
obligacionNombre: obRows[0]?.nombre || 'Obligación fiscal',
periodo: data.periodo,
tipoDocumento: data.tipoDocumento,
filename: data.pdfFilename,
},
pdfBase64: data.pdfBase64,
}).catch((err: any) => console.error('[notifyDocumentoSubido obligacion_evidencia]', err?.message || err));
res.status(201).json(result);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function descargarEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
try {
const id = parseInt(String(req.params.id));
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
const pdf = await obligacionEvidenciasService.getEvidenciaPdf(req.tenantPool!, id);
if (!pdf) return next(new AppError(404, 'Evidencia no encontrada'));
res.setHeader('Content-Type', pdf.mime);
res.setHeader('Content-Disposition', `attachment; filename="${pdf.filename}"`);
res.send(pdf.buffer);
} catch (error) { next(error); }
}
export async function eliminarEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
try {
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para eliminar documentos' });
const id = parseInt(String(req.params.id));
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
const result = await obligacionEvidenciasService.deleteEvidencia(req.tenantPool!, id);
if (!result) return next(new AppError(404, 'Evidencia no encontrada'));
res.status(204).send();
} catch (error) { next(error); }
}

View File

@@ -402,7 +402,7 @@ async function hasIncompleteCfdis(pool: Pool, contribuyenteId: string): Promise<
FROM cfdis
WHERE contribuyente_id = $1
AND status = 'Vigente'
AND tipo_comprobante IN ('I', 'E')
AND tipo_comprobante IN ('I', 'E', 'P', 'N')
AND xml_original IS NULL
`, [contribuyenteId]);
return Number(rows[0]?.count || 0) > 0;
@@ -414,7 +414,7 @@ async function getOldestIncompleteCfdiDate(pool: Pool, contribuyenteId: string):
FROM cfdis
WHERE contribuyente_id = $1
AND status = 'Vigente'
AND tipo_comprobante IN ('I', 'E')
AND tipo_comprobante IN ('I', 'E', 'P', 'N')
AND xml_original IS NULL
`, [contribuyenteId]);
return rows[0]?.fecha_emision || null;
@@ -504,7 +504,7 @@ async function recoverTenant(tenantId: string): Promise<void> {
}
}
async function runRecoverySyncJob(): Promise<void> {
export async function runRecoverySyncJob(): Promise<void> {
if (isRecoveryRunning) {
console.log('[SAT Recovery] Ya en ejecución, omitiendo');
return;

View File

@@ -0,0 +1,7 @@
-- Extender periodicidad para soportar declaraciones cuatrimestrales (ej. SISUB)
ALTER TABLE declaraciones_provisionales
DROP CONSTRAINT IF EXISTS declaraciones_provisionales_periodicidad_check;
ALTER TABLE declaraciones_provisionales
ADD CONSTRAINT declaraciones_provisionales_periodicidad_check
CHECK (periodicidad IN ('mensual', 'bimestral', 'trimestral', 'cuatrimestral', 'semestral', 'anual'));

View File

@@ -0,0 +1,25 @@
-- Evidencias de cumplimiento para obligaciones fiscales.
-- Permite subir cualquier documento (declaración, pago, acuse, complemento)
-- vinculado a una obligación y periodo específicos.
CREATE TABLE IF NOT EXISTS obligacion_evidencias (
id serial PRIMARY KEY,
obligacion_id uuid NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE,
periodo varchar(7) NOT NULL, -- "2026-04"
contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
tipo_documento varchar(30) NOT NULL CHECK (tipo_documento IN (
'declaracion', 'pago', 'acuse', 'complemento'
)),
archivo bytea NOT NULL,
archivo_filename varchar(255) NOT NULL,
archivo_mime varchar(100) DEFAULT 'application/pdf',
notas text,
subido_por uuid, -- UUID del usuario en horux360 (sin FK local)
subido_por_email varchar(255),
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_obligacion_evidencias_obligacion_periodo
ON obligacion_evidencias (obligacion_id, periodo);
CREATE INDEX IF NOT EXISTS idx_obligacion_evidencias_contribuyente
ON obligacion_evidencias (contribuyente_id);

View File

@@ -0,0 +1,16 @@
-- Estados de declaración y pago por separado para obligaciones que requieren ambos.
ALTER TABLE obligacion_periodos
ADD COLUMN IF NOT EXISTS declaracion_presentada boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS pago_presentado boolean DEFAULT false;
-- Backfill: periodos ya completados se consideran con declaración y pago presentados.
UPDATE obligacion_periodos
SET declaracion_presentada = true,
pago_presentado = true
WHERE completada = true
AND (declaracion_presentada IS NULL OR pago_presentado IS NULL);
-- Asegurar que declaracion_presentada y pago_presentado no sean NULL.
ALTER TABLE obligacion_periodos
ALTER COLUMN declaracion_presentada SET NOT NULL,
ALTER COLUMN pago_presentado SET NOT NULL;

View File

@@ -0,0 +1,12 @@
-- Relación entre declaraciones provisionales y obligaciones fiscales.
-- Permite saber exactamente qué obligaciones cierra una declaración
-- y aplicar el comprobante de pago a las mismas obligaciones.
CREATE TABLE IF NOT EXISTS declaracion_obligaciones (
declaracion_id INT NOT NULL REFERENCES declaraciones_provisionales(id) ON DELETE CASCADE,
obligacion_id UUID NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (declaracion_id, obligacion_id)
);
CREATE INDEX IF NOT EXISTS idx_declaracion_obligaciones_obligacion
ON declaracion_obligaciones (obligacion_id);

View File

@@ -35,4 +35,10 @@ router.post('/extras', documentosController.crearExtra);
router.get('/extras/:id/pdf', documentosController.descargarExtraPdf);
router.delete('/extras/:id', documentosController.eliminarExtra);
// Evidencias de obligaciones fiscales
router.get('/obligacion-evidencias', documentosController.listarEvidenciasObligacion);
router.post('/obligacion-evidencias', documentosController.crearEvidenciaObligacion);
router.get('/obligacion-evidencias/:id/pdf', documentosController.descargarEvidenciaObligacion);
router.delete('/obligacion-evidencias/:id', documentosController.eliminarEvidenciaObligacion);
export { router as documentosRoutes };

View File

@@ -119,6 +119,7 @@ function appliesToPeriod(frecuencia: string | null, periodo: string): boolean {
case 'mensual': return true;
case 'bimestral': return month % 2 === 1;
case 'trimestral': return [1, 4, 7, 10].includes(month);
case 'cuatrimestral': return [1, 5, 9].includes(month);
case 'anual': return month === 3 || month === 4;
case 'eventual': return false;
default: return true;

View File

@@ -214,6 +214,7 @@ export async function generarEventosDesdeObligaciones(
if (freq === 'mensual') monthsToGenerate.push(m);
else if (freq === 'bimestral' && m % 2 === 1) monthsToGenerate.push(m);
else if (freq === 'trimestral' && [1, 4, 7, 10].includes(m)) monthsToGenerate.push(m);
else if (freq === 'cuatrimestral' && [1, 5, 9].includes(m)) monthsToGenerate.push(m);
else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m);
// 'eventual' and unknown: skip auto-generation
}

View File

@@ -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 };

View File

@@ -1,4 +1,4 @@
import { createEmailTransport } from '@horux/core';
import { createEmailTransport, type EmailAttachment } from '@horux/core';
import { env } from '../../config/env.js';
const transport = createEmailTransport(
@@ -13,8 +13,8 @@ const transport = createEmailTransport(
: null
);
async function sendEmail(to: string, subject: string, html: string) {
await transport.send(to, subject, html);
async function sendEmail(to: string, subject: string, html: string, attachments?: EmailAttachment[]) {
await transport.send(to, subject, html, attachments);
}
export const emailService = {
@@ -128,10 +128,14 @@ export const emailService = {
* Notifica la subida de una declaración o documento extra al despacho.
* `recipients` debe venir deduplicado por el caller. El subject se
* genera a partir del kind y RFC del contribuyente.
*
* Para declaraciones, `attachments` puede contener los PDFs subidos
* (acuse + liga de pago) para enviarlos adjuntos al correo.
*/
sendDocumentoSubido: async (
recipients: string[],
data: import('./templates/documento-subido.js').DocumentoSubidoData,
attachments?: EmailAttachment[],
) => {
if (recipients.length === 0) return;
const { documentoSubidoEmail } = await import('./templates/documento-subido.js');
@@ -143,7 +147,7 @@ export const emailService = {
// destinatario NO debe impedir enviar al siguiente.
for (const to of recipients) {
try {
await sendEmail(to, subject, html);
await sendEmail(to, subject, html, attachments);
} catch (err: any) {
console.error(`[Email] Fallo enviando documento-subido a ${to}:`, err?.message || err);
}

View File

@@ -2,7 +2,7 @@ import { baseTemplate, heading, infoBox, primaryButton, BRAND_COLORS as C } from
export interface DocumentoSubidoData {
/** Kind: para el título/subject. */
kind: 'declaracion' | 'extra';
kind: 'declaracion' | 'extra' | 'obligacion_evidencia';
/** Quién subió el documento (email). */
subidoPor: string;
/** RFC del contribuyente. */
@@ -24,25 +24,38 @@ export interface DocumentoSubidoData {
descripcion?: string | null;
categoria?: string | null;
};
/** Si es evidencia de obligación fiscal. */
evidencia?: {
obligacionNombre: string;
periodo: string;
tipoDocumento: string;
filename: string;
};
/** URL al sistema (ej. https://despachos.horuxfin.com/documentos). */
link: string;
/** Solo para declaraciones: los adjuntos se omitieron por exceder el límite de tamaño. */
attachmentsOmitted?: boolean;
}
export function documentoSubidoEmail(data: DocumentoSubidoData): string {
const titulo = data.kind === 'declaracion'
? 'Nueva declaración subida'
: 'Nuevo documento subido';
: data.kind === 'obligacion_evidencia'
? 'Nueva evidencia de obligación fiscal'
: 'Nuevo documento subido';
const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion
? declaracionBlock(data.declaracion)
: data.extra
? extraBlock(data.extra)
: '';
: data.kind === 'obligacion_evidencia' && data.evidencia
? evidenciaBlock(data.evidencia)
: data.extra
? extraBlock(data.extra)
: '';
return baseTemplate(`
${heading(titulo)}
<p style="color:${C.textPrimary};margin:0 0 16px;">
<strong>${escapeHtml(data.subidoPor)}</strong> subió un ${data.kind === 'declaracion' ? 'acuse de declaración' : 'documento'}
<strong>${escapeHtml(data.subidoPor)}</strong> subió ${data.kind === 'obligacion_evidencia' ? 'una evidencia de obligación fiscal' : data.kind === 'declaracion' ? 'un acuse de declaración' : 'un documento'}
para <strong>${escapeHtml(data.contribuyenteNombre)}</strong>.
</p>
${infoBox(`
@@ -57,6 +70,12 @@ export function documentoSubidoEmail(data: DocumentoSubidoData): string {
<div style="margin-top:24px;">
${primaryButton('Ver en el sistema', data.link)}
</div>
${data.kind === 'declaracion' && data.attachmentsOmitted ? `
<p style="color:${C.textMuted};font-size:13px;margin-top:16px;">
Los documentos no se adjuntaron porque exceden el tamaño permitido por correo.
Puedes descargarlos desde el sistema.
</p>
` : ''}
`);
}
@@ -76,6 +95,19 @@ function declaracionBlock(d: NonNullable<DocumentoSubidoData['declaracion']>): s
`;
}
function evidenciaBlock(e: NonNullable<DocumentoSubidoData['evidencia']>): string {
return `
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Obligación</p>
<p style="margin:0 0 12px;color:${C.textPrimary};font-weight:600;">${escapeHtml(e.obligacionNombre)}</p>
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Periodo</p>
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.periodo)}</p>
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Tipo de documento</p>
<p style="margin:0 0 12px;color:${C.textPrimary};text-transform:capitalize;">${escapeHtml(e.tipoDocumento)}</p>
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Archivo</p>
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.filename)}</p>
`;
}
function extraBlock(e: NonNullable<DocumentoSubidoData['extra']>): string {
return `
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Documento</p>

View File

@@ -5,6 +5,10 @@ import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js'
import { env } from '../config/env.js';
import { filterRecipientsByRole, type RecipientWithRole } from './notification-preferences.service.js';
import type { DocumentoSubidoData } from './email/templates/documento-subido.js';
import type { EmailAttachment } from '@horux/core';
/** Límite total de adjuntos para evitar rechazos por SMTP (20 MB). */
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
/**
* Notifica a los destinatarios relevantes cuando se sube una declaración
@@ -26,7 +30,11 @@ export async function notifyDocumentoSubido(params: {
subidoPor: string;
kind: DocumentoSubidoData['kind'];
declaracion?: DocumentoSubidoData['declaracion'];
declaracionId?: number;
extra?: DocumentoSubidoData['extra'];
evidencia?: DocumentoSubidoData['evidencia'];
/** PDF en base64 para adjuntar en notificaciones de evidencia de obligación. */
pdfBase64?: string;
}): Promise<void> {
const { pool, tenantId, contribuyenteId, subidoPor } = params;
@@ -77,6 +85,23 @@ export async function notifyDocumentoSubido(params: {
// 4. Link al sistema. Usa FRONTEND_URL del env.
const link = `${env.FRONTEND_URL}/documentos`;
// Adjuntar los PDFs cuando se trata de una declaración recién creada o de una evidencia de obligación.
let attachments: EmailAttachment[] | undefined;
let attachmentsOmitted = false;
if (params.kind === 'declaracion' && params.declaracionId) {
const built = await buildDeclaracionAttachments(pool, params.declaracionId);
attachments = built.attachments;
attachmentsOmitted = built.omitted;
} else if (params.kind === 'obligacion_evidencia' && params.pdfBase64 && params.evidencia) {
const content = Buffer.from(params.pdfBase64, 'base64');
if (content.length > MAX_ATTACHMENT_BYTES) {
attachmentsOmitted = true;
console.warn(`[notifyDocumentoSubido] Evidencia de obligación excede ${MAX_ATTACHMENT_BYTES} bytes (${content.length}). Se envía sin adjunto.`);
} else {
attachments = [{ filename: params.evidencia.filename, content }];
}
}
await emailService.sendDocumentoSubido(Array.from(recipients), {
kind: params.kind,
subidoPor,
@@ -85,6 +110,46 @@ export async function notifyDocumentoSubido(params: {
despachoNombre: tenant?.nombre,
declaracion: params.declaracion,
extra: params.extra,
evidencia: params.evidencia,
link,
});
attachmentsOmitted,
}, attachments);
}
async function buildDeclaracionAttachments(
pool: Pool,
declaracionId: number,
): Promise<{ attachments?: EmailAttachment[]; omitted: boolean }> {
const { rows } = await pool.query(
`SELECT pdf_declaracion, pdf_filename,
pdf_liga_pago, pdf_liga_pago_filename
FROM declaraciones_provisionales
WHERE id = $1`,
[declaracionId],
);
const row = rows[0];
if (!row) return { omitted: false };
let totalSize = 0;
const attachments: EmailAttachment[] = [];
if (row.pdf_declaracion && row.pdf_filename) {
const content = Buffer.from(row.pdf_declaracion);
totalSize += content.length;
attachments.push({ filename: row.pdf_filename, content });
}
if (row.pdf_liga_pago && row.pdf_liga_pago_filename) {
const content = Buffer.from(row.pdf_liga_pago);
totalSize += content.length;
attachments.push({ filename: row.pdf_liga_pago_filename, content });
}
if (totalSize > MAX_ATTACHMENT_BYTES) {
console.warn(`[notifyDocumentoSubido] Adjuntos de declaración ${declaracionId} exceden ${MAX_ATTACHMENT_BYTES} bytes (${totalSize}). Se envía sin adjuntos.`);
return { omitted: true };
}
return { attachments, omitted: false };
}

View File

@@ -0,0 +1,272 @@
import type { Pool } from 'pg';
import { OBLIGACIONES_CATALOGO } from '../constants/obligaciones-fiscales.js';
export interface EvidenciaRow {
id: number;
obligacionId: string;
periodo: string;
contribuyenteId: string;
tipoDocumento: 'declaracion' | 'pago' | 'acuse' | 'complemento';
archivo: Buffer;
archivoFilename: string;
archivoMime: string;
notas: string | null;
subidoPor: string | null;
subidoPorEmail: string | null;
createdAt: string;
}
export interface CreateEvidenciaInput {
obligacionId: string;
periodo: string;
contribuyenteId: string;
tipoDocumento: 'declaracion' | 'pago' | 'acuse' | 'complemento';
pdfBase64: string;
pdfFilename: string;
notas?: string;
subidoPor: string; // userId UUID
subidoPorEmail?: string;
}
function rowToEvidencia(r: any): EvidenciaRow {
return {
id: r.id,
obligacionId: r.obligacion_id,
periodo: r.periodo,
contribuyenteId: r.contribuyente_id,
tipoDocumento: r.tipo_documento,
archivo: Buffer.from(r.archivo),
archivoFilename: r.archivo_filename,
archivoMime: r.archivo_mime,
notas: r.notas,
subidoPor: r.subido_por,
subidoPorEmail: r.subido_por_email,
createdAt: r.created_at.toISOString(),
};
}
async function getObligacionContribuyente(pool: Pool, obligacionId: string): Promise<{ contribuyenteId: string; catalogoId: string | null } | null> {
const { rows } = await pool.query<{ contribuyente_id: string; catalogo_id: string | null }>(
`SELECT contribuyente_id, catalogo_id FROM obligaciones_contribuyente WHERE id = $1`,
[obligacionId],
);
const row = rows[0];
if (!row) return null;
return { contribuyenteId: row.contribuyente_id, catalogoId: row.catalogo_id };
}
function requierePago(obligacion: { catalogoId: string | null }): boolean {
if (!obligacion.catalogoId) return true; // conservador: sin catálogo, requiere pago
const catalogo = OBLIGACIONES_CATALOGO.find((o) => o.id === obligacion.catalogoId);
return catalogo?.requierePago ?? true;
}
function esDocumentoDeclaracion(tipo: string): boolean {
return tipo === 'declaracion' || tipo === 'acuse' || tipo === 'complemento';
}
async function updatePeriodoStatus(
pool: Pool,
obligacionId: string,
periodo: string,
tipoDocumento: string,
reqPago: boolean,
completadaPor: string,
notas?: string,
): Promise<{ completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean }> {
const { rows } = await pool.query<{
declaracion_presentada: boolean;
pago_presentado: boolean;
completada: boolean;
}>(
`SELECT declaracion_presentada, pago_presentado, completada
FROM obligacion_periodos
WHERE obligacion_id = $1 AND periodo = $2`,
[obligacionId, periodo],
);
const existing = rows[0];
let declaracionPresentada = existing?.declaracion_presentada ?? false;
let pagoPresentado = existing?.pago_presentado ?? false;
if (esDocumentoDeclaracion(tipoDocumento)) declaracionPresentada = true;
if (tipoDocumento === 'pago') pagoPresentado = true;
const completada = !reqPago || pagoPresentado;
const now = new Date();
if (existing) {
await pool.query(
`UPDATE obligacion_periodos
SET declaracion_presentada = $3,
pago_presentado = $4,
completada = $5,
completada_at = CASE WHEN $5 THEN COALESCE(completada_at, $6) ELSE completada_at END,
completada_por = CASE WHEN $5 THEN COALESCE(completada_por, $7) ELSE completada_por END,
notas = COALESCE($8, notas)
WHERE obligacion_id = $1 AND periodo = $2`,
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada, now, completadaPor, notas ?? null],
);
} else {
await pool.query(
`INSERT INTO obligacion_periodos
(obligacion_id, periodo, declaracion_presentada, pago_presentado, completada, completada_at, completada_por, notas)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada, completada ? now : null, completada ? completadaPor : null, notas ?? null],
);
}
if (completada) {
await pool.query(
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
[`ob-${obligacionId}-${periodo}`],
);
}
return { completada, declaracionPresentada, pagoPresentado };
}
async function recalcPeriodoStatus(
pool: Pool,
obligacionId: string,
periodo: string,
reqPago: boolean,
): Promise<void> {
const { rows } = await pool.query<{ tipo_documento: string }>(
`SELECT tipo_documento FROM obligacion_evidencias WHERE obligacion_id = $1 AND periodo = $2`,
[obligacionId, periodo],
);
const declaracionPresentada = rows.some((r) => esDocumentoDeclaracion(r.tipo_documento));
const pagoPresentado = rows.some((r) => r.tipo_documento === 'pago');
const completada = !reqPago || pagoPresentado;
await pool.query(
`UPDATE obligacion_periodos
SET declaracion_presentada = $3,
pago_presentado = $4,
completada = $5,
completada_at = CASE WHEN $5 THEN COALESCE(completada_at, NOW()) ELSE completada_at END
WHERE obligacion_id = $1 AND periodo = $2`,
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada],
);
}
export async function createEvidencia(
pool: Pool,
data: CreateEvidenciaInput,
): Promise<{ evidencia: EvidenciaRow; completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean }> {
const obligacion = await getObligacionContribuyente(pool, data.obligacionId);
if (!obligacion) throw new Error('Obligación no encontrada');
if (obligacion.contribuyenteId !== data.contribuyenteId) throw new Error('La obligación no pertenece al contribuyente');
const reqPago = requierePago(obligacion);
const archivo = Buffer.from(data.pdfBase64, 'base64');
const { rows } = await pool.query(
`INSERT INTO obligacion_evidencias
(obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime, notas, subido_por, subido_por_email)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime,
notas, subido_por, subido_por_email, created_at`,
[data.obligacionId, data.periodo, data.contribuyenteId, data.tipoDocumento, archivo, data.pdfFilename, 'application/pdf', data.notas ?? null, data.subidoPor, data.subidoPorEmail],
);
const status = await updatePeriodoStatus(
pool,
data.obligacionId,
data.periodo,
data.tipoDocumento,
reqPago,
data.subidoPor,
data.notas,
);
return { evidencia: rowToEvidencia(rows[0]), ...status };
}
export async function listEvidencias(
pool: Pool,
contribuyenteId: string,
filters?: { periodo?: string; obligacionId?: string },
): Promise<EvidenciaRow[]> {
const conditions: string[] = ['contribuyente_id = $1'];
const params: unknown[] = [contribuyenteId];
if (filters?.periodo) {
params.push(filters.periodo);
conditions.push(`periodo = $${params.length}`);
}
if (filters?.obligacionId) {
params.push(filters.obligacionId);
conditions.push(`obligacion_id = $${params.length}`);
}
const { rows } = await pool.query(
`SELECT id, obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime,
notas, subido_por, subido_por_email, created_at
FROM obligacion_evidencias
WHERE ${conditions.join(' AND ')}
ORDER BY created_at DESC`,
params,
);
return rows.map(rowToEvidencia);
}
export async function getEvidenciaPdf(
pool: Pool,
id: number,
): Promise<{ buffer: Buffer; filename: string; mime: string } | null> {
const { rows } = await pool.query(
`SELECT archivo, archivo_filename, archivo_mime FROM obligacion_evidencias WHERE id = $1`,
[id],
);
if (rows.length === 0 || !rows[0].archivo) return null;
return {
buffer: Buffer.from(rows[0].archivo),
filename: rows[0].archivo_filename || `evidencia-${id}.pdf`,
mime: rows[0].archivo_mime || 'application/pdf',
};
}
export async function deleteEvidencia(
pool: Pool,
id: number,
): Promise<{ obligacionId: string; periodo: string } | null> {
const { rows } = await pool.query<{ obligacion_id: string; periodo: string }>(
`DELETE FROM obligacion_evidencias WHERE id = $1 RETURNING obligacion_id, periodo`,
[id],
);
if (rows.length === 0) return null;
const { obligacion_id: obligacionId, periodo } = rows[0];
const obligacion = await getObligacionContribuyente(pool, obligacionId);
if (obligacion) {
const reqPago = requierePago(obligacion);
await recalcPeriodoStatus(pool, obligacionId, periodo, reqPago);
}
return { obligacionId, periodo };
}
export async function getPeriodoStatus(
pool: Pool,
obligacionId: string,
periodo: string,
): Promise<{ completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean } | null> {
const { rows } = await pool.query<{
completada: boolean;
declaracion_presentada: boolean;
pago_presentado: boolean;
}>(
`SELECT completada, declaracion_presentada, pago_presentado
FROM obligacion_periodos
WHERE obligacion_id = $1 AND periodo = $2`,
[obligacionId, periodo],
);
if (rows.length === 0) return null;
return {
completada: rows[0].completada,
declaracionPresentada: rows[0].declaracion_presentada,
pagoPresentado: rows[0].pago_presentado,
};
}

View File

@@ -1,6 +1,11 @@
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).
@@ -255,6 +260,7 @@ export async function initRecomendaciones(
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';
@@ -351,13 +357,22 @@ export async function getObligacionesPorPeriodo(
const [year, month] = periodo.split('-').map(Number);
const currentPeriodo = new Date().toISOString().substring(0, 7);
const results: Array<ObligacionContribuyente & { periodStatus: string; periodoAplica: string; declaracion: DeclaracionLink | null }> = [];
const results: Array<ObligacionContribuyente & {
periodStatus: string;
periodoAplica: string;
declaracion: DeclaracionLink | null;
declaracionPresentada: boolean;
pagoPresentado: boolean;
requierePago: boolean;
}> = [];
// 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;
@@ -365,6 +380,7 @@ export async function getObligacionesPorPeriodo(
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,
@@ -377,10 +393,14 @@ export async function getObligacionesPorPeriodo(
`, [contribuyenteId]);
const completionMap = new Map<string, boolean>();
const declaracionPresentadaMap = new Map<string, boolean>();
const pagoPresentadoMap = new Map<string, boolean>();
const declaracionMap = new Map<string, DeclaracionLink | null>();
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,
@@ -407,6 +427,9 @@ export async function getObligacionesPorPeriodo(
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),
});
}
@@ -434,6 +457,9 @@ export async function getObligacionesPorPeriodo(
periodStatus: 'atrasada',
periodoAplica: pastPeriodo,
declaracion: null,
declaracionPresentada: declaracionPresentadaMap.get(pastKey) === true,
pagoPresentado: pagoPresentadoMap.get(pastKey) === true,
requierePago: requierePagoPorCatalogo(ob.catalogoId),
});
}
}
@@ -448,7 +474,14 @@ export async function getObligacionesPorPeriodo(
return a.nombre.localeCompare(b.nombre);
});
return results as Array<ObligacionContribuyente & { periodStatus: 'pendiente' | 'completada' | 'atrasada'; periodoAplica: string; declaracion: DeclaracionLink | null }>;
return results as Array<ObligacionContribuyente & {
periodStatus: 'pendiente' | 'completada' | 'atrasada';
periodoAplica: string;
declaracion: DeclaracionLink | null;
declaracionPresentada: boolean;
pagoPresentado: boolean;
requierePago: boolean;
}>;
}
function appliesTo(frecuencia: string | null, periodo: string): boolean {
@@ -457,6 +490,7 @@ function appliesTo(frecuencia: string | null, periodo: string): boolean {
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;

View File

@@ -299,7 +299,7 @@ async function saveCfdis(
cfdi_tipo_relacion=$88, cfdis_relacionados=$89,
last_sat_sync=NOW(), sat_sync_job_id=$90::uuid,
actualizado_en=NOW()
WHERE uuid = $1`,
WHERE LOWER(uuid) = LOWER($1)`,
[cfdi.uuid, ...vals]
);
// Re-insert conceptos for updated CFDI
@@ -355,7 +355,7 @@ async function saveCfdis(
[...vals, contribuyenteId]
);
// Get the inserted cfdi id and save conceptos
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE uuid = $1`, [cfdi.uuid]);
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE LOWER(uuid) = LOWER($1)`, [cfdi.uuid]);
if (newRow) await saveConceptosWithRetry(pool, newRow.id, cfdi);
inserted++;
}
@@ -609,30 +609,35 @@ async function requestAndDownload(
});
let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
// NOTA: se desactivó la reutilización de requestIds de jobs previos porque el SAT
// limita las descargas por solicitud. Reusar un requestId de un job anterior puede
// agotar el límite y devolver "Máximo de descargas permitidas", dejando el recovery
// sin poder descargar. Cada job nuevo crea sus propias solicitudes.
//
// Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente
// SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo).
if (!existingMap[kindKey]) {
const previousJob = await prisma.satSyncJob.findFirst({
where: {
tenantId: jobRow?.tenantId,
contribuyenteId: jobRow?.contribuyenteId ?? null,
id: { not: jobId },
dateFrom: jobRow?.dateFrom,
dateTo: jobRow?.dateTo,
},
orderBy: { createdAt: 'desc' },
select: { satRequestIds: true },
});
if (previousJob?.satRequestIds) {
const prevMap = previousJob.satRequestIds as Record<string, string>;
if (prevMap[kindKey]) {
console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
// Copiar al job actual para futuros usos
await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
}
}
}
// if (!existingMap[kindKey]) {
// const previousJob = await prisma.satSyncJob.findFirst({
// where: {
// tenantId: jobRow?.tenantId,
// contribuyenteId: jobRow?.contribuyenteId ?? null,
// id: { not: jobId },
// dateFrom: jobRow?.dateFrom,
// dateTo: jobRow?.dateTo,
// },
// orderBy: { createdAt: 'desc' },
// select: { satRequestIds: true },
// });
// if (previousJob?.satRequestIds) {
// const prevMap = previousJob.satRequestIds as Record<string, string>;
// if (prevMap[kindKey]) {
// console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
// // Copiar al job actual para futuros usos
// await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
// existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
// }
// }
// }
let requestId: string | null = existingMap[kindKey] || null;
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;