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 { 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', 'semestral', 'anual']).optional(), impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'])).min(1, 'Selecciona al menos un impuesto'), 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'] }, ); 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. // 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', 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); } }