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 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, 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: parseInt(req.query.limit as string) || 20, }; 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 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, } = req.query; 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 + EMIT E PUE (excl. E/07) // Grupo 2 (Sueldos 605, recibido como N): // RECIB N PUE con receptor=605 // Grupo 3 (PM y otros): EMIT I PUE+PPD + EMIT E PUE // gastos: uniforme todos los regímenes del receptor // RECIB I PUE + RECIB P + RECIB E PUE (excl. E/07) // 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) // // 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'; // 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). // Grupo 1 usa Método A: todas las I/07 y E/07 se incluyen (sin filtro // `E_NO_ANTICIPO`) — la suma algebraica se neutraliza correctamente // cuando anticipo, I/07 y E/07 están en el mismo universo de la query. 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 (tipo_comprobante = 'E' AND metodo_pago = 'PUE') ) ) 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')) OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE') ) ) ) ${NO_IGNORADO_EMISOR.replace('regimen_fiscal_emisor', `CASE WHEN ${esEmisor} THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END`)}`; } else if (bucketStr === 'gastos') { // Método A: sin E_NO_ANTICIPO — las E/07 también aparecen en el // drill (restan del gasto al igual que en el KPI). where += ` AND ( ${esReceptor} AND ( (tipo_comprobante = 'I' AND metodo_pago = 'PUE') OR (tipo_comprobante = 'P') OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE') ) 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 === '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 /, '')}`; } } 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); } }