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:
244
apps/api/src/services/sat/sat-parser.service.ts
Normal file
244
apps/api/src/services/sat/sat-parser.service.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user