import type { Request, Response, NextFunction } from 'express'; import * as cfdiService from '../services/cfdi.service.js'; import { AppError } from '../middlewares/error.middleware.js'; import { GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../services/dashboard.service.js'; import { getRegimenesIgnoradosClaves } from '../services/regimen.service.js'; import { resolveContribuyenteContext } from '../utils/contribuyente-context.js'; import { buildExtraFilters } from '../services/_shared/cfdi-filters.js'; import type { CfdiFilters } from '@horux/shared'; export async function getCfdis(req: Request, res: Response, next: NextFunction) { try { if (!req.tenantPool) { return next(new AppError(400, 'Tenant no configurado')); } const filters: CfdiFilters = { tipo: req.query.tipo as any, tipoComprobante: req.query.tipoComprobante as any, estado: req.query.estado as any, fechaInicio: req.query.fechaInicio as string, fechaFin: req.query.fechaFin as string, rfc: req.query.rfc as string, emisor: req.query.emisor as string, receptor: req.query.receptor as string, search: req.query.search as string, contribuyenteId: req.query.contribuyenteId as string, page: parseInt(req.query.page as string) || 1, // Cap defensivo: paginación normal usa 20-100; export pide 10000. // Más de eso se rechaza para no agotar memoria del proceso. limit: Math.min(parseInt(req.query.limit as string) || 20, 10_000), }; const result = await cfdiService.getCfdis(req.tenantPool, filters); res.json(result); } catch (error) { next(error); } } export async function getCfdiById(req: Request, res: Response, next: NextFunction) { try { if (!req.tenantPool) { return next(new AppError(400, 'Tenant no configurado')); } const cfdi = await cfdiService.getCfdiById(req.tenantPool, String(req.params.id)); if (!cfdi) { return next(new AppError(404, 'CFDI no encontrado')); } res.json(cfdi); } catch (error) { next(error); } } export async function getXml(req: Request, res: Response, next: NextFunction) { try { if (!req.tenantPool) { return next(new AppError(400, 'Tenant no configurado')); } const xml = await cfdiService.getXmlById(req.tenantPool, String(req.params.id)); if (!xml) { return next(new AppError(404, 'XML no encontrado para este CFDI')); } res.set('Content-Type', 'application/xml'); res.set('Content-Disposition', `attachment; filename="cfdi-${req.params.id}.xml"`); res.send(xml); } catch (error) { next(error); } } export async function listConceptos(req: Request, res: Response, next: NextFunction) { try { if (!req.tenantPool) return next(new AppError(400, 'Tenant no configurado')); const filters: CfdiFilters & { uuidLike?: string; claveProdServ?: string; descripcionConcepto?: string; orderBy?: 'fecha' | 'importe'; orderDir?: 'asc' | 'desc'; } = { tipo: req.query.tipo as any, tipoComprobante: req.query.tipoComprobante as any, estado: req.query.estado as any, fechaInicio: req.query.fechaInicio as string, fechaFin: req.query.fechaFin as string, rfc: req.query.rfc as string, emisor: req.query.emisor as string, receptor: req.query.receptor as string, search: req.query.search as string, contribuyenteId: req.query.contribuyenteId as string, page: parseInt(req.query.page as string) || 1, limit: Math.min(parseInt(req.query.limit as string) || 50, 10_000), uuidLike: req.query.uuidLike as string, claveProdServ: req.query.claveProdServ as string, descripcionConcepto: req.query.descripcionConcepto as string, orderBy: req.query.orderBy as 'fecha' | 'importe', orderDir: req.query.orderDir as 'asc' | 'desc', }; const result = await cfdiService.getConceptosList(req.tenantPool, filters); res.json(result); } catch (error) { next(error); } } export async function getConceptos(req: Request, res: Response, next: NextFunction) { try { if (!req.tenantPool) { return next(new AppError(400, 'Tenant no configurado')); } const conceptos = await cfdiService.getConceptos(req.tenantPool, String(req.params.id)); res.json(conceptos); } catch (error) { next(error); } } export async function drillDown(req: Request, res: Response, next: NextFunction) { try { if (!req.tenantPool) { return next(new AppError(400, 'Tenant no configurado')); } const { fechaInicio, fechaFin, type, tipoComprobante, metodoPago, regimenEmisor, regimenReceptor, status, contribuyenteId, bucket, considerarActivos, considerarNCs, } = req.query; // Default true (consistente con el resto del sistema). Solo false si la URL // pasa explícitamente '0' o 'false'. Sin estos toggles, el drill ignoraba // el filtro de "Considerar activos" y mostraba CFDIs que la card sí estaba // excluyendo del total. const considerarActivosBool = considerarActivos !== '0' && considerarActivos !== 'false'; const considerarNCsBool = considerarNCs !== '0' && considerarNCs !== 'false'; const extra = buildExtraFilters(considerarActivosBool, considerarNCsBool); let where = 'WHERE 1=1'; const params: any[] = []; let pi = 1; // `bucket` expande la combinación (type, tipo_comprobante, metodo_pago, // régimen) exactamente igual a la fórmula de KPIs/tarjetas — para que // el drill-down cuadre línea a línea con el total del header. // // Reglas por bucket (alineado con dashboard.service y impuestos.service): // ingresos: 3 grupos de régimen del emisor con fórmulas distintas. // Grupo 1 (PF Empresarial 606/612/621/625/626): EMIT I PUE + EMIT P // Grupo 2 (Sueldos 605, recibido como N): RECIB N PUE con receptor=605 // Grupo 3 (PM y otros): EMIT I PUE+PPD // gastos: uniforme todos los regímenes del receptor // RECIB I PUE + RECIB P // causado (IVA): EMIT I PUE + EMIT P + EMIT E PUE (excl. E/07) // acreditable (IVA): RECIB I PUE + RECIB P + RECIB E PUE (excl. E/07) // // Las E PUE NO entran en ingresos/gastos — viven en sus propios drills // ("NCs Emitidas" / "NCs Recibidas"). En IVA causado/acreditable sí // entran, ya que el IVA de las NCs sí se acredita/cancela. // // Régimenes "ignorados" por el tenant se excluyen en todos los buckets. // Las NC que restan se muestran como filas con signo (frontend las resta // del total del header). Si `bucket` se pasa, se ignoran filtros // type/tipoComprobante/metodoPago de entrada. const bucketStr = typeof bucket === 'string' ? bucket.toLowerCase() : ''; const bucketApplied = bucketStr === 'ingresos' || bucketStr === 'gastos' || bucketStr === 'causado' || bucketStr === 'acreditable' || bucketStr === 'ncs_emitidas' || bucketStr === 'ncs_recibidas' || bucketStr === 'no_deducibles_efectivo'; // Régimenes ignorados por el tenant (configurable en /regimenes). Se // excluyen del lado correspondiente según el bucket. const ignorados = req.user?.tenantId ? await getRegimenesIgnoradosClaves(req.user.tenantId) : []; // Resolver condiciones esEmisor/esReceptor basadas en RFC del contribuyente. // Reemplaza `type = 'EMITIDO/RECIBIDO' AND contribuyente_id = X` por un // filtro por RFC — fuente de verdad cuando dos contribuyentes del tenant // se facturan entre sí (type/contribuyente_id pueden ser inconsistentes). const contribIdStr = typeof contribuyenteId === 'string' ? contribuyenteId : undefined; const cfdiCtx = req.user?.tenantId ? await resolveContribuyenteContext(req.tenantPool, req.user.tenantId, contribIdStr) : null; const esEmisor = cfdiCtx?.esEmisor || `type = 'EMITIDO'`; const esReceptor = cfdiCtx?.esReceptor || `type = 'RECIBIDO'`; const NO_IGNORADO_EMISOR = ignorados.length > 0 ? `AND (regimen_fiscal_emisor IS NULL OR regimen_fiscal_emisor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))` : ''; const NO_IGNORADO_RECEPTOR = ignorados.length > 0 ? `AND (regimen_fiscal_receptor IS NULL OR regimen_fiscal_receptor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))` : ''; const g1 = GRUPO_PF_EMPRESARIAL.map(r => `'${r}'`).join(','); const g3 = GRUPO_PM_OTROS.map(r => `'${r}'`).join(','); // Conjunto canónico de regímenes que el dashboard considera (excluye 616 // extranjero y otros fuera del catálogo). El drill debe respetarlo para // cuadrar con los KPIs/tarjetas. const TODOS_REGS = [...GRUPO_PF_EMPRESARIAL, '605', ...GRUPO_PM_OTROS] .map(r => `'${r}'`) .join(','); const E_NO_ANTICIPO = `COALESCE(cfdi_tipo_relacion, '') <> '07'`; if (bucketStr === 'ingresos') { // 3 grupos con fórmulas distintas. Filtro por RFC (esEmisor/esReceptor). // Las E PUE se exhiben en su propia card "NCs Emitidas" — no entran aquí. // I/07 PPD compensación: cuando el contribuyente emite I/07 PPD con E // relacionada en mismo mes, el cálculo aporta el valor de la E. La I/07 // PPD aparece en el drill (parte del Grupo 1 universe vía I PPD), pero // las E ya no. where += ` AND ( ( -- Grupo 1 PF Empresarial ${esEmisor} AND regimen_fiscal_emisor IN (${g1}) AND ( (tipo_comprobante = 'I' AND metodo_pago = 'PUE') OR (tipo_comprobante = 'P') ) ) OR ( -- Grupo 2 Sueldos: nómina recibida 605 ${esReceptor} AND tipo_comprobante = 'N' AND metodo_pago = 'PUE' AND regimen_fiscal_receptor = '605' ) OR ( -- Grupo 3 PM y otros ${esEmisor} AND regimen_fiscal_emisor IN (${g3}) AND tipo_comprobante = 'I' AND metodo_pago IN ('PUE','PPD') ) ) ${NO_IGNORADO_EMISOR.replace('regimen_fiscal_emisor', `CASE WHEN ${esEmisor} THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END`)}`; } else if (bucketStr === 'gastos') { // Las E PUE se exhiben en su propia card "NCs Recibidas" — no entran aquí. where += ` AND ( ${esReceptor} AND ( (tipo_comprobante = 'I' AND metodo_pago = 'PUE') OR (tipo_comprobante = 'P') ) AND regimen_fiscal_receptor IN (${TODOS_REGS}) ) ${NO_IGNORADO_RECEPTOR}`; } else if (bucketStr === 'causado') { where += ` AND ( ${esEmisor} AND ( (tipo_comprobante = 'I' AND metodo_pago = 'PUE') OR (tipo_comprobante = 'P') OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND ${E_NO_ANTICIPO}) ) AND regimen_fiscal_emisor IN (${TODOS_REGS}) ) ${NO_IGNORADO_EMISOR}`; } else if (bucketStr === 'ncs_emitidas') { // E PUE emitidas por el contribuyente, por régimen del emisor. // Mirror del card "NCs Emitidas" en /impuestos > ISR. // Sin restringir a TODOS_REGS — el calcular function tampoco lo hace // (acepta cualquier régimen no-NULL no-ignorado, incluyendo 616 // Extranjero, etc.). Si el contador filtró regímenes ignorados, el // NO_IGNORADO_EMISOR ya los excluye. where += ` AND ( ${esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND regimen_fiscal_emisor IS NOT NULL ) ${NO_IGNORADO_EMISOR}`; } else if (bucketStr === 'ncs_recibidas') { // E PUE recibidas por el contribuyente, por régimen del receptor. // Mirror del card "NCs Recibidas" en /impuestos > ISR. where += ` AND ( ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND regimen_fiscal_receptor IS NOT NULL ) ${NO_IGNORADO_RECEPTOR}`; } else if (bucketStr === 'no_deducibles_efectivo') { // Art. 27 fracción III LISR — facturas recibidas pagadas en efectivo // (forma_pago='01') con monto > $2,000. Mirror del card "No Deducibles". // I PUE: comparación con total_mxn. P: con monto_pago_mxn. where += ` AND ( ${esReceptor} AND forma_pago = '01' AND ( (tipo_comprobante = 'I' AND metodo_pago = 'PUE' AND COALESCE(total_mxn, 0) > 2000) OR (tipo_comprobante = 'P' AND COALESCE(monto_pago_mxn, 0) > 2000) ) AND regimen_fiscal_receptor IS NOT NULL ) ${NO_IGNORADO_RECEPTOR}`; } else if (bucketStr === 'acreditable') { where += ` AND ( ${esReceptor} AND ( (tipo_comprobante = 'I' AND metodo_pago = 'PUE') OR (tipo_comprobante = 'P') OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND ${E_NO_ANTICIPO}) ) AND regimen_fiscal_receptor IN (${TODOS_REGS}) ) ${NO_IGNORADO_RECEPTOR}`; } // Fecha efectiva: para CFDIs tipo P (complementos de pago) usa fecha_pago_p // (cuándo el cliente cobró) en vez de fecha_emision (cuándo se emitió el // complemento). Así el drill-down es coherente con los KPIs — un P emitido // en mayo que cobró una PPD de noviembre aparece en noviembre, no en mayo. const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`; if (fechaInicio) { where += ` AND ${FECHA_EFECTIVA} >= $${pi++}::date`; params.push(fechaInicio); } if (fechaFin) { where += ` AND ${FECHA_EFECTIVA} < ($${pi++}::date + interval '1 day')`; params.push(fechaFin); } if (!bucketApplied) { if (type) { where += ` AND type = $${pi++}`; params.push(type); } // tipoComprobante acepta valor único ('I') o CSV ('I,P'). Cuando la lista // incluye P, el filtro metodoPago NO se aplica a los P (que no tienen), // para que un drill-down "Ingresos del Mes" muestre I PUE + todos los P. const tiposList = tipoComprobante ? (tipoComprobante as string).split(',').map(t => t.trim()).filter(Boolean) : []; const includesP = tiposList.includes('P'); if (tiposList.length === 1) { where += ` AND tipo_comprobante = $${pi++}`; params.push(tiposList[0]); } else if (tiposList.length > 1) { where += ` AND tipo_comprobante = ANY($${pi++})`; params.push(tiposList); } if (metodoPago) { const metodos = (metodoPago as string).split(','); if (includesP) { // P no tiene metodo_pago: el filtro aplica solo a los no-P where += ` AND (tipo_comprobante = 'P' OR metodo_pago = ANY($${pi++}))`; params.push(metodos); } else { where += ` AND metodo_pago = ANY($${pi++})`; params.push(metodos); } } } if (regimenEmisor) { where += ` AND regimen_fiscal_emisor = $${pi++}`; params.push(regimenEmisor); } if (regimenReceptor) { where += ` AND regimen_fiscal_receptor = $${pi++}`; params.push(regimenReceptor); } if (status) { if (status === 'vigente') { where += ` AND status NOT IN ('Cancelado', '0')`; } else { where += ` AND status IN ('Cancelado', '0')`; } } if (contribuyenteId && !bucketApplied) { // Solo aplica cuando NO hay bucket (drill crudo, sin semantic de lado). // Con bucket, esEmisor/esReceptor ya restringen por RFC del contribuyente. // Sin bucket, filtramos inclusivo: contribuyente_id O RFC en cualquier lado. if (cfdiCtx) { where += ` AND ${cfdiCtx.contribFilter.replace(/^AND /, '')}`; } } // Aplica filtros de "Considerar activos" / "Considerar NCs" — alineado // con los KPIs/cards. Sin esto el drill mostraba CFDIs que la card había // excluido (ej. P que paga una I de activo con uso_cfdi=I03). where += extra; const { rows } = await req.tenantPool.query(` SELECT id, uuid, type, tipo_comprobante as "tipoComprobante", fecha_emision as "fechaEmision", status, rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", subtotal, subtotal_mxn as "subtotalMxn", total, total_mxn as "totalMxn", moneda, metodo_pago as "metodoPago", iva_traslado_mxn as "ivaTrasladoMxn", iva_retencion_mxn as "ivaRetencionMxn", isr_retencion_mxn as "isrRetencionMxn", monto_pago_mxn as "montoPagoMxn", regimen_fiscal_emisor as "regimenEmisor", regimen_fiscal_receptor as "regimenReceptor" FROM cfdis ${where} ORDER BY fecha_emision DESC LIMIT 500 `, params); res.json(rows); } catch (error) { next(error); } } export async function getEmisores(req: Request, res: Response, next: NextFunction) { try { if (!req.tenantPool) { return next(new AppError(400, 'Tenant no configurado')); } const search = (req.query.search as string) || ''; if (search.length < 2) { return res.json([]); } const contribuyenteId = req.query.contribuyenteId as string | undefined; const emisores = await cfdiService.getEmisores(req.tenantPool, search, 10, contribuyenteId); res.json(emisores); } catch (error) { next(error); } } export async function getReceptores(req: Request, res: Response, next: NextFunction) { try { if (!req.tenantPool) { return next(new AppError(400, 'Tenant no configurado')); } const search = (req.query.search as string) || ''; if (search.length < 2) { return res.json([]); } const contribuyenteId = req.query.contribuyenteId as string | undefined; const receptores = await cfdiService.getReceptores(req.tenantPool, search, 10, contribuyenteId); res.json(receptores); } catch (error) { next(error); } } export async function getResumen(req: Request, res: Response, next: NextFunction) { try { if (!req.tenantPool) { return next(new AppError(400, 'Tenant no configurado')); } const año = parseInt(req.query.año as string) || new Date().getFullYear(); const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1; const contribuyenteId = req.query.contribuyenteId as string | undefined; const resumen = await cfdiService.getResumenCfdis(req.tenantPool, año, mes, contribuyenteId); res.json(resumen); } catch (error) { next(error); } } export async function createCfdi(req: Request, res: Response, next: NextFunction) { try { if (!req.tenantPool) { return next(new AppError(400, 'Tenant no configurado')); } if (!['owner', 'contador'].includes(req.user!.role)) { return next(new AppError(403, 'No tienes permisos para agregar CFDIs')); } const cfdi = await cfdiService.createCfdi(req.tenantPool, req.body); res.status(201).json(cfdi); } catch (error: any) { if (error.message?.includes('duplicate')) { return next(new AppError(409, 'Este CFDI ya existe (UUID duplicado)')); } next(error); } } export async function createManyCfdis(req: Request, res: Response, next: NextFunction) { try { if (!req.tenantPool) { return next(new AppError(400, 'Tenant no configurado')); } if (!['owner', 'contador'].includes(req.user!.role)) { return next(new AppError(403, 'No tienes permisos para agregar CFDIs')); } if (!Array.isArray(req.body.cfdis)) { return next(new AppError(400, 'Se requiere un array de CFDIs')); } const batchInfo = { batchNumber: req.body.batchNumber || 1, totalBatches: req.body.totalBatches || 1, totalFiles: req.body.totalFiles || req.body.cfdis.length }; console.log(`[CFDI Bulk] Lote ${batchInfo.batchNumber}/${batchInfo.totalBatches} - ${req.body.cfdis.length} CFDIs`); const result = await cfdiService.createManyCfdisBatch(req.tenantPool, req.body.cfdis); res.status(201).json({ message: `Lote ${batchInfo.batchNumber} procesado`, batchNumber: batchInfo.batchNumber, totalBatches: batchInfo.totalBatches, inserted: result.inserted, duplicates: result.duplicates, errors: result.errors, errorMessages: result.errorMessages.slice(0, 5) }); } catch (error: any) { console.error('[CFDI Bulk Error]', error.message, error.stack); next(new AppError(400, error.message || 'Error al procesar CFDIs')); } } export async function deleteCfdi(req: Request, res: Response, next: NextFunction) { try { if (!req.tenantPool) { return next(new AppError(400, 'Tenant no configurado')); } if (!['owner', 'contador'].includes(req.user!.role)) { return next(new AppError(403, 'No tienes permisos para eliminar CFDIs')); } await cfdiService.deleteCfdi(req.tenantPool, String(req.params.id)); res.status(204).send(); } catch (error) { next(error); } }