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:
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@horux/shared": "workspace:*",
|
"@horux/shared": "workspace:*",
|
||||||
|
"@nodecfdi/cfdi-core": "^1.0.1",
|
||||||
"@nodecfdi/credentials": "^3.2.0",
|
"@nodecfdi/credentials": "^3.2.0",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
|
|||||||
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 };
|
||||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
|||||||
'@horux/shared':
|
'@horux/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
'@nodecfdi/cfdi-core':
|
||||||
|
specifier: ^1.0.1
|
||||||
|
version: 1.0.1
|
||||||
'@nodecfdi/credentials':
|
'@nodecfdi/credentials':
|
||||||
specifier: ^3.2.0
|
specifier: ^3.2.0
|
||||||
version: 3.2.0(luxon@3.7.2)
|
version: 3.2.0(luxon@3.7.2)
|
||||||
@@ -461,6 +464,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-YoWtdhCPB86W+2TpXrZ1yXzehNC2sEFCB0vw4XtnHKdtw6pKxKyDT2qQf4TqICROp0IZNNKunFDw3EhcoR41Tw==}
|
resolution: {integrity: sha512-YoWtdhCPB86W+2TpXrZ1yXzehNC2sEFCB0vw4XtnHKdtw6pKxKyDT2qQf4TqICROp0IZNNKunFDw3EhcoR41Tw==}
|
||||||
engines: {node: '>=18 <=22 || ^16'}
|
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':
|
'@nodecfdi/credentials@3.2.0':
|
||||||
resolution: {integrity: sha512-knZE8kIrIib27M/tcUQRgvnObMd7oR9EKZTSdBSHXW/5Pw6UB23v0ruUAJSFY0789J3OLfKaIVRXBG2I+q9ZTA==}
|
resolution: {integrity: sha512-knZE8kIrIib27M/tcUQRgvnObMd7oR9EKZTSdBSHXW/5Pw6UB23v0ruUAJSFY0789J3OLfKaIVRXBG2I+q9ZTA==}
|
||||||
engines: {node: '>=18 <=22 || ^16'}
|
engines: {node: '>=18 <=22 || ^16'}
|
||||||
@@ -1060,6 +1067,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-8GVr3S/nmLKL7QI7RYhVIcz3PuT/fxfkQLuh/F1CaT+/3QgI14RqiJkcKIni7h9u4ySbQGiGvm4XbNxRBJin4g==}
|
resolution: {integrity: sha512-8GVr3S/nmLKL7QI7RYhVIcz3PuT/fxfkQLuh/F1CaT+/3QgI14RqiJkcKIni7h9u4ySbQGiGvm4XbNxRBJin4g==}
|
||||||
engines: {node: '>= 6.13.0'}
|
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:
|
accepts@1.3.8:
|
||||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -2510,6 +2521,10 @@ snapshots:
|
|||||||
|
|
||||||
'@nodecfdi/base-converter@1.0.7': {}
|
'@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)':
|
'@nodecfdi/credentials@3.2.0(luxon@3.7.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodecfdi/base-converter': 1.0.7
|
'@nodecfdi/base-converter': 1.0.7
|
||||||
@@ -3101,6 +3116,8 @@ snapshots:
|
|||||||
|
|
||||||
'@vilic/node-forge@1.3.2-5': {}
|
'@vilic/node-forge@1.3.2-5': {}
|
||||||
|
|
||||||
|
'@xmldom/xmldom@0.9.8': {}
|
||||||
|
|
||||||
accepts@1.3.8:
|
accepts@1.3.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
|
|||||||
Reference in New Issue
Block a user