- Agrega tabla sat_proxy_errors con proxy_usado, error_code, stage, message. - sat-client.service.ts expone proxyInfo usado en cada conexión SAT. - sat.service.ts registra errores de bloqueo/devolución del SAT en sat_proxy_errors y guarda proxyUsed en sat_sync_jobs. - Nuevo job sat-proxy-report.job.ts: cron 8 AM CDMX, envía email a ADMIN_EMAIL con errores de las últimas 24h agrupados por proxy/error_code. - Template de email sat-proxy-report.ts y método sendSatProxyReport. - Registra el cron en src/index.ts. - Actualiza docs/SAT-SYNC-IMPLEMENTATION.md.
386 lines
13 KiB
TypeScript
386 lines
13 KiB
TypeScript
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<QueryResult> {
|
|
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<VerifyResult> {
|
|
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<DownloadResult> {
|
|
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);
|
|
}
|