import AdmZip from 'adm-zip'; import { XMLParser } from 'fast-xml-parser'; import type { TipoCfdi, EstadoCfdi } from '@horux/shared'; interface CfdiParsed { uuid: string; type: TipoCfdi; tipoComprobante: string; serie: string | null; folio: string | null; status: EstadoCfdi; fechaEmision: Date; fechaCertSat: Date | null; rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string; subtotal: number; descuento: number; total: number; moneda: string; tipoCambio: number; metodoPago: string | null; formaPago: string | null; usoCfdi: string | null; pac: string | null; // Impuestos del comprobante ivaTraslado: number; isrRetencion: number; ivaRetencion: number; iepsTraslado: number; iepsRetencion: number; // Impuestos locales impuestosLocalesTrasladado: number; impuestosLocalesRetenidos: number; // Complemento de pagos montoPago: number; fechaPagoP: string | null; numParcialidad: string | null; uuidRelacionado: string | null; saldoInsoluto: string | null; isrRetencionPago: number; ivaTrasladoPago: number; ivaRetencionPago: number; iepsTrasladoPago: number; iepsRetencionPago: number; // Nómina fechaPago: string | null; fechaInicialPago: string | null; fechaFinalPago: string | null; numDiasPagados: number; numSeguroSocial: string | null; puesto: string | null; salarioBaseCotApor: number; salarioDiarioIntegrado: number; totalPercepciones: number; totalDeducciones: number; impRetenidosNomina: number; otrasDeduccionesNomina: number; subsidioCausado: number; regimenFiscalEmisor: string | null; regimenFiscalReceptor: string | null; codigoPostalReceptor: string | null; // CfdiRelacionados a nivel raíz del comprobante (CFDI 4.0). // `cfdiTipoRelacion` — clave SAT (01..07). NULL si no hay relación. // `cfdisRelacionados` — UUIDs pipe-separated. cfdiTipoRelacion: string | null; cfdisRelacionados: string | null; conceptos: ConceptoParsed[]; xmlOriginal: string; // Factura global (InformacionGlobal) periodicidad: string | null; mesesGlobal: string | null; añoGlobal: string | null; } interface ConceptoParsed { claveProdServ: string | null; noIdentificacion: string | null; descripcion: string; cantidad: number; claveUnidad: string | null; unidad: string | null; valorUnitario: number; importe: number; descuento: number; // Impuestos por concepto isrRetencion: number; ivaTraslado: number; ivaRetencion: number; iepsTraslado: number; iepsRetencion: number; } interface ExtractedXml { filename: string; content: string; } const xmlParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', removeNSPrefix: true, }); /** * Extrae archivos XML de un paquete ZIP en base64 */ export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] { const zipBuffer = Buffer.from(zipBase64, 'base64'); const zip = new AdmZip(zipBuffer); const entries = zip.getEntries(); const xmlFiles: ExtractedXml[] = []; for (const entry of entries) { if (entry.entryName.toLowerCase().endsWith('.xml')) { let content = entry.getData().toString('utf-8'); // Remover UTF-8 BOM si existe — fast-xml-parser no lo maneja y devuelve // result.Comprobante = undefined, dejando el CFDI sin parsear. if (content.charCodeAt(0) === 0xFEFF) { content = content.slice(1); } xmlFiles.push({ filename: entry.entryName, content, }); } } return xmlFiles; } /** * Parsea una fecha del XML/CSV del SAT preservando la hora **literal**. * * Problema: el CFDI 4.0 define `Fecha` y `FechaTimbrado` como ISO-8601 sin * zona horaria (hora local del contribuyente = México). Si se pasa tal cual * a `new Date(str)`, Node lo interpreta según la timezone de la máquina: * en CDMX (UTC-6), "2025-12-31T18:37:51" se convierte a UTC * "2026-01-01T00:37:51Z", cambiando la fecha efectiva y desalineando el * mes/año del CFDI. Postgres guarda ese valor UTC, y los filtros por rango * lo sacan del mes correcto. * * Solución: forzar 'Z' si el string no trae TZ indicator. Esto hace que * Node interprete el texto como UTC literal y preserve la hora tal cual. * El valor queda naive pero consistente: todo el sistema filtra con * fechas naive (sin TZ), así que el resultado es correcto. */ function parseCfdiDate(str: string | null | undefined): Date { if (!str) return new Date(0); let s = String(str).trim(); if (!s) return new Date(0); // Defensa: el SAT a veces concatena múltiples fechas con '|' (ej. en // FechaTimbrado duplicado). Tomamos solo la primera fecha válida. if (s.includes('|')) { s = s.split('|')[0].trim(); } const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(s); return new Date(hasTz ? s : s + 'Z'); } function toArray(val: any): any[] { if (!val) return []; return Array.isArray(val) ? val : [val]; } function pf(val: any): number { return parseFloat(val || '0') || 0; } /** * Extrae el UUID del TimbreFiscalDigital */ function getFirstTimbre(comprobante: any): any { const timbre = comprobante.Complemento?.TimbreFiscalDigital; if (!timbre) return null; return Array.isArray(timbre) ? timbre[0] : timbre; } /** * Extrae el UUID del TimbreFiscalDigital */ function extractUuid(comprobante: any): string { const timbre = getFirstTimbre(comprobante); return timbre?.['@_UUID'] || ''; } /** * Extrae datos del timbre: fecha cert SAT y PAC */ function extractTimbreData(comprobante: any): { fechaCertSat: Date | null; pac: string | null } { const timbre = getFirstTimbre(comprobante); if (!timbre) return { fechaCertSat: null, pac: null }; return { fechaCertSat: timbre['@_FechaTimbrado'] ? parseCfdiDate(timbre['@_FechaTimbrado']) : null, pac: timbre['@_RfcProvCertif'] || null, }; } /** * Extrae impuestos trasladados (IVA 002, IEPS 003) */ function extractTraslados(comprobante: any): { iva: number; ieps: number } { const traslados = toArray(comprobante.Impuestos?.Traslados?.Traslado); let iva = 0, ieps = 0; for (const t of traslados) { const importe = pf(t['@_Importe']); if (t['@_Impuesto'] === '002') iva += importe; else if (t['@_Impuesto'] === '003') ieps += importe; } return { iva, ieps }; } /** * Extrae impuestos retenidos (ISR 001, IVA 002, IEPS 003) */ function extractRetenciones(comprobante: any): { isr: number; iva: number; ieps: number } { const retenciones = toArray(comprobante.Impuestos?.Retenciones?.Retencion); let isr = 0, iva = 0, ieps = 0; for (const r of retenciones) { const importe = pf(r['@_Importe']); if (r['@_Impuesto'] === '001') isr += importe; else if (r['@_Impuesto'] === '002') iva += importe; else if (r['@_Impuesto'] === '003') ieps += importe; } return { isr, iva, ieps }; } /** * Extrae impuestos locales */ function extractImpuestosLocales(comprobante: any): { trasladado: number; retenido: number } { const complemento = comprobante.Complemento; if (!complemento) return { trasladado: 0, retenido: 0 }; const impLocales = complemento.ImpuestosLocales; if (!impLocales) return { trasladado: 0, retenido: 0 }; return { trasladado: pf(impLocales['@_TotaldeTraslados']), retenido: pf(impLocales['@_TotaldeRetenciones']), }; } /** * Extrae CfdiRelacionados a nivel raíz del Comprobante. Puede haber 1+ * nodos `cfdi:CfdiRelacionados` (cada uno con un `TipoRelacion`), y dentro * de cada uno 1+ `cfdi:CfdiRelacionado` con UUID. Retorna el primer * TipoRelacion encontrado (lo más común) y todos los UUIDs pipe-separated. */ function extractCfdiRelacionados(comprobante: any): { tipoRelacion: string | null; uuids: string | null; } { const nodes = toArray(comprobante.CfdiRelacionados); if (nodes.length === 0) return { tipoRelacion: null, uuids: null }; let tipoRelacion: string | null = null; const allUuids: string[] = []; for (const node of nodes) { if (!tipoRelacion && node['@_TipoRelacion']) { tipoRelacion = String(node['@_TipoRelacion']); } const rels = toArray(node.CfdiRelacionado); for (const r of rels) { if (r['@_UUID']) allUuids.push(String(r['@_UUID'])); } } return { tipoRelacion, uuids: allUuids.length > 0 ? allUuids.join('|') : null, }; } /** * Extrae datos del complemento de pagos (pago20) */ function extractPagos(comprobante: any): { montoPago: number; fechaPagoP: string | null; numParcialidad: string | null; uuidRelacionado: string | null; saldoInsoluto: string | null; isrRetencion: number; ivaTraslado: number; ivaRetencion: number; iepsTraslado: number; iepsRetencion: number; } { const result = { montoPago: 0, fechaPagoP: null as string | null, numParcialidad: null as string | null, uuidRelacionado: null as string | null, saldoInsoluto: null as string | null, isrRetencion: 0, ivaTraslado: 0, ivaRetencion: 0, iepsTraslado: 0, iepsRetencion: 0, }; const complemento = comprobante.Complemento; if (!complemento) return result; // Try pago20:Pagos or just Pagos const pagosNode = complemento.Pagos; if (!pagosNode) return result; const pagos = toArray(pagosNode.Pago); const fechas: string[] = []; const parcialidades: string[] = []; const uuids: string[] = []; const saldos: string[] = []; for (const pago of pagos) { result.montoPago += pf(pago['@_Monto']); if (pago['@_FechaPago']) fechas.push(pago['@_FechaPago']); // Impuestos del pago const retPago = toArray(pago.ImpuestosP?.RetencionesPP?.RetencionP || pago.ImpuestosP?.RetencionesPP); for (const r of retPago) { const importe = pf(r['@_ImporteP']); if (r['@_ImpuestoP'] === '001') result.isrRetencion += importe; else if (r['@_ImpuestoP'] === '002') result.ivaRetencion += importe; else if (r['@_ImpuestoP'] === '003') result.iepsRetencion += importe; } const trasPago = toArray(pago.ImpuestosP?.TrasladosP?.TrasladoP || pago.ImpuestosP?.TrasladosP); for (const t of trasPago) { const importe = pf(t['@_ImporteP']); if (t['@_ImpuestoP'] === '002') result.ivaTraslado += importe; else if (t['@_ImpuestoP'] === '003') result.iepsTraslado += importe; } // Documentos relacionados const doctos = toArray(pago.DoctoRelacionado); for (const d of doctos) { if (d['@_IdDocumento']) uuids.push(d['@_IdDocumento']); if (d['@_NumParcialidad']) parcialidades.push(d['@_NumParcialidad']); if (d['@_ImpSaldoInsoluto'] !== undefined) saldos.push(d['@_ImpSaldoInsoluto']); } } result.fechaPagoP = fechas.length > 0 ? parseCfdiDate(fechas[0]).toISOString() : null; result.numParcialidad = parcialidades.length > 0 ? parcialidades.join('|') : null; result.uuidRelacionado = uuids.length > 0 ? uuids.join('|') : null; result.saldoInsoluto = saldos.length > 0 ? saldos.join('|') : null; return result; } /** * Extrae datos del complemento de nómina (nomina12) */ function extractNomina(comprobante: any): { fechaPago: string | null; fechaInicialPago: string | null; fechaFinalPago: string | null; numDiasPagados: number; numSeguroSocial: string | null; puesto: string | null; salarioBaseCotApor: number; salarioDiarioIntegrado: number; totalPercepciones: number; totalDeducciones: number; impRetenidosNomina: number; otrasDeduccionesNomina: number; subsidioCausado: number; } { const result = { fechaPago: null as string | null, fechaInicialPago: null as string | null, fechaFinalPago: null as string | null, numDiasPagados: 0, numSeguroSocial: null as string | null, puesto: null as string | null, salarioBaseCotApor: 0, salarioDiarioIntegrado: 0, totalPercepciones: 0, totalDeducciones: 0, impRetenidosNomina: 0, otrasDeduccionesNomina: 0, subsidioCausado: 0, }; const complemento = comprobante.Complemento; if (!complemento) return result; const nomina = complemento.Nomina; if (!nomina) return result; result.fechaPago = nomina['@_FechaPago'] ? parseCfdiDate(nomina['@_FechaPago']).toISOString() : null; result.fechaInicialPago = nomina['@_FechaInicialPago'] ? parseCfdiDate(nomina['@_FechaInicialPago']).toISOString() : null; result.fechaFinalPago = nomina['@_FechaFinalPago'] ? parseCfdiDate(nomina['@_FechaFinalPago']).toISOString() : null; result.numDiasPagados = pf(nomina['@_NumDiasPagados']); result.totalPercepciones = pf(nomina['@_TotalPercepciones']); result.totalDeducciones = pf(nomina['@_TotalDeducciones']); // Receptor de nómina const receptor = nomina.Receptor; if (receptor) { result.numSeguroSocial = receptor['@_NumSeguridadSocial'] || null; result.puesto = receptor['@_Puesto'] || null; result.salarioBaseCotApor = pf(receptor['@_SalarioBaseCotApor']); result.salarioDiarioIntegrado = pf(receptor['@_SalarioDiarioIntegrado']); } // Deducciones const deducciones = nomina.Deducciones; if (deducciones) { result.impRetenidosNomina = pf(deducciones['@_TotalImpuestosRetenidos']); result.otrasDeduccionesNomina = pf(deducciones['@_TotalOtrasDeducciones']); } // Subsidio causado (OtrosPagos/OtroPago[@TipoOtroPago='002']) const otrosPagos = toArray(nomina.OtrosPagos?.OtroPago); for (const op of otrosPagos) { if (op['@_TipoOtroPago'] === '002') { result.subsidioCausado = pf(op.SubsidioAlEmpleo?.['@_SubsidioCausado']); } } return result; } /** * Extrae los conceptos del comprobante */ function extractConceptos(comprobante: any): ConceptoParsed[] { const conceptosNode = comprobante.Conceptos?.Concepto; if (!conceptosNode) return []; const conceptos = toArray(conceptosNode); return conceptos.map((c: any) => { // Impuestos por concepto const trasladosC = toArray(c.Impuestos?.Traslados?.Traslado); const retencionesC = toArray(c.Impuestos?.Retenciones?.Retencion); let ivaTraslado = 0, iepsTraslado = 0; for (const t of trasladosC) { const importe = pf(t['@_Importe']); if (t['@_Impuesto'] === '002') ivaTraslado += importe; else if (t['@_Impuesto'] === '003') iepsTraslado += importe; } let isrRetencion = 0, ivaRetencion = 0, iepsRetencion = 0; for (const r of retencionesC) { const importe = pf(r['@_Importe']); if (r['@_Impuesto'] === '001') isrRetencion += importe; else if (r['@_Impuesto'] === '002') ivaRetencion += importe; else if (r['@_Impuesto'] === '003') iepsRetencion += importe; } return { claveProdServ: c['@_ClaveProdServ'] || null, noIdentificacion: c['@_NoIdentificacion'] || null, descripcion: c['@_Descripcion'] || '', cantidad: pf(c['@_Cantidad']) || 1, claveUnidad: c['@_ClaveUnidad'] || null, unidad: c['@_Unidad'] || null, valorUnitario: pf(c['@_ValorUnitario']), importe: pf(c['@_Importe']), descuento: pf(c['@_Descuento']), isrRetencion, ivaTraslado, ivaRetencion, iepsTraslado, iepsRetencion, }; }); } /** * Parsea un XML de CFDI y extrae los datos relevantes * @param downloadType - 'emitidos' o 'recibidos' para determinar el type (EMITIDO/RECIBIDO) */ export function parseXml(xmlContent: string, downloadType: 'emitidos' | 'recibidos' = 'emitidos'): CfdiParsed | null { try { const result = xmlParser.parse(xmlContent); const comprobante = result.Comprobante; if (!comprobante) { console.error('[Parser] No se encontró el nodo Comprobante'); return null; } const emisor = comprobante.Emisor || {}; const receptor = comprobante.Receptor || {}; const retenciones = extractRetenciones(comprobante); const traslados = extractTraslados(comprobante); const timbreData = extractTimbreData(comprobante); const impLocales = extractImpuestosLocales(comprobante); const tipoComprobante = comprobante['@_TipoDeComprobante'] || 'I'; // Complemento de pagos (solo tipo P) const pagosData = tipoComprobante === 'P' ? extractPagos(comprobante) : { montoPago: 0, fechaPagoP: null, numParcialidad: null, uuidRelacionado: null, saldoInsoluto: null, isrRetencion: 0, ivaTraslado: 0, ivaRetencion: 0, iepsTraslado: 0, iepsRetencion: 0, }; // CfdiRelacionados a nivel raíz. CFDI 4.0 permite 1+ nodos // `cfdi:CfdiRelacionados` cada uno con un TipoRelacion y múltiples UUIDs. // Aquí capturamos el PRIMER TipoRelacion (lo más común es que haya uno // solo, especialmente en NC tipo E). Los UUIDs de todos los bloques se // concatenan con `|`. const relacionesData = extractCfdiRelacionados(comprobante); // Complemento de nómina (solo tipo N) const nominaData = tipoComprobante === 'N' ? extractNomina(comprobante) : { fechaPago: null, fechaInicialPago: null, fechaFinalPago: null, numDiasPagados: 0, numSeguroSocial: null, puesto: null, salarioBaseCotApor: 0, salarioDiarioIntegrado: 0, totalPercepciones: 0, totalDeducciones: 0, impRetenidosNomina: 0, otrasDeduccionesNomina: 0, subsidioCausado: 0, }; const cfdi: CfdiParsed = { uuid: extractUuid(comprobante), type: downloadType === 'emitidos' ? 'EMITIDO' : 'RECIBIDO', tipoComprobante, serie: comprobante['@_Serie'] || null, folio: comprobante['@_Folio'] || null, status: 'Vigente', fechaEmision: parseCfdiDate(comprobante['@_Fecha']), fechaCertSat: timbreData.fechaCertSat, rfcEmisor: emisor['@_Rfc'] || '', nombreEmisor: emisor['@_Nombre'] || '', rfcReceptor: receptor['@_Rfc'] || '', nombreReceptor: receptor['@_Nombre'] || '', subtotal: pf(comprobante['@_SubTotal']), descuento: pf(comprobante['@_Descuento']), total: pf(comprobante['@_Total']), moneda: comprobante['@_Moneda'] || 'MXN', tipoCambio: pf(comprobante['@_TipoCambio']) || 1, metodoPago: comprobante['@_MetodoPago'] || null, formaPago: comprobante['@_FormaPago'] || null, usoCfdi: receptor['@_UsoCFDI'] || null, pac: timbreData.pac, regimenFiscalEmisor: emisor['@_RegimenFiscal'] || null, regimenFiscalReceptor: receptor['@_RegimenFiscalReceptor'] || receptor['@_RegimenFiscal'] || null, codigoPostalReceptor: receptor['@_DomicilioFiscalReceptor'] || null, cfdiTipoRelacion: relacionesData.tipoRelacion, cfdisRelacionados: relacionesData.uuids, // Impuestos comprobante ivaTraslado: traslados.iva, isrRetencion: retenciones.isr, ivaRetencion: retenciones.iva, iepsTraslado: traslados.ieps, iepsRetencion: retenciones.ieps, // Impuestos locales impuestosLocalesTrasladado: impLocales.trasladado, impuestosLocalesRetenidos: impLocales.retenido, // Complemento de pagos montoPago: pagosData.montoPago, fechaPagoP: pagosData.fechaPagoP, numParcialidad: pagosData.numParcialidad, uuidRelacionado: pagosData.uuidRelacionado, saldoInsoluto: pagosData.saldoInsoluto, isrRetencionPago: pagosData.isrRetencion, ivaTrasladoPago: pagosData.ivaTraslado, ivaRetencionPago: pagosData.ivaRetencion, iepsTrasladoPago: pagosData.iepsTraslado, iepsRetencionPago: pagosData.iepsRetencion, // Nómina ...nominaData, conceptos: extractConceptos(comprobante), xmlOriginal: xmlContent, periodicidad: comprobante.InformacionGlobal?.['@_Periodicidad'] || null, mesesGlobal: comprobante.InformacionGlobal?.['@_Meses'] || null, añoGlobal: comprobante.InformacionGlobal?.['@_Año'] || null, }; if (!cfdi.uuid) { console.error('[Parser] CFDI sin UUID'); return null; } return cfdi; } catch (error) { console.error('[Parser Error]', error); return null; } } /** * Procesa un paquete ZIP completo y retorna los CFDIs parseados * @param downloadType - 'emitidos' o 'recibidos' */ export function processPackage(zipBase64: string, downloadType: 'emitidos' | 'recibidos' = 'emitidos'): CfdiParsed[] { const xmlFiles = extractXmlsFromZip(zipBase64); const cfdis: CfdiParsed[] = []; for (const { content } of xmlFiles) { const cfdi = parseXml(content, downloadType); if (cfdi) { cfdis.push(cfdi); } } return cfdis; } /** * Datos parseados de un registro de metadata del SAT */ interface CfdiMetadata { uuid: string; rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string; rfcPac: string | null; fechaEmision: Date; fechaCertSat: Date | null; fechaCancelacion: Date | null; monto: number; tipoComprobante: string; status: string; // 'Vigente' | 'Cancelado' type: 'EMITIDO' | 'RECIBIDO'; } /** * Extrae archivos CSV de un paquete ZIP de metadata en base64. * Usa AdmZip directamente para evitar problemas de archivos temporales en Windows. */ function extractCsvsFromZip(zipBase64: string): string[] { const zipBuffer = Buffer.from(zipBase64, 'base64'); const zip = new AdmZip(zipBuffer); const entries = zip.getEntries(); const csvContents: string[] = []; for (const entry of entries) { const name = entry.entryName.toLowerCase(); if (name.endsWith('.csv') || name.endsWith('.txt')) { csvContents.push(entry.getData().toString('utf-8')); } } return csvContents; } /** * Parsea una línea CSV respetando campos entrecomillados */ function parseCsvLine(line: string): string[] { const fields: string[] = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (ch === '"') { inQuotes = !inQuotes; } else if (ch === '~' && !inQuotes) { fields.push(current.trim()); current = ''; } else { current += ch; } } fields.push(current.trim()); return fields; } /** * Procesa un paquete de metadata del SAT (ZIP con CSV) y retorna los registros. * Usa AdmZip directo en vez de MetadataPackageReader para compatibilidad Windows. */ export function processMetadataPackage( zipBase64: string, downloadType: 'emitidos' | 'recibidos' = 'emitidos' ): CfdiMetadata[] { const csvContents = extractCsvsFromZip(zipBase64); const results: CfdiMetadata[] = []; const tipoMap: Record = { 'Ingreso': 'I', 'Egreso': 'E', 'Traslado': 'T', 'Nómina': 'N', 'Nomina': 'N', 'Pago': 'P', }; for (const csv of csvContents) { const lines = csv.split(/\r?\n/).filter(l => l.trim()); if (lines.length < 2) continue; // Header line — SAT uses ~ as delimiter const headers = parseCsvLine(lines[0]); // Find column indices (case-insensitive) const idx = (name: string) => headers.findIndex(h => h.toLowerCase() === name.toLowerCase()); const iUuid = idx('Uuid'); const iRfcEmisor = idx('RfcEmisor'); const iNombreEmisor = idx('NombreEmisor'); const iRfcReceptor = idx('RfcReceptor'); const iNombreReceptor = idx('NombreReceptor'); const iRfcPac = idx('RfcPac'); const iFechaEmision = idx('FechaEmision'); const iFechaCert = idx('FechaCertificacionSat'); const iFechaCancel = idx('FechaCancelacion'); const iMonto = idx('Monto'); const iEfecto = idx('EfectoComprobante'); const iEstatus = idx('Estatus'); // Fallback column names const iEstado = iEstatus >= 0 ? iEstatus : idx('Estado'); if (iUuid < 0) continue; // No UUID column = invalid CSV for (let i = 1; i < lines.length; i++) { const fields = parseCsvLine(lines[i]); const uuid = (fields[iUuid] || '').trim(); if (!uuid) continue; const estatus = (fields[iEstado] || 'Vigente').trim(); const fechaCancelStr = iFechaCancel >= 0 ? (fields[iFechaCancel] || '').trim() : ''; const fechaEmisionStr = iFechaEmision >= 0 ? (fields[iFechaEmision] || '').trim() : ''; const fechaCertStr = iFechaCert >= 0 ? (fields[iFechaCert] || '').trim() : ''; const efecto = iEfecto >= 0 ? (fields[iEfecto] || 'Ingreso').trim() : 'Ingreso'; results.push({ uuid: uuid.toUpperCase(), rfcEmisor: iRfcEmisor >= 0 ? (fields[iRfcEmisor] || '').trim() : '', nombreEmisor: iNombreEmisor >= 0 ? (fields[iNombreEmisor] || '').trim() : '', rfcReceptor: iRfcReceptor >= 0 ? (fields[iRfcReceptor] || '').trim() : '', nombreReceptor: iNombreReceptor >= 0 ? (fields[iNombreReceptor] || '').trim() : '', rfcPac: iRfcPac >= 0 ? (fields[iRfcPac] || '').trim() || null : null, fechaEmision: fechaEmisionStr ? parseCfdiDate(fechaEmisionStr) : new Date(), fechaCertSat: fechaCertStr ? parseCfdiDate(fechaCertStr) : null, fechaCancelacion: fechaCancelStr ? parseCfdiDate(fechaCancelStr) : null, monto: parseFloat(iMonto >= 0 ? fields[iMonto] || '0' : '0') || 0, tipoComprobante: tipoMap[efecto] || efecto.charAt(0) || 'I', status: estatus === '0' || estatus.toLowerCase().includes('cancel') ? 'Cancelado' : 'Vigente', type: downloadType === 'emitidos' ? 'EMITIDO' : 'RECIBIDO', }); } } return results; } /** * Valida que un XML sea un CFDI válido */ export function isValidCfdi(xmlContent: string): boolean { try { const result = xmlParser.parse(xmlContent); const comprobante = result.Comprobante; if (!comprobante) return false; if (!comprobante.Complemento?.TimbreFiscalDigital) return false; if (!extractUuid(comprobante)) return false; return true; } catch { return false; } } export type { CfdiParsed, CfdiMetadata, ConceptoParsed, ExtractedXml };