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:
@@ -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ñ]');
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user