import { XMLParser } from 'fast-xml-parser'; import { createHash, randomUUID } from 'crypto'; import type { Credential } from '@nodecfdi/credentials/node'; import type { SatDownloadRequestResponse, SatVerifyResponse, SatPackageResponse, CfdiSyncType } from '@horux/shared'; const SAT_SOLICITUD_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc'; const SAT_VERIFICA_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc'; const SAT_DESCARGA_URL = 'https://cfdidescargamasaborrar.clouda.sat.gob.mx/DescargaMasivaService.svc'; type TipoSolicitud = 'CFDI' | 'Metadata'; interface RequestDownloadParams { credential: Credential; token: string; rfc: string; fechaInicio: Date; fechaFin: Date; tipoSolicitud: TipoSolicitud; tipoCfdi: CfdiSyncType; } /** * Formatea fecha a formato SAT (YYYY-MM-DDTHH:MM:SS) */ function formatSatDate(date: Date): string { return date.toISOString().slice(0, 19); } /** * Construye el XML de solicitud de descarga */ function buildDownloadRequest(params: RequestDownloadParams): string { const { credential, token, rfc, fechaInicio, fechaFin, tipoSolicitud, tipoCfdi } = params; const uuid = randomUUID(); const certificate = credential.certificate(); const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64'); // Construir el elemento de solicitud const rfcEmisor = tipoCfdi === 'emitidos' ? rfc : undefined; const rfcReceptor = tipoCfdi === 'recibidos' ? rfc : undefined; const solicitudContent = `${rfc}` + `${formatSatDate(fechaInicio)}` + `${formatSatDate(fechaFin)}` + `${tipoSolicitud}` + (rfcEmisor ? `${rfcEmisor}` : '') + (rfcReceptor ? `${rfcReceptor}` : ''); const solicitudToSign = `${solicitudContent}`; const digestValue = createHash('sha1').update(solicitudToSign).digest('base64'); const signedInfoXml = `` + `` + `` + `` + `` + `` + `` + `` + `${digestValue}` + `` + ``; const signatureBinary = credential.sign(signedInfoXml, 'sha1'); const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64'); return ` ${signedInfoXml} ${signatureValue} ${certificate.issuerAsRfc4514()} ${certificate.serialNumber().bytes()} ${cerB64} `; } /** * Solicita la descarga de CFDIs al SAT */ export async function requestDownload(params: RequestDownloadParams): Promise { const soapRequest = buildDownloadRequest(params); try { const response = await fetch(SAT_SOLICITUD_URL, { method: 'POST', headers: { 'Content-Type': 'text/xml;charset=UTF-8', 'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/ISolicitaDescargaService/SolicitaDescarga', 'Authorization': `WRAP access_token="${params.token}"`, }, body: soapRequest, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Error HTTP ${response.status}: ${errorText}`); } const responseXml = await response.text(); return parseDownloadRequestResponse(responseXml); } catch (error: any) { console.error('[SAT Download Request Error]', error); throw new Error(`Error al solicitar descarga: ${error.message}`); } } /** * Parsea la respuesta de solicitud de descarga */ function parseDownloadRequestResponse(responseXml: string): SatDownloadRequestResponse { const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, attributeNamePrefix: '@_', }); const result = parser.parse(responseXml); const envelope = result.Envelope || result['s:Envelope']; const body = envelope?.Body || envelope?.['s:Body']; const respuesta = body?.SolicitaDescargaResponse?.SolicitaDescargaResult; if (!respuesta) { throw new Error('Respuesta inválida del SAT'); } return { idSolicitud: respuesta['@_IdSolicitud'] || '', codEstatus: respuesta['@_CodEstatus'] || '', mensaje: respuesta['@_Mensaje'] || '', }; } /** * Verifica el estado de una solicitud de descarga */ export async function verifyRequest( credential: Credential, token: string, rfc: string, idSolicitud: string ): Promise { const certificate = credential.certificate(); const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64'); const verificaContent = ``; const digestValue = createHash('sha1').update(verificaContent).digest('base64'); const signedInfoXml = `` + `` + `` + `` + `` + `` + `${digestValue}` + `` + ``; const signatureBinary = credential.sign(signedInfoXml, 'sha1'); const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64'); const soapRequest = ` ${signedInfoXml} ${signatureValue} ${certificate.issuerAsRfc4514()} ${certificate.serialNumber().bytes()} ${cerB64} `; try { const response = await fetch(SAT_VERIFICA_URL, { method: 'POST', headers: { 'Content-Type': 'text/xml;charset=UTF-8', 'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IVerificaSolicitudDescargaService/VerificaSolicitudDescarga', 'Authorization': `WRAP access_token="${token}"`, }, body: soapRequest, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Error HTTP ${response.status}: ${errorText}`); } const responseXml = await response.text(); return parseVerifyResponse(responseXml); } catch (error: any) { console.error('[SAT Verify Error]', error); throw new Error(`Error al verificar solicitud: ${error.message}`); } } /** * Parsea la respuesta de verificación */ function parseVerifyResponse(responseXml: string): SatVerifyResponse { const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, attributeNamePrefix: '@_', }); const result = parser.parse(responseXml); const envelope = result.Envelope || result['s:Envelope']; const body = envelope?.Body || envelope?.['s:Body']; const respuesta = body?.VerificaSolicitudDescargaResponse?.VerificaSolicitudDescargaResult; if (!respuesta) { throw new Error('Respuesta de verificación inválida'); } // Extraer paquetes let paquetes: string[] = []; const paquetesNode = respuesta.IdsPaquetes; if (paquetesNode) { if (Array.isArray(paquetesNode)) { paquetes = paquetesNode; } else if (typeof paquetesNode === 'string') { paquetes = [paquetesNode]; } } return { codEstatus: respuesta['@_CodEstatus'] || '', estadoSolicitud: parseInt(respuesta['@_EstadoSolicitud'] || '0', 10), codigoEstadoSolicitud: respuesta['@_CodigoEstadoSolicitud'] || '', numeroCfdis: parseInt(respuesta['@_NumeroCFDIs'] || '0', 10), mensaje: respuesta['@_Mensaje'] || '', paquetes, }; } /** * Descarga un paquete de CFDIs */ export async function downloadPackage( credential: Credential, token: string, rfc: string, idPaquete: string ): Promise { const certificate = credential.certificate(); const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64'); const descargaContent = ``; const digestValue = createHash('sha1').update(descargaContent).digest('base64'); const signedInfoXml = `` + `` + `` + `` + `` + `` + `${digestValue}` + `` + ``; const signatureBinary = credential.sign(signedInfoXml, 'sha1'); const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64'); const soapRequest = ` ${signedInfoXml} ${signatureValue} ${certificate.issuerAsRfc4514()} ${certificate.serialNumber().bytes()} ${cerB64} `; try { const response = await fetch(SAT_DESCARGA_URL, { method: 'POST', headers: { 'Content-Type': 'text/xml;charset=UTF-8', 'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IDescargaMasivaService/Descargar', 'Authorization': `WRAP access_token="${token}"`, }, body: soapRequest, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Error HTTP ${response.status}: ${errorText}`); } const responseXml = await response.text(); return parseDownloadResponse(responseXml); } catch (error: any) { console.error('[SAT Download Package Error]', error); throw new Error(`Error al descargar paquete: ${error.message}`); } } /** * Parsea la respuesta de descarga de paquete */ function parseDownloadResponse(responseXml: string): SatPackageResponse { const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, attributeNamePrefix: '@_', }); const result = parser.parse(responseXml); const envelope = result.Envelope || result['s:Envelope']; const body = envelope?.Body || envelope?.['s:Body']; const respuesta = body?.RespuestaDescargaMasivaTercerosSalida?.Paquete; if (!respuesta) { throw new Error('No se pudo obtener el paquete'); } return { paquete: respuesta, }; } /** * Estados de solicitud del SAT */ export const SAT_REQUEST_STATES = { ACCEPTED: 1, IN_PROGRESS: 2, COMPLETED: 3, ERROR: 4, REJECTED: 5, EXPIRED: 6, } as const; /** * Verifica si la solicitud está completa */ export function isRequestComplete(estadoSolicitud: number): boolean { return estadoSolicitud === SAT_REQUEST_STATES.COMPLETED; } /** * Verifica si la solicitud falló */ export function isRequestFailed(estadoSolicitud: number): boolean { return ( estadoSolicitud === SAT_REQUEST_STATES.ERROR || estadoSolicitud === SAT_REQUEST_STATES.REJECTED || estadoSolicitud === SAT_REQUEST_STATES.EXPIRED ); } /** * Verifica si la solicitud está en progreso */ export function isRequestInProgress(estadoSolicitud: number): boolean { return ( estadoSolicitud === SAT_REQUEST_STATES.ACCEPTED || estadoSolicitud === SAT_REQUEST_STATES.IN_PROGRESS ); }