Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View 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 };