From 09684f77b96300448e42729385f541ea0c164762 Mon Sep 17 00:00:00 2001 From: Consultoria AS Date: Sun, 25 Jan 2026 00:50:11 +0000 Subject: [PATCH] feat(sat): add CFDI XML parser service (Phase 4) - Add sat-parser.service.ts for processing SAT packages: - Extract XML files from ZIP packages - Parse CFDI 4.0 XML structure with proper namespace handling - Extract fiscal data: UUID, amounts, taxes, dates, RFC info - Map SAT types (I/E/T/P/N) to application types - Handle IVA and ISR retention calculations - Install @nodecfdi/cfdi-core dependency Co-Authored-By: Claude Opus 4.5 --- apps/api/package.json | 1 + .../src/services/sat/sat-parser.service.ts | 244 ++++++++++++++++++ pnpm-lock.yaml | 17 ++ 3 files changed, 262 insertions(+) create mode 100644 apps/api/src/services/sat/sat-parser.service.ts diff --git a/apps/api/package.json b/apps/api/package.json index 42b3d77..15ea917 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@horux/shared": "workspace:*", + "@nodecfdi/cfdi-core": "^1.0.1", "@nodecfdi/credentials": "^3.2.0", "@prisma/client": "^5.22.0", "adm-zip": "^0.5.16", diff --git a/apps/api/src/services/sat/sat-parser.service.ts b/apps/api/src/services/sat/sat-parser.service.ts new file mode 100644 index 0000000..3bd875c --- /dev/null +++ b/apps/api/src/services/sat/sat-parser.service.ts @@ -0,0 +1,244 @@ +import AdmZip from 'adm-zip'; +import { XMLParser } from 'fast-xml-parser'; +import type { TipoCfdi, EstadoCfdi } from '@horux/shared'; + +interface CfdiParsed { + uuidFiscal: string; + tipo: TipoCfdi; + serie: string | null; + folio: string | null; + fechaEmision: Date; + fechaTimbrado: Date; + rfcEmisor: string; + nombreEmisor: string; + rfcReceptor: string; + nombreReceptor: string; + subtotal: number; + descuento: number; + iva: number; + isrRetenido: number; + ivaRetenido: number; + total: number; + moneda: string; + tipoCambio: number; + metodoPago: string | null; + formaPago: string | null; + usoCfdi: string | null; + estado: EstadoCfdi; + xmlOriginal: string; +} + +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')) { + const content = entry.getData().toString('utf-8'); + xmlFiles.push({ + filename: entry.entryName, + content, + }); + } + } + + return xmlFiles; +} + +/** + * Mapea el tipo de comprobante SAT a nuestro tipo + */ +function mapTipoCfdi(tipoComprobante: string): TipoCfdi { + const mapping: Record = { + 'I': 'ingreso', + 'E': 'egreso', + 'T': 'traslado', + 'P': 'pago', + 'N': 'nomina', + }; + return mapping[tipoComprobante] || 'ingreso'; +} + +/** + * Extrae el UUID del TimbreFiscalDigital + */ +function extractUuid(comprobante: any): string { + const complemento = comprobante.Complemento; + if (!complemento) return ''; + + const timbre = complemento.TimbreFiscalDigital; + if (!timbre) return ''; + + return timbre['@_UUID'] || ''; +} + +/** + * Extrae la fecha de timbrado + */ +function extractFechaTimbrado(comprobante: any): Date { + const complemento = comprobante.Complemento; + if (!complemento) return new Date(); + + const timbre = complemento.TimbreFiscalDigital; + if (!timbre) return new Date(); + + return new Date(timbre['@_FechaTimbrado']); +} + +/** + * Extrae los impuestos trasladados (IVA) + */ +function extractIva(comprobante: any): number { + const impuestos = comprobante.Impuestos; + if (!impuestos) return 0; + + const traslados = impuestos.Traslados?.Traslado; + if (!traslados) return 0; + + const trasladoArray = Array.isArray(traslados) ? traslados : [traslados]; + + let totalIva = 0; + for (const traslado of trasladoArray) { + if (traslado['@_Impuesto'] === '002') { // 002 = IVA + totalIva += parseFloat(traslado['@_Importe'] || '0'); + } + } + + return totalIva; +} + +/** + * Extrae los impuestos retenidos + */ +function extractRetenciones(comprobante: any): { isr: number; iva: number } { + const impuestos = comprobante.Impuestos; + if (!impuestos) return { isr: 0, iva: 0 }; + + const retenciones = impuestos.Retenciones?.Retencion; + if (!retenciones) return { isr: 0, iva: 0 }; + + const retencionArray = Array.isArray(retenciones) ? retenciones : [retenciones]; + + let isr = 0; + let iva = 0; + + for (const retencion of retencionArray) { + const importe = parseFloat(retencion['@_Importe'] || '0'); + if (retencion['@_Impuesto'] === '001') { // 001 = ISR + isr += importe; + } else if (retencion['@_Impuesto'] === '002') { // 002 = IVA + iva += importe; + } + } + + return { isr, iva }; +} + +/** + * Parsea un XML de CFDI y extrae los datos relevantes + */ +export function parseXml(xmlContent: string): 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 cfdi: CfdiParsed = { + uuidFiscal: extractUuid(comprobante), + tipo: mapTipoCfdi(comprobante['@_TipoDeComprobante']), + serie: comprobante['@_Serie'] || null, + folio: comprobante['@_Folio'] || null, + fechaEmision: new Date(comprobante['@_Fecha']), + fechaTimbrado: extractFechaTimbrado(comprobante), + rfcEmisor: emisor['@_Rfc'] || '', + nombreEmisor: emisor['@_Nombre'] || '', + rfcReceptor: receptor['@_Rfc'] || '', + nombreReceptor: receptor['@_Nombre'] || '', + subtotal: parseFloat(comprobante['@_SubTotal'] || '0'), + descuento: parseFloat(comprobante['@_Descuento'] || '0'), + iva: extractIva(comprobante), + isrRetenido: retenciones.isr, + ivaRetenido: retenciones.iva, + total: parseFloat(comprobante['@_Total'] || '0'), + moneda: comprobante['@_Moneda'] || 'MXN', + tipoCambio: parseFloat(comprobante['@_TipoCambio'] || '1'), + metodoPago: comprobante['@_MetodoPago'] || null, + formaPago: comprobante['@_FormaPago'] || null, + usoCfdi: receptor['@_UsoCFDI'] || null, + estado: 'vigente', + xmlOriginal: xmlContent, + }; + + if (!cfdi.uuidFiscal) { + 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 + */ +export function processPackage(zipBase64: string): CfdiParsed[] { + const xmlFiles = extractXmlsFromZip(zipBase64); + const cfdis: CfdiParsed[] = []; + + for (const { content } of xmlFiles) { + const cfdi = parseXml(content); + if (cfdi) { + cfdis.push(cfdi); + } + } + + return cfdis; +} + +/** + * 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, ExtractedXml }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3653db..07a8383 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@horux/shared': specifier: workspace:* version: link:../../packages/shared + '@nodecfdi/cfdi-core': + specifier: ^1.0.1 + version: 1.0.1 '@nodecfdi/credentials': specifier: ^3.2.0 version: 3.2.0(luxon@3.7.2) @@ -461,6 +464,10 @@ packages: resolution: {integrity: sha512-YoWtdhCPB86W+2TpXrZ1yXzehNC2sEFCB0vw4XtnHKdtw6pKxKyDT2qQf4TqICROp0IZNNKunFDw3EhcoR41Tw==} engines: {node: '>=18 <=22 || ^16'} + '@nodecfdi/cfdi-core@1.0.1': + resolution: {integrity: sha512-OGm8BUxehpofu53j0weJ8SyF8v6RNJsGdziBu/Y+Xfd6PnrbpMWdPd40LSiP5tctLzm9ubDQIwKJX63Zp0I5BA==} + engines: {node: '>=18'} + '@nodecfdi/credentials@3.2.0': resolution: {integrity: sha512-knZE8kIrIib27M/tcUQRgvnObMd7oR9EKZTSdBSHXW/5Pw6UB23v0ruUAJSFY0789J3OLfKaIVRXBG2I+q9ZTA==} engines: {node: '>=18 <=22 || ^16'} @@ -1060,6 +1067,10 @@ packages: resolution: {integrity: sha512-8GVr3S/nmLKL7QI7RYhVIcz3PuT/fxfkQLuh/F1CaT+/3QgI14RqiJkcKIni7h9u4ySbQGiGvm4XbNxRBJin4g==} engines: {node: '>= 6.13.0'} + '@xmldom/xmldom@0.9.8': + resolution: {integrity: sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==} + engines: {node: '>=14.6'} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -2510,6 +2521,10 @@ snapshots: '@nodecfdi/base-converter@1.0.7': {} + '@nodecfdi/cfdi-core@1.0.1': + dependencies: + '@xmldom/xmldom': 0.9.8 + '@nodecfdi/credentials@3.2.0(luxon@3.7.2)': dependencies: '@nodecfdi/base-converter': 1.0.7 @@ -3101,6 +3116,8 @@ snapshots: '@vilic/node-forge@1.3.2-5': {} + '@xmldom/xmldom@0.9.8': {} + accepts@1.3.8: dependencies: mime-types: 2.1.35