import type { Request, Response, NextFunction } from 'express'; import type { Pool } from 'pg'; import { z } from 'zod'; import * as facturapiService from '../services/facturapi.service.js'; import { createInvoiceContribuyente, cancelInvoiceContribuyente, downloadPdfContribuyente, downloadXmlContribuyente, sendInvoiceByEmailContribuyente, getCustomizationContribuyente, uploadLogoContribuyente, updateColorContribuyente, } from '../services/contribuyente-facturapi.service.js'; import { parseXml } from '../services/sat/sat-parser.service.js'; import * as tenantsService from '../services/tenants.service.js'; import { prisma } from '../config/database.js'; import { AppError } from '../middlewares/error.middleware.js'; import { hasPlatformRole } from '../utils/platform-admin.js'; import { auditFromReq } from '../utils/audit.js'; import { recomputarSaldoPendiente } from '../utils/saldo.js'; function effectiveTenantId(req: Request): string { return req.viewingTenantId || req.user!.tenantId; } /** * Detecta si un mensaje de error del SAT (propagado por Facturapi) indica * que el CSD aún no está en la Lista de Contribuyentes Obligados (LCO). * El SAT tarda 24-72h en propagar un CSD nuevo; durante esa ventana todo * intento de emisión falla. Cuando se detecta este patrón se marca la * org con `last_lco_rejection_at` para que el frontend muestre un banner. */ function isLcoRejection(errorMessage: string): boolean { if (!errorMessage) return false; const msg = errorMessage.toLowerCase(); return ( /no se encontr.*rfc.*lco/.test(msg) || /rfc.*no.*registrado.*lco/.test(msg) || /lista.*contribuyentes.*obligados/.test(msg) || /csd.*no.*registrad/.test(msg) || msg.includes('lco') ); } /** * Registra el timestamp del rechazo LCO en la fila correspondiente de * `facturapi_orgs`. Fire-and-forget: un fallo aquí no bloquea la * propagación del error al frontend. */ async function markLcoRejection( pool: import('pg').Pool, contribuyenteId: string | undefined, ): Promise { try { if (contribuyenteId) { await pool.query( `UPDATE facturapi_orgs SET last_lco_rejection_at = NOW() WHERE contribuyente_id = $1`, [contribuyenteId], ); } // Nota: Horux360 single-tenant usaría `tenants.facturapi_org_id` en // BD central; en el fork multi-contribuyente solo marcamos la fila // por-contribuyente. Si el user emite desde el org del tenant (sin // contribuyenteId), el banner no aplicaría aquí. } catch (e: any) { console.error('[facturacion.markLcoRejection] falló UPDATE:', e?.message || e); } } // ── Organización ── export async function getOrgStatus(req: Request, res: Response, next: NextFunction) { try { const status = await facturapiService.getOrganizationStatus(effectiveTenantId(req)); res.json(status); } catch (error) { next(error); } } export async function createOrg(req: Request, res: Response, next: NextFunction) { try { const result = await facturapiService.createOrganization(effectiveTenantId(req)); res.status(201).json(result); } catch (error) { next(error); } } // ── CSD ── export async function uploadCsd(req: Request, res: Response, next: NextFunction) { try { const { cerFile, keyFile, password } = req.body; if (!cerFile || !keyFile || !password) { return res.status(400).json({ message: 'cerFile, keyFile y password son requeridos' }); } const result = await facturapiService.uploadCsd(effectiveTenantId(req), cerFile, keyFile, password); if (!result.success) return res.status(400).json({ message: result.message }); res.json(result); } catch (error) { next(error); } } // ── Emisión ── export async function emitir(req: Request, res: Response, next: NextFunction) { try { const tenantId = effectiveTenantId(req); const contribuyenteId = req.body.contribuyenteId as string | undefined; // ── Validar CFDIs relacionados antes de consumir timbre ── // En Live, SAT rechaza si el UUID relacionado no existe, está cancelado, // o el rfc_receptor no coincide con el customer.taxId del CFDI nuevo. // Catch temprano con error legible en vez de un 500 oscuro de Facturapi. const relatedDocs: Array<{ relationship: string; uuids: string[] }> = req.body.relatedDocuments || []; const customerRfc = req.body.customer?.taxId?.toUpperCase()?.trim(); if (relatedDocs.length > 0 && customerRfc && req.tenantPool) { const allUuids = relatedDocs .flatMap(r => r.uuids || []) .filter(u => typeof u === 'string' && u.trim() !== ''); for (const uuid of allUuids) { const { rows } = await req.tenantPool.query( `SELECT rfc_receptor, status FROM cfdis WHERE LOWER(uuid) = LOWER($1) LIMIT 1`, [uuid.trim()], ); if (rows.length === 0) { throw new AppError(400, `El CFDI relacionado con UUID ${uuid} no existe en el sistema.`); } const rel = rows[0]; if (rel.status === 'Cancelado' || rel.status === '0') { throw new AppError(400, `El CFDI relacionado con UUID ${uuid} está cancelado.`); } const rfcReceptorRel = (rel.rfc_receptor || '').toUpperCase().trim(); if (rfcReceptorRel !== customerRfc) { throw new AppError( 400, `El CFDI relacionado con UUID ${uuid} no corresponde al RFC del receptor de esta factura. ` + `RFC esperado: ${customerRfc}. RFC del receptor del CFDI relacionado: ${rfcReceptorRel}.`, ); } } } // ── Validar fecha de emisión (solo I, E, T) ── const tipo = req.body.type || 'I'; if (tipo !== 'P' && req.body.fechaEmision) { const fecha = new Date(req.body.fechaEmision); const now = new Date(); const minDate = new Date(now.getTime() - 72 * 60 * 60 * 1000); if (isNaN(fecha.getTime()) || fecha > now || fecha < minDate) { throw new AppError(400, 'La fecha de emisión debe estar entre 72 horas en el pasado y el momento actual'); } } // Reservar timbre — si falla emisión en Facturapi, revertimos abajo const consumedTimbre = await facturapiService.consumeTimbre(tenantId); // Emitir factura en Facturapi // Si hay contribuyenteId, usar la org Facturapi del contribuyente (tenant BD). // Si no, usar la org del tenant (BD central). let invoice; try { if (contribuyenteId) { invoice = await createInvoiceContribuyente(req.tenantPool!, contribuyenteId, req.body); } else { invoice = await facturapiService.createInvoice(tenantId, req.body); } } catch (err: any) { // SAT nunca selló → revertir el timbre reservado (fire-and-forget; no bloquear la respuesta // de error si el refund falla, solo loggear la inconsistencia) facturapiService.refundTimbre(tenantId, consumedTimbre).catch(refundErr => { console.error('[facturacion.emitir] Falló refund de timbre tras rechazo Facturapi:', { tenantId, consumedTimbre, refundError: refundErr?.message || String(refundErr), }); }); // Loggea el payload que causó el rechazo para diagnóstico server-side console.error('[facturacion.emitir] Rechazo al crear factura:', { tenantId, contribuyenteId: contribuyenteId || null, type: req.body?.type, items: req.body?.items?.map((it: any) => ({ description: it.description, taxes: it.taxes, })), error: err?.message || String(err), }); // Detectar rechazo por CSD aún no propagado a la LCO y marcar la org // para que el frontend muestre banner informativo durante 24h. if (isLcoRejection(err?.message || '')) { await markLcoRejection(req.tenantPool!, contribuyenteId); } // Propaga el mensaje real (Facturapi suele explicar la validación) throw new AppError(400, err?.message || 'Error al emitir factura'); } // Guardar en tabla cfdis del tenant. // El response de `invoices.create` de Facturapi NO incluye `issuer`/`subtotal`/`taxes` // como campos top-level (usa `issuer_info` y los impuestos viven dentro de `items[*].product.taxes`). // La forma más fiable y consistente con el sync SAT es descargar el XML timbrado y // reutilizar el mismo parser que ya procesa los CFDIs descargados del SAT. const pool = req.tenantPool!; const xmlBuffer = contribuyenteId ? await downloadXmlContribuyente(pool, contribuyenteId, invoice.id) : await facturapiService.downloadXml(tenantId, invoice.id); const xmlString = xmlBuffer.toString('utf-8'); const parsed = parseXml(xmlString, 'emitidos'); if (!parsed) { throw new AppError(500, `Factura ${invoice.uuid} emitida en Facturapi pero el XML no pudo parsearse`); } const fecha = parsed.fechaEmision; const year = String(fecha.getFullYear()); const month = String(fecha.getMonth() + 1).padStart(2, '0'); // Upsert RFCs desde datos del XML (fuente autoritativa — igual al sync SAT) const { rows: [emisorRow] } = await pool.query( `INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) VALUES ($1, $2, $3) ON CONFLICT (rfc) DO UPDATE SET razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social), regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END RETURNING id`, [parsed.rfcEmisor, parsed.nombreEmisor || null, parsed.regimenFiscalEmisor || null], ); const { rows: [receptorRow] } = await pool.query( `INSERT INTO rfcs (rfc, razon_social, regimen_fiscal, codigo_postal) VALUES ($1, $2, $3, $4) ON CONFLICT (rfc) DO UPDATE SET razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social), regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END, codigo_postal = CASE WHEN $4 IS NOT NULL AND $4 != '' THEN $4 ELSE rfcs.codigo_postal END RETURNING id`, [parsed.rfcReceptor, parsed.nombreReceptor || null, parsed.regimenFiscalReceptor || null, req.body.customer?.zip || null], ); // Para CFDIs tipo P (complemento de pago) parseamos `fechaPagoP`. SAT // permite múltiples pagos por complemento — el parser concatena las fechas // con '|'; aquí tomamos la primera (suficiente para el cálculo fiscal, // donde fecha_pago_p drives el período de devengo). const fechaPagoP = parsed.fechaPagoP ? new Date(String(parsed.fechaPagoP).split('|')[0]) : null; await pool.query(` INSERT INTO cfdis ( year, month, type, uuid, serie, folio, status, fecha_emision, fecha_cert_sat, rfc_emisor_id, rfc_emisor, nombre_emisor, regimen_fiscal_emisor, rfc_receptor_id, rfc_receptor, nombre_receptor, regimen_fiscal_receptor, subtotal, subtotal_mxn, total, total_mxn, moneda, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi, iva_traslado, iva_traslado_mxn, iva_retencion, iva_retencion_mxn, monto_pago, monto_pago_mxn, fecha_pago_p, iva_traslado_pago, iva_traslado_pago_mxn, iva_retencion_pago, iva_retencion_pago_mxn, ieps_traslado_pago, ieps_traslado_pago_mxn, source, facturapi_id, contribuyente_id, xml_original ) VALUES ( $1, $2, 'EMITIDO', $3, $4, $5, 'Vigente', $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $16, $17, $17, $18, $19, $20, $21, $22, $23, $23, $24, $24, $25, $25, $26, $27, $27, $28, $28, $29, $29, 'facturapi', $30, $31, $32 ) `, [ year, month, parsed.uuid, parsed.serie, parsed.folio, fecha, parsed.fechaCertSat, emisorRow.id, parsed.rfcEmisor, parsed.nombreEmisor, parsed.regimenFiscalEmisor, receptorRow.id, parsed.rfcReceptor, parsed.nombreReceptor, parsed.regimenFiscalReceptor, parsed.subtotal, parsed.total, parsed.moneda, parsed.tipoComprobante, parsed.metodoPago, parsed.formaPago, parsed.usoCfdi, parsed.ivaTraslado, parsed.ivaRetencion, parsed.montoPago, fechaPagoP, parsed.ivaTrasladoPago, parsed.ivaRetencionPago, parsed.iepsTrasladoPago, invoice.id, contribuyenteId ?? null, xmlString, ]); // Inicializar saldo pendiente para I/PPD (igual que el flujo SAT) if (parsed.tipoComprobante === 'I' && parsed.metodoPago === 'PPD' && parsed.uuid) { await recomputarSaldoPendiente(pool, [parsed.uuid]); } // Enviar por email si el receptor tiene email — ruteado a la org correcta const customerEmail = req.body.customer?.email; if (customerEmail) { const sendPromise = contribuyenteId ? sendInvoiceByEmailContribuyente(req.tenantPool!, contribuyenteId, invoice.id, customerEmail) : facturapiService.sendInvoiceByEmail(tenantId, invoice.id, customerEmail); sendPromise.catch(err => console.error('[Facturapi] Error enviando email:', err.message)); } res.status(201).json({ id: invoice.id, uuid: invoice.uuid, total: invoice.total, status: invoice.status, }); } catch (error: any) { // Los errores de emisión ya hacen refund dentro del inner catch. // Aquí solo propagamos — incluye errores del INSERT post-emisión (CFDI ya sellado, // no refund) y errores de validación de timbre (ocurrieron antes del consume). next(error); } } // Estado LCO: si hubo un rechazo del SAT por CSD no propagado en las últimas 24h, // el frontend muestra un banner informativo en la pantalla de emisión. export async function getLcoStatus(req: Request, res: Response, next: NextFunction) { try { const contribuyenteId = req.query.contribuyenteId as string | undefined; if (!contribuyenteId) { return res.json({ hasRecentLcoRejection: false, rejectedAt: null }); } const { rows } = await req.tenantPool!.query<{ last_lco_rejection_at: Date | null }>( `SELECT last_lco_rejection_at FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true`, [contribuyenteId], ); const rejectedAt = rows[0]?.last_lco_rejection_at || null; const hasRecentLcoRejection = rejectedAt !== null && Date.now() - new Date(rejectedAt).getTime() < 24 * 60 * 60 * 1000; res.json({ hasRecentLcoRejection, rejectedAt }); } catch (error) { next(error); } } // ── Cancelación ── export async function cancelar(req: Request, res: Response, next: NextFunction) { try { const tenantId = effectiveTenantId(req); const { uuid } = req.params; const { motive, substitution, contribuyenteId: bodyContribuyenteId } = req.body; const pool = req.tenantPool!; const { rows } = await pool.query( `SELECT facturapi_id, contribuyente_id FROM cfdis WHERE uuid = $1 AND source = 'facturapi'`, [uuid] ); if (rows.length === 0 || !rows[0].facturapi_id) { return res.status(404).json({ message: 'CFDI no encontrado o no fue emitido por Facturapi' }); } const facturapiId = rows[0].facturapi_id; const cfdiContribuyenteId = rows[0].contribuyente_id as string | null; // En modelo multi-contribuyente: si el caller envía un contribuyenteId, // solo puede cancelar facturas de ESE contribuyente. if (bodyContribuyenteId && cfdiContribuyenteId && bodyContribuyenteId !== cfdiContribuyenteId) { return res.status(403).json({ message: 'No tienes permiso para cancelar esta factura' }); } const result = cfdiContribuyenteId ? await cancelInvoiceContribuyente(pool, cfdiContribuyenteId, facturapiId, motive || '02', substitution) : await facturapiService.cancelInvoice(tenantId, facturapiId, motive || '02', substitution); // Capturamos la fecha del CFDI antes del UPDATE para saber qué mes marcar // como invalidado (la cancelación afecta las métricas del mes del CFDI, // no del mes actual). const { rows: fechas } = await pool.query<{ fecha_emision: Date; fecha_pago_p: Date | null; tipo_comprobante: string }>( `SELECT fecha_emision, fecha_pago_p, tipo_comprobante FROM cfdis WHERE uuid = $1`, [uuid], ); await pool.query( `UPDATE cfdis SET status = 'Cancelado', fecha_cancelacion = NOW(), actualizado_en = NOW() WHERE uuid = $1`, [uuid] ); // Invalidar métricas del mes afectado (usa fecha_pago_p para P, fecha_emision para el resto) if (cfdiContribuyenteId && fechas[0]) { const f = fechas[0]; const fechaContable = f.tipo_comprobante === 'P' && f.fecha_pago_p ? f.fecha_pago_p : f.fecha_emision; const { markForInvalidation } = await import('../services/metricas.service.js'); await markForInvalidation( pool, cfdiContribuyenteId, fechaContable.getFullYear(), fechaContable.getMonth() + 1, 'CFDI_CANCEL', ).catch(err => console.warn('[Cancelar] markForInvalidation falló:', err?.message || err)); } res.json({ message: 'CFDI cancelado', result }); } catch (error) { next(error); } } // ── Descargas ── async function resolveCfdiContribuyenteId( pool: Pool, facturapiId: string, ): Promise { const { rows } = await pool.query<{ contribuyente_id: string | null }>( `SELECT contribuyente_id FROM cfdis WHERE facturapi_id = $1 LIMIT 1`, [facturapiId], ); return rows[0]?.contribuyente_id ?? null; } export async function downloadPdf(req: Request, res: Response, next: NextFunction) { try { const id = String(req.params.id); const pool = req.tenantPool!; const cfdiContribuyenteId = await resolveCfdiContribuyenteId(pool, id); const buffer = cfdiContribuyenteId ? await downloadPdfContribuyente(pool, cfdiContribuyenteId, id) : await facturapiService.downloadPdf(effectiveTenantId(req), id); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename=factura-${id}.pdf`); res.send(buffer); } catch (error) { next(error); } } export async function downloadXml(req: Request, res: Response, next: NextFunction) { try { const id = String(req.params.id); const pool = req.tenantPool!; const cfdiContribuyenteId = await resolveCfdiContribuyenteId(pool, id); const buffer = cfdiContribuyenteId ? await downloadXmlContribuyente(pool, cfdiContribuyenteId, id) : await facturapiService.downloadXml(effectiveTenantId(req), id); res.setHeader('Content-Type', 'application/xml'); res.setHeader('Content-Disposition', `attachment; filename=factura-${id}.xml`); res.send(buffer); } catch (error) { next(error); } } // ── Timbres ── export async function getTimbres(req: Request, res: Response, next: NextFunction) { try { const status = await facturapiService.getTimbreStatus(effectiveTenantId(req)); res.json(status); } catch (error) { next(error); } } // ── Personalización (logo, color) ── export async function getCustomization(req: Request, res: Response, next: NextFunction) { try { const data = await facturapiService.getCustomization(effectiveTenantId(req)); res.json(data || {}); } catch (error) { next(error); } } export async function uploadLogo(req: Request, res: Response, next: NextFunction) { try { const { logo } = req.body; // base64 if (!logo) return res.status(400).json({ message: 'Logo es requerido (base64)' }); const result = await facturapiService.uploadLogo(effectiveTenantId(req), logo); if (!result.success) return res.status(400).json({ message: result.message }); res.json(result); } catch (error) { next(error); } } export async function updateColor(req: Request, res: Response, next: NextFunction) { try { const { color } = req.body; if (!color) return res.status(400).json({ message: 'Color es requerido' }); const result = await facturapiService.updateColor(effectiveTenantId(req), color); if (!result.success) return res.status(400).json({ message: result.message }); res.json(result); } catch (error) { next(error); } } // ── Personalización per-contribuyente ── export async function getCustomizationContribuyenteCtrl(req: Request, res: Response, next: NextFunction) { try { const contribuyenteId = String(req.params.id); const data = await getCustomizationContribuyente(req.tenantPool!, contribuyenteId); res.json(data || {}); } catch (error) { next(error); } } export async function uploadLogoContribuyenteCtrl(req: Request, res: Response, next: NextFunction) { try { const contribuyenteId = String(req.params.id); const { logo } = req.body; if (!logo) return res.status(400).json({ message: 'Logo es requerido (base64)' }); const result = await uploadLogoContribuyente(req.tenantPool!, contribuyenteId, logo); if (!result.success) return res.status(400).json({ message: result.message }); res.json(result); } catch (error) { next(error); } } export async function updateColorContribuyenteCtrl(req: Request, res: Response, next: NextFunction) { try { const contribuyenteId = String(req.params.id); const { color } = req.body; if (!color) return res.status(400).json({ message: 'Color es requerido' }); const result = await updateColorContribuyente(req.tenantPool!, contribuyenteId, color); if (!result.success) return res.status(400).json({ message: result.message }); res.json(result); } catch (error) { next(error); } } // ── Datos fiscales del tenant ── // Schema Zod para preferencias de auto-facturación const PreferenciasFacturacionSchema = z.object({ factPreferencia: z.enum(['publico_general', 'mis_datos']).optional(), factUsoCfdi: z.string().min(2).max(5).optional(), factRegimenPreferido: z.string().max(3).nullable().optional(), }); export async function getPreferenciasFacturacion(req: Request, res: Response, next: NextFunction) { try { const data = await tenantsService.getPreferenciasFacturacion(effectiveTenantId(req)); res.json(data); } catch (error) { next(error); } } export async function updatePreferenciasFacturacion(req: Request, res: Response, next: NextFunction) { try { const parsed = PreferenciasFacturacionSchema.parse(req.body); const data = await tenantsService.updatePreferenciasFacturacion(effectiveTenantId(req), parsed); res.json(data); } catch (error: any) { if (error?.name === 'ZodError') { return next(new AppError(400, error.errors[0].message)); } next(error); } } export async function getDatosFiscales(req: Request, res: Response, next: NextFunction) { try { const data = await tenantsService.getDatosFiscales(effectiveTenantId(req)); res.json(data || {}); } catch (error) { next(error); } } export async function updateDatosFiscales(req: Request, res: Response, next: NextFunction) { try { if (req.user!.role !== 'owner') { return res.status(403).json({ message: 'Solo el dueño puede actualizar datos fiscales' }); } const data = await tenantsService.updateDatosFiscales(effectiveTenantId(req), req.body); res.json(data); } catch (error) { next(error); } } // ── Búsqueda de conceptos previos ── export async function searchConceptos(req: Request, res: Response, next: NextFunction) { try { const q = (req.query.q as string || '').trim(); const tipo = (req.query.tipo as string || 'todos'); // emitidos, recibidos, todos const contribuyenteId = (req.query.contribuyenteId as string || '').replace(/[^a-f0-9-]/gi, ''); const pool = req.tenantPool!; let whereType = ''; if (tipo === 'emitidos') { whereType = `AND c.type = 'EMITIDO'`; } else if (tipo === 'recibidos') { whereType = `AND c.type = 'RECIBIDO' AND c.uso_cfdi = 'G01'`; } else { whereType = `AND (c.type = 'EMITIDO' OR (c.type = 'RECIBIDO' AND c.uso_cfdi = 'G01'))`; } let whereSearch = ''; const params: any[] = []; if (q.length >= 2) { params.push(`%${q}%`); whereSearch = `AND (cc.descripcion ILIKE $${params.length} OR cc.clave_prod_serv ILIKE $${params.length})`; } let whereContribuyente = ''; if (contribuyenteId) { params.push(contribuyenteId); whereContribuyente = `AND c.contribuyente_id = $${params.length}`; } const { rows } = await pool.query(` SELECT DISTINCT ON (cc.clave_prod_serv, cc.descripcion) cc.clave_prod_serv as "claveProdServ", cc.descripcion, cc.clave_unidad as "claveUnidad", cc.unidad, cc.valor_unitario_mxn as "valorUnitario", cc.importe_mxn as "importe", cc.iva_traslado_mxn as "ivaTraslado", cc.isr_retencion_mxn as "isrRetencion", cc.iva_retencion_mxn as "ivaRetencion", c.type as "tipoCfdi", c.rfc_emisor as "rfcEmisor", c.nombre_emisor as "nombreEmisor", c.rfc_receptor as "rfcReceptor", c.nombre_receptor as "nombreReceptor", c.fecha_emision as "fechaEmision" FROM cfdi_conceptos cc JOIN cfdis c ON cc.cfdi_id = c.id WHERE c.status NOT IN ('Cancelado', '0') ${whereType} ${whereSearch} ${whereContribuyente} ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC LIMIT 30 `, params); res.json(rows); } catch (error) { next(error); } } // ── CFDIs PPD pendientes ── export async function getCfdisPpdPendientes(req: Request, res: Response, next: NextFunction) { try { const rfc = (req.query.rfc as string || '').trim().toUpperCase(); if (rfc.length < 3) return res.json([]); const contribuyenteId = (req.query.contribuyenteId as string || '').trim(); const pool = req.tenantPool!; // Buscar CFDIs emitidos PPD vigentes para este RFC receptor con saldo > 0. // Usamos `saldo_pendiente_mxn` denormalizado (utils/saldo.ts §13) que ya // considera pagos P + NCs no-07 + anticipos aplicados. Es la fuente de // verdad del sistema — recalcular con subquery solo sobre pagos P // sobreestima el saldo cuando hay NCs/anticipos. // En multi-RFC con contribuyente activo, filtra por contribuyente_id — // solo los PPDs emitidos por el contribuyente activo. Sin contribuyenteId, // retorna todos los del tenant (compat con flujos sin contribuyente activo). const params: any[] = [rfc]; let contribFilter = ''; if (contribuyenteId) { params.push(contribuyenteId); contribFilter = ` AND c.contribuyente_id = $${params.length}`; } const { rows } = await pool.query(` SELECT c.uuid, c.serie, c.folio, c.total_mxn as "totalMxn", c.fecha_emision as "fechaEmision", c.rfc_receptor as "rfcReceptor", c.nombre_receptor as "nombreReceptor", c.iva_traslado_mxn as "ivaTrasladoMxn", c.saldo_pendiente_mxn as "saldoPendiente" FROM cfdis c WHERE c.type = 'EMITIDO' AND c.metodo_pago = 'PPD' AND c.tipo_comprobante = 'I' AND c.status NOT IN ('Cancelado', '0') AND c.rfc_receptor = $1${contribFilter} AND COALESCE(c.saldo_pendiente_mxn, 0) > 0 ORDER BY c.fecha_emision DESC LIMIT 20 `, params); res.json(rows); } catch (error) { next(error); } } // ── CFDIs relacionables ── // Devuelve CFDIs emitidos por el contribuyente activo cuyo rfc_receptor // coincide con el de la nueva factura. Usado por el dropdown de la sección // "CFDIs Relacionados" en facturación tipo I y E. // // Filtros aplicados: // - contribuyente_id = caller (multi-RFC: solo CFDIs del contribuyente activo) // - rfc_receptor = rfc del receptor de la factura nueva // - tipo_comprobante IN ('I','E') — los relacionables habituales // - status NOT IN ('Cancelado','0') — solo vigentes (SAT rechaza relacionar cancelados) export async function getCfdisRelacionables(req: Request, res: Response, next: NextFunction) { try { const rfcReceptor = (req.query.rfcReceptor as string || '').trim().toUpperCase(); const contribuyenteId = (req.query.contribuyenteId as string || '').trim(); if (rfcReceptor.length < 12) return res.json([]); if (!contribuyenteId) return res.json([]); const pool = req.tenantPool!; const { rows } = await pool.query(` SELECT uuid, serie, folio, total_mxn AS "totalMxn", fecha_emision AS "fechaEmision", tipo_comprobante AS "tipoComprobante", metodo_pago AS "metodoPago" FROM cfdis WHERE contribuyente_id = $1 AND rfc_receptor = $2 AND tipo_comprobante IN ('I', 'E') AND status NOT IN ('Cancelado', '0') ORDER BY fecha_emision DESC LIMIT 50 `, [contribuyenteId, rfcReceptor]); res.json(rows); } catch (error) { next(error); } } // ── Búsqueda de RFCs ── export async function searchRfcs(req: Request, res: Response, next: NextFunction) { try { const q = (req.query.q as string || '').trim(); if (q.length < 3) return res.json([]); const contribuyenteId = (req.query.contribuyenteId as string || '').replace(/[^a-f0-9-]/gi, ''); const pool = req.tenantPool!; // RFC del tenant despacho para excluirlo (no se factura a sí mismo) const tenantId = effectiveTenantId(req); const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { rfc: true }, }); const tenantRfc = tenant?.rfc || ''; const params: any[] = [tenantRfc, `%${q}%`]; let whereContribuyente = ''; if (contribuyenteId) { params.push(contribuyenteId); whereContribuyente = `AND id IN ( SELECT rfc_receptor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_receptor_id IS NOT NULL UNION SELECT rfc_emisor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_emisor_id IS NOT NULL )`; } const { rows } = await pool.query(` SELECT id, rfc, razon_social as "razonSocial", regimen_fiscal as "regimenFiscal", codigo_postal as "codigoPostal" FROM rfcs WHERE rfc != $1 AND (rfc ILIKE $2 OR razon_social ILIKE $2) ${whereContribuyente} ORDER BY razon_social LIMIT 10 `, params); res.json(rows); } catch (error) { next(error); } } // ── Timbres adicionales: catálogo + compra ── export async function getPaquetesCatalogo(req: Request, res: Response, next: NextFunction) { try { const catalogo = await facturapiService.listPaquetesCatalogo(); res.json(catalogo); } catch (error) { next(error); } } const comprarPaqueteSchema = z.object({ catalogoId: z.number().int().positive(), }); // Admin global: catálogo completo incluyendo inactivos + edit export async function getPaquetesCatalogoAdmin(req: Request, res: Response, next: NextFunction) { try { if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) { return res.status(403).json({ message: 'Solo admin global puede ver el catálogo completo' }); } const catalogo = await facturapiService.listAllPaquetesCatalogo(); res.json(catalogo); } catch (error) { next(error); } } const updatePaqueteSchema = z.object({ precio: z.number().positive().optional(), active: z.boolean().optional(), }); export async function updatePaqueteCatalogo(req: Request, res: Response, next: NextFunction) { try { if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) { return res.status(403).json({ message: 'Solo admin global puede editar el catálogo' }); } const id = parseInt(String(req.params.id)); if (isNaN(id)) return next(new AppError(400, 'id inválido')); const data = updatePaqueteSchema.parse(req.body); const before = await facturapiService.listAllPaquetesCatalogo().then(r => r.find(p => p.id === id)); const updated = await facturapiService.updatePaqueteCatalogo({ id, ...data }); auditFromReq(req, 'timbres.catalogo_updated', { entityType: 'TimbrePaqueteCatalogo', entityId: String(id), metadata: { cantidad: updated.cantidad, from: { precio: before?.precio, active: before?.active }, to: { precio: updated.precio, active: updated.active }, }, }); res.json(updated); } catch (error: any) { if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); if (error?.message?.includes('precio') || error?.message?.includes('actualizar')) { return next(new AppError(400, error.message)); } next(error); } } export async function comprarPaquete(req: Request, res: Response, next: NextFunction) { try { if (!['owner', 'cfo'].includes(req.user!.role)) { return res.status(403).json({ message: 'Solo owner/cfo pueden comprar timbres adicionales' }); } const { catalogoId } = comprarPaqueteSchema.parse(req.body); const result = await facturapiService.iniciarCompraPaquete({ tenantId: effectiveTenantId(req), catalogoId, callerEmail: req.user!.email, }); res.json(result); } catch (error: any) { if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); // Errores de negocio esperados → 400 con mensaje para el usuario const msg = error?.message || ''; if (msg.includes('no disponible') || msg.includes('dueño') || msg.includes('email') || msg.includes('MercadoPago')) { return next(new AppError(400, msg)); } console.error('[comprarPaquete] Error no esperado:', error); next(error); } } // ── Admin global: pagos de suscripción sin factura ── export async function getPagosSinFactura(req: Request, res: Response, next: NextFunction) { try { if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) { return res.status(403).json({ message: 'Solo admin global puede consultar pagos sin factura' }); } const payments = await prisma.payment.findMany({ where: { status: 'approved', facturapiInvoiceId: null, kind: 'subscription', amount: { gt: 0 }, }, include: { subscription: { select: { plan: true, frequency: true } }, tenant: { select: { nombre: true, rfc: true } }, }, orderBy: { paidAt: 'desc' }, }); res.json(payments); } catch (error) { next(error); } } export async function emitirFacturaPago(req: Request, res: Response, next: NextFunction) { try { if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) { return res.status(403).json({ message: 'Solo admin global puede emitir facturas de pago' }); } const paymentId = String(req.params.paymentId); const payment = await prisma.payment.findUnique({ where: { id: paymentId }, include: { subscription: true }, }); if (!payment) { return next(new AppError(404, 'Pago no encontrado')); } if (payment.status !== 'approved') { return next(new AppError(400, 'Solo pagos aprobados pueden facturarse')); } if (payment.facturapiInvoiceId) { return next(new AppError(400, 'Este pago ya tiene una factura emitida')); } // Reutilizar helpers del servicio de facturación const { getEmitterTenant, getCustomerFromTenant } = await import('../services/payment/invoicing.service.js'); const emitter = await getEmitterTenant(); const amount = Number(payment.amount); const plan = (payment as any).subscription?.plan || 'custom'; const frequency = (payment as any).subscription?.frequency || 'monthly'; const descFrecuencia = frequency === 'annual' ? 'anual' : 'mensual'; const description = `Suscripción ${plan} ${descFrecuencia} a Horux Despachos`; const customer = await getCustomerFromTenant(payment.tenantId); if (!customer) { return next(new AppError(400, 'El tenant no tiene datos fiscales completos. No se puede facturar.')); } const tenantPref = await prisma.tenant.findUnique({ where: { id: payment.tenantId }, select: { factUsoCfdi: true }, }); const usoCfdi = customer ? (tenantPref?.factUsoCfdi || 'G03') : 'S01'; const formaPagoMap: Record = { master: '04', visa: '04', amex: '04', debmaster: '28', debvisa: '28', account_money: '03', bank_transfer: '03', }; const normalizedMethod = (payment.paymentMethod || '').toLowerCase().replace(/^proration-/, ''); const formaPago = formaPagoMap[normalizedMethod] || '03'; const payload = { customer: { legalName: customer.legalName, taxId: customer.taxId, taxSystem: customer.taxSystem, email: customer.email, zip: customer.zip, }, items: [ { description, productKey: '81112502', unitKey: 'E48', unitName: 'Servicio', quantity: 1, price: amount, taxIncluded: true, taxes: [{ type: 'IVA', rate: 0.16, factor: 'Tasa' }], }, ], use: usoCfdi, paymentForm: formaPago, paymentMethod: 'PUE', currency: 'MXN', }; const invoice = await facturapiService.createInvoice(emitter.id, payload as any); await prisma.payment.update({ where: { id: payment.id }, data: { facturapiInvoiceId: invoice.id }, }); auditFromReq(req, 'invoice.emitted_manual', { entityType: 'Payment', entityId: payment.id, metadata: { facturapiInvoiceId: invoice.id, amount, plan, frequency }, }); res.json({ success: true, invoiceId: invoice.id, paymentId: payment.id }); } catch (error) { next(error); } }