import { Fiel, HttpsWebClient, FielRequestBuilder, Service, QueryParameters, DateTimePeriod, DownloadType, RequestType, DocumentStatus, ServiceEndpoints, } from '@nodecfdi/sat-ws-descarga-masiva'; import { proxyManager } from './proxy.service.js'; export interface FielData { cerContent: string; keyContent: string; password: string; } export interface ProxyInfo { host: string; port: number; url: string; } /** * Timeout explícito para el cliente HTTP del SAT (ms). * * IMPORTANTE: la librería @nodecfdi/sat-ws-descarga-masiva@2.0.0 tiene un bug * en HttpsWebClient: si no se pasa un timeout explícito y ocurre un timeout * de red, rechaza con un `Error` nativo en vez de `WebClientException`. * Eso rompe el manejo de errores posterior y produce * `webError.getResponse is not a function`. * * Al pasar un timeout explícito, `_timeout` queda definido y la librería * envuelve el timeout como `WebClientException`, permitiendo reintentos sanos. * * El endpoint de verificación del SAT suele tardar >30s en responder; 5 minutos * da margen sin dejar la conexión colgada indefinidamente. */ const SAT_WEB_CLIENT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutos /** * Crea el servicio de descarga masiva del SAT usando los datos de la FIEL */ export function createSatService(fielData: FielData): { service: Service; proxyInfo: ProxyInfo | null } { // Crear FIEL usando el método estático create const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password); // Verificar que la FIEL sea válida if (!fiel.isValid()) { throw new Error('La FIEL no es válida o está vencida'); } // Crear cliente HTTP con timeout explícito para evitar el bug de la librería // cuando ocurre un timeout de red. Si hay proxies configurados, se usa uno // del pool para reducir el riesgo de bloqueo por IP del SAT. const proxy = proxyManager.getNextProxy(); const proxyAgent = proxy ? proxyManager.createAgent(proxy) : null; if (proxyAgent) { console.log('[SAT] Usando proxy para la conexión con el SAT'); } else { console.log('[SAT] Sin proxy configurado; usando IP directa del servidor'); } const webClient = new (HttpsWebClient as any)( undefined, undefined, SAT_WEB_CLIENT_TIMEOUT_MS, proxyAgent, ); // Crear request builder con la FIEL const requestBuilder = new FielRequestBuilder(fiel); // Crear y retornar el servicio const service = new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi()); return { service, proxyInfo: proxy ? { host: proxy.host, port: proxy.port, url: proxy.url } : null, }; } export interface QueryResult { success: boolean; requestId?: string; message: string; statusCode?: string; } export interface VerifyResult { success: boolean; status: 'pending' | 'processing' | 'ready' | 'failed' | 'rejected'; packageIds: string[]; totalCfdis: number; message: string; statusCode?: string; } export interface DownloadResult { success: boolean; packageContent: string; // Base64 encoded ZIP message: string; } /** * Realiza una consulta al SAT para solicitar CFDIs */ export async function querySat( service: Service, fechaInicio: Date, fechaFin: Date, tipo: 'emitidos' | 'recibidos', requestType: 'metadata' | 'cfdi' = 'cfdi' ): Promise { try { // El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca // a medianoche en zona horaria de México, dos fechas dentro del mismo día // calendario mexicano resultan iguales. Ajustamos fechaFin al día siguiente // en hora México para evitar el error. let adjustedFechaFin = fechaFin; if (isSameMexicoDay(fechaInicio, fechaFin)) { // Sumar 24h en ms es suficiente porque formatDateForSat solo usa la fecha // calendaria de México, no la hora. adjustedFechaFin = new Date(fechaFin.getTime() + 24 * 60 * 60 * 1000); } const period = DateTimePeriod.createFromValues( formatDateForSat(fechaInicio), formatDateForSat(adjustedFechaFin) ); const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received'); const reqType = new RequestType(requestType === 'cfdi' ? 'xml' : 'metadata'); // XMLs: solo vigentes (active). Metadata: todos (undefined = sin filtro). let parameters = QueryParameters.create(period, downloadType, reqType); if (requestType === 'cfdi') { parameters = parameters.withDocumentStatus(new DocumentStatus('active')); } const result = await service.query(parameters); if (!result.getStatus().isAccepted()) { return { success: false, message: result.getStatus().getMessage(), statusCode: result.getStatus().getCode().toString(), }; } return { success: true, requestId: result.getRequestId(), message: 'Solicitud aceptada', statusCode: result.getStatus().getCode().toString(), }; } catch (error: any) { // Errores tipo "EmptyResult (5004)" o "Se han agotado las solicitudes de por vida" // a veces vienen como excepción en vez de resultado aceptado. Los traducimos para // que el llamador los trate como "sin datos / no hay nada más que hacer" en lugar // de error fatal. const raw = error?.message || String(error); const emptyMatch = raw.match(/EmptyResult\s*\(?\s*(5004)\s*\)?/i) || raw.includes('5004'); if (emptyMatch) { return { success: false, message: 'No se encontró la información', statusCode: '5004', }; } const exhaustedMatch = raw.includes('Se han agotado las solicitudes de por vida'); if (exhaustedMatch) { return { success: false, message: 'Se han agotado las solicitudes de por vida para este rango', statusCode: 'exhausted', }; } console.error('[SAT Query Error]', error?.message, error?.stack || error); return { success: false, message: error.message || 'Error al realizar consulta', }; } } /** * Verifica el estado de una solicitud */ export async function verifySatRequest( service: Service, requestId: string ): Promise { try { const result = await service.verify(requestId); const statusRequest = result.getStatusRequest(); // `codeRequest` es el código SAT específico del estado de la solicitud // (5000 Accepted, 5002 Exhausted, 5003 MaximumLimit, 5004 EmptyResult, // 5005 Duplicated) y su mensaje explica POR QUÉ el SAT rechazó. Es la // pieza clave para diagnosticar rejections — el `getStatus().getCode()` // solo devuelve el wrapper HTTP (5000 genérico "Aceptada"). // // Fuente: docs phpcfdi + lib @nodecfdi/sat-ws-descarga-masiva (`CodeRequest`). const codeReqObj = typeof (result as any).getCodeRequest === 'function' ? (result as any).getCodeRequest() : null; const codeRequestValue = codeReqObj ? codeReqObj.getValue() : null; const codeRequestMessage = codeReqObj ? codeReqObj.getMessage() : null; const codeRequestEntry = codeReqObj ? codeReqObj.getEntryId() : null; // Debug logging console.log('[SAT Verify Debug]', { statusRequestValue: statusRequest.getValue(), statusRequestEntryId: statusRequest.getEntryId(), cfdis: result.getNumberCfdis(), packages: result.getPackageIds(), statusCode: result.getStatus().getCode(), statusMsg: result.getStatus().getMessage(), codeRequestValue, codeRequestEntry, codeRequestMessage, }); // Usar isTypeOf para determinar el estado let status: VerifyResult['status']; if (statusRequest.isTypeOf('Finished')) { status = 'ready'; } else if (statusRequest.isTypeOf('InProgress')) { status = 'processing'; } else if (statusRequest.isTypeOf('Accepted')) { status = 'pending'; } else if (statusRequest.isTypeOf('Failure')) { status = 'failed'; } else if (statusRequest.isTypeOf('Rejected')) { status = 'rejected'; } else { // Default: check by entryId const entryId = statusRequest.getEntryId(); if (entryId === 'Finished') status = 'ready'; else if (entryId === 'InProgress') status = 'processing'; else if (entryId === 'Accepted') status = 'pending'; else if (entryId === 'Unknown' && result.getStatus().getCode().toString() === '404') status = 'failed'; else status = 'pending'; } // Para estados terminales no-felices, construir mensaje informativo. // `codeRequest` (si está disponible) es la razón SAT real del rechazo. const statusCode = result.getStatus().getCode().toString(); const statusMsg = result.getStatus().getMessage(); const reqValue = statusRequest.getValue(); const reqEntry = statusRequest.getEntryId(); // EmptyResult (5004) o Exhausted (5002, "solicitudes de por vida"): el SAT // aceptó la solicitud pero no generó paquetes (rango sin info) o ya agotamos // las solicitudes de ese rango. Tratarlos como "ready" con 0 paquetes para // NO fallar la etapa ni quemar reintentos — es un resultado benigno. // Se comparan value/entry/mensaje de forma defensiva porque getValue() puede // venir como number o string según la versión de la librería. const codeValueStr = codeRequestValue != null ? String(codeRequestValue) : ''; const codeEntryStr = codeRequestEntry != null ? String(codeRequestEntry) : ''; const codeMsgStr = codeRequestMessage != null ? String(codeRequestMessage) : ''; const isEmptyResult = codeValueStr === '5004' || codeEntryStr === '5004' || /EmptyResult/i.test(codeEntryStr) || /\b5004\b/.test(codeMsgStr); const isExhausted = codeValueStr === '5002' || /Exhausted/i.test(codeEntryStr) || /solicitudes de por vida/i.test(codeMsgStr); if (isEmptyResult || isExhausted) { return { success: true, status: 'ready', packageIds: [], totalCfdis: 0, message: isExhausted ? 'Se han agotado las solicitudes de por vida para este rango' : 'No se encontró información para el rango solicitado', statusCode, }; } let message = statusMsg; if (status === 'rejected' || status === 'failed') { const codeReqStr = codeRequestValue ? ` codeRequest=${codeRequestEntry}(${codeRequestValue}) — ${codeRequestMessage}` : ''; message = `SAT request=${reqEntry}(${reqValue})${codeReqStr} wrapperCode=${statusCode} wrapperMsg="${statusMsg}"`; } return { success: result.getStatus().isAccepted(), status, packageIds: result.getPackageIds(), totalCfdis: result.getNumberCfdis(), message, statusCode, }; } catch (error: any) { console.error('[SAT Verify Error]', error?.message, error?.stack || error); // Errores de la librería (ej. webError.getResponse is not a function) // no son fallos del SAT — devolver 'pending' para reintentar polling return { success: false, status: 'pending', packageIds: [], totalCfdis: 0, message: error.message || 'Error al verificar solicitud', }; } } /** * Descarga un paquete de CFDIs */ export async function downloadSatPackage( service: Service, packageId: string ): Promise { try { const result = await service.download(packageId); if (!result.getStatus().isAccepted()) { return { success: false, packageContent: '', message: result.getStatus().getMessage(), }; } return { success: true, packageContent: result.getPackageContent(), message: 'Paquete descargado', }; } catch (error: any) { console.error('[SAT Download Error]', error); return { success: false, packageContent: '', message: error.message || 'Error al descargar paquete', }; } } /** * Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss). * El SAT requiere hora 00:00:00; cualquier otra hora causa * "Fecha final invalida" / "Fecha inicial invalida". * * IMPORTANTE: las fechas deben interpretarse en la zona horaria de México * (America/Mexico_City) porque el SAT opera en esa zona. El servidor corre * en UTC, así que usamos Intl.DateTimeFormat para obtener los componentes * locales a México. */ function formatDateForSat(date: Date): string { const fmt = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit', }); const parts = fmt.formatToParts(date); const get = (type: string) => parts.find(p => p.type === type)?.value || '00'; return `${get('year')}-${get('month')}-${get('day')} 00:00:00`; } /** * Devuelve true si dos fechas (interpretadas en zona horaria de México) * caen en el mismo día calendario. */ function isSameMexicoDay(a: Date, b: Date): boolean { const fmt = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit', }); return fmt.format(a) === fmt.format(b); }