- 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.
432 lines
18 KiB
TypeScript
432 lines
18 KiB
TypeScript
import type { Request, Response, NextFunction } from 'express';
|
|
import { z } from 'zod';
|
|
import { getOpiniones, getOpinionPdf, consultarOpinion, consultarOpinionContribuyente } from '../services/opinion-cumplimiento.service.js';
|
|
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';
|
|
|
|
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
|
|
|
function effectiveTenantId(req: Request): string {
|
|
return req.viewingTenantId || req.user!.tenantId;
|
|
}
|
|
|
|
export async function listarOpiniones(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
|
let rfc: string | undefined;
|
|
if (contribuyenteId) {
|
|
const { rows } = await req.tenantPool!.query(
|
|
'SELECT rfc FROM contribuyentes WHERE entidad_id = $1',
|
|
[contribuyenteId],
|
|
);
|
|
rfc = rows[0]?.rfc;
|
|
}
|
|
const opiniones = await getOpiniones(req.tenantPool!, 5, rfc);
|
|
res.json(opiniones);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
export async function descargarPdf(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const id = parseInt(String(req.params.id));
|
|
if (isNaN(id)) return res.status(400).json({ error: 'ID inválido' });
|
|
|
|
const pdf = await getOpinionPdf(req.tenantPool!, id);
|
|
if (!pdf) return res.status(404).json({ error: 'Opinión no encontrada' });
|
|
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
res.setHeader('Content-Disposition', `attachment; filename="opinion_cumplimiento_${id}.pdf"`);
|
|
res.send(pdf);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
export async function consultarManual(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const tenantId = effectiveTenantId(req);
|
|
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
|
|
|
let opinion;
|
|
if (contribuyenteId) {
|
|
opinion = await consultarOpinionContribuyente(req.tenantPool!, contribuyenteId);
|
|
} else {
|
|
opinion = await consultarOpinion(tenantId);
|
|
}
|
|
res.json(opinion);
|
|
} catch (error: any) {
|
|
if (error.message?.includes('FIEL')) {
|
|
return res.status(400).json({ error: error.message });
|
|
}
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Declaraciones provisionales
|
|
// ============================================================================
|
|
|
|
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
|
|
|
|
function canUpload(req: Request): boolean {
|
|
return ROLES_UPLOAD.includes(req.user!.role);
|
|
}
|
|
|
|
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', '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),
|
|
ligaPagoBase64: z.string().min(100).optional(),
|
|
ligaPagoFilename: z.string().min(1).max(255).optional(),
|
|
notas: z.string().max(2000).optional(),
|
|
}).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) {
|
|
try {
|
|
const fechaDesde = req.query.fechaDesde as string | undefined;
|
|
const fechaHasta = req.query.fechaHasta as string | undefined;
|
|
const contribuyenteId = typeof req.query.contribuyenteId === 'string' && req.query.contribuyenteId
|
|
? req.query.contribuyenteId
|
|
: null;
|
|
const data = await declaracionesService.listDeclaraciones(req.tenantPool!, fechaDesde, fechaHasta, contribuyenteId);
|
|
res.json(data);
|
|
} catch (error) { next(error); }
|
|
}
|
|
|
|
export async function crearDeclaracion(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir declaraciones' });
|
|
const data = createDeclaracionSchema.parse(req.body);
|
|
const contribuyenteId = req.body.contribuyenteId as string | undefined;
|
|
const result = await declaracionesService.createDeclaracion(req.tenantPool!, {
|
|
...data,
|
|
creadoPor: req.user!.email,
|
|
creadoPorUserId: req.user!.userId,
|
|
contribuyenteId,
|
|
});
|
|
|
|
// 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!,
|
|
tenantId: req.viewingTenantId ?? req.user!.tenantId,
|
|
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,
|
|
impuestos: data.impuestos as string[],
|
|
montoPago: data.montoPago ?? null,
|
|
},
|
|
}).catch((err: any) => console.error('[notifyDocumentoSubido declaracion]', 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));
|
|
if (error?.message?.includes('Ya existe') || error?.message?.includes('normal')) {
|
|
return next(new AppError(400, error.message));
|
|
}
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
const comprobantePagoSchema = z.object({
|
|
pdfBase64: z.string().min(100),
|
|
pdfFilename: z.string().min(1).max(255),
|
|
});
|
|
|
|
export async function subirComprobantePago(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir comprobantes' });
|
|
const id = parseInt(String(req.params.id));
|
|
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
|
|
const data = comprobantePagoSchema.parse(req.body);
|
|
const result = await declaracionesService.uploadComprobantePago(req.tenantPool!, id, {
|
|
...data,
|
|
uploadedByUserId: req.user!.userId,
|
|
});
|
|
res.json(result);
|
|
} catch (error: any) {
|
|
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
|
if (error?.message?.includes('no encontrada')) {
|
|
return next(new AppError(404, error.message));
|
|
}
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
export async function descargarDeclaracionPdf(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 v = req.params.variant;
|
|
const variant: 'declaracion' | 'liga' | 'pago' = v === 'pago' ? 'pago' : v === 'liga' ? 'liga' : 'declaracion';
|
|
const pdf = await declaracionesService.getDeclaracionPdf(req.tenantPool!, id, variant);
|
|
if (!pdf) return res.status(404).json({ message: 'PDF no encontrado' });
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${pdf.filename}"`);
|
|
res.send(pdf.buffer);
|
|
} catch (error) { next(error); }
|
|
}
|
|
|
|
// ============================================================================
|
|
// Constancia de Situación Fiscal
|
|
// ============================================================================
|
|
|
|
export async function listarConstancias(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
|
let rfc: string | undefined;
|
|
if (contribuyenteId) {
|
|
const { rows } = await req.tenantPool!.query(
|
|
'SELECT rfc FROM contribuyentes WHERE entidad_id = $1',
|
|
[contribuyenteId],
|
|
);
|
|
rfc = rows[0]?.rfc;
|
|
}
|
|
const data = await constanciaService.listConstancias(req.tenantPool!, 12, rfc);
|
|
res.json(data);
|
|
} catch (error) { next(error); }
|
|
}
|
|
|
|
export async function descargarConstanciaPdf(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 constanciaService.getConstanciaPdf(req.tenantPool!, id);
|
|
if (!pdf) return res.status(404).json({ message: 'Constancia no encontrada' });
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
res.setHeader('Content-Disposition', `attachment; filename="constancia_${id}.pdf"`);
|
|
res.send(pdf);
|
|
} catch (error) { next(error); }
|
|
}
|
|
|
|
export async function consultarConstanciaManual(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const tenantId = effectiveTenantId(req);
|
|
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
|
|
|
let constancia;
|
|
if (contribuyenteId) {
|
|
constancia = await constanciaService.consultarConstanciaContribuyente(req.tenantPool!, contribuyenteId);
|
|
} else {
|
|
constancia = await constanciaService.consultarConstancia(tenantId);
|
|
}
|
|
res.json(constancia);
|
|
} catch (error: any) {
|
|
if (error.message?.includes('FIEL')) return res.status(400).json({ error: error.message });
|
|
if (error.message?.includes('Timeout') || error.name === 'TimeoutError') {
|
|
return res.status(504).json({ error: 'El portal del SAT no respondió a tiempo. Intenta de nuevo en unos minutos.' });
|
|
}
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
export async function eliminarDeclaracion(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para eliminar declaraciones' });
|
|
const id = parseInt(String(req.params.id));
|
|
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
|
|
await declaracionesService.deleteDeclaracion(req.tenantPool!, id);
|
|
res.status(204).send();
|
|
} catch (error: any) {
|
|
if (error?.message?.includes('no encontrada')) {
|
|
return next(new AppError(404, error.message));
|
|
}
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Documentos Extras — PDFs libres (acuses, contratos, poderes, estados, etc.)
|
|
// ============================================================================
|
|
|
|
const createExtraSchema = z.object({
|
|
nombre: z.string().min(1, 'Nombre requerido').max(255),
|
|
descripcion: z.string().max(2000).optional(),
|
|
categoria: z.string().max(100).optional(),
|
|
pdfBase64: z.string().min(100, 'PDF requerido'),
|
|
pdfFilename: z.string().min(1).max(255),
|
|
});
|
|
|
|
export async function listarExtras(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
|
const categoria = req.query.categoria as string | undefined;
|
|
const data = await extrasService.listExtras(req.tenantPool!, contribuyenteId, categoria);
|
|
res.json(data);
|
|
} catch (error) { next(error); }
|
|
}
|
|
|
|
export async function crearExtra(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir documentos' });
|
|
const data = createExtraSchema.parse(req.body);
|
|
const contribuyenteId = req.body.contribuyenteId as string | undefined;
|
|
const result = await extrasService.createExtra(req.tenantPool!, {
|
|
...data,
|
|
contribuyenteId: contribuyenteId ?? null,
|
|
subidoPor: req.user!.email,
|
|
});
|
|
|
|
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
|
|
notifyDocumentoSubido({
|
|
pool: req.tenantPool!,
|
|
tenantId: req.viewingTenantId ?? req.user!.tenantId,
|
|
contribuyenteId: contribuyenteId ?? null,
|
|
subidoPor: req.user!.email,
|
|
kind: 'extra',
|
|
extra: {
|
|
nombre: data.nombre,
|
|
descripcion: data.descripcion ?? null,
|
|
categoria: data.categoria ?? null,
|
|
},
|
|
}).catch((err: any) => console.error('[notifyDocumentoSubido extra]', 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 descargarExtraPdf(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 extrasService.getExtraPdf(req.tenantPool!, id);
|
|
if (!pdf) return next(new AppError(404, 'Documento no encontrado'));
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${pdf.filename}"`);
|
|
res.send(pdf.buffer);
|
|
} catch (error) { next(error); }
|
|
}
|
|
|
|
export async function eliminarExtra(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 ok = await extrasService.deleteExtra(req.tenantPool!, id);
|
|
if (!ok) return next(new AppError(404, 'Documento no encontrado'));
|
|
res.status(204).send();
|
|
} catch (error) { next(error); }
|
|
}
|
|
|
|
export async function listarCategoriasExtras(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
|
const data = await extrasService.listCategorias(req.tenantPool!, contribuyenteId);
|
|
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); }
|
|
}
|