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 <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-01-25 00:50:11 +00:00
parent 56e6e27ab3
commit 09684f77b9
3 changed files with 262 additions and 0 deletions

View File

@@ -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",

View File

@@ -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<string, TipoCfdi> = {
'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 };

17
pnpm-lock.yaml generated
View File

@@ -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