Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,530 @@
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);
}
}