Initial commit - Horux Despachos NL
This commit is contained in:
735
apps/api/src/services/sat/sat-parser.service.ts
Normal file
735
apps/api/src/services/sat/sat-parser.service.ts
Normal file
@@ -0,0 +1,735 @@
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
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')) {
|
||||
const content = entry.getData().toString('utf-8');
|
||||
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);
|
||||
const s = String(str).trim();
|
||||
if (!s) return new Date(0);
|
||||
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 extractUuid(comprobante: any): string {
|
||||
return comprobante.Complemento?.TimbreFiscalDigital?.['@_UUID'] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae datos del timbre: fecha cert SAT y PAC
|
||||
*/
|
||||
function extractTimbreData(comprobante: any): { fechaCertSat: Date | null; pac: string | null } {
|
||||
const timbre = comprobante.Complemento?.TimbreFiscalDigital;
|
||||
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 ? fechas.join('|') : 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'] || null;
|
||||
result.fechaInicialPago = nomina['@_FechaInicialPago'] || null;
|
||||
result.fechaFinalPago = nomina['@_FechaFinalPago'] || 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,
|
||||
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,
|
||||
};
|
||||
|
||||
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<string, string> = {
|
||||
'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 };
|
||||
Reference in New Issue
Block a user