feat: mejoras SAT, auth, web y migración
SAT: - Cron diario 6-10 AM con grupos de tenants (~20% por hora). - Retries fijos de daily sync a 9 AM y 4 PM CDMX. - Filtrado de contribuyentes con FIEL vigente en cron. - Timeout de 5 min en cliente HTTP del SAT. - Manejo defensivo de 404, EmptyResult (5004) y solicitudes agotadas. - Fechas formateadas en zona horaria America/Mexico_City. - Patch a @nodecfdi/sat-ws-descarga-masiva para evitar getResponse crash. - Sweep de jobs stale con thresholds ajustados. Auth/Web: - Primer pago de suscripción define periodo activo en webhook. - Rate limit de login: 25 intentos / 15 min. - Recuperación de contraseña: 24h de validez. - Soporte viewingTenantId en contribuyentes. - Timeout y estado de carga al crear organización en Facturapi. - Filtros por cliente y cartera en Mis Asignados. - Orden alfabético en selector de contribuyente. DB: - Migración 057: unique index de declaraciones incluye impuestos.
This commit is contained in:
@@ -17,6 +17,23 @@ export interface FielData {
|
||||
password: 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
|
||||
*/
|
||||
@@ -29,8 +46,13 @@ export function createSatService(fielData: FielData): Service {
|
||||
throw new Error('La FIEL no es válida o está vencida');
|
||||
}
|
||||
|
||||
// Crear cliente HTTP
|
||||
const webClient = new HttpsWebClient();
|
||||
// Crear cliente HTTP con timeout explícito para evitar el bug de la librería
|
||||
// cuando ocurre un timeout de red.
|
||||
const webClient = new (HttpsWebClient as any)(
|
||||
undefined,
|
||||
undefined,
|
||||
SAT_WEB_CLIENT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
// Crear request builder con la FIEL
|
||||
const requestBuilder = new FielRequestBuilder(fiel);
|
||||
@@ -73,10 +95,13 @@ export async function querySat(
|
||||
): Promise<QueryResult> {
|
||||
try {
|
||||
// El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca
|
||||
// a medianoche, dos fechas dentro del mismo día calendario resultan iguales.
|
||||
// Ajustamos fechaFin al día siguiente para evitar el error.
|
||||
// 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 (formatDateForSat(fechaInicio) === formatDateForSat(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);
|
||||
}
|
||||
|
||||
@@ -110,7 +135,30 @@ export async function querySat(
|
||||
statusCode: result.getStatus().getCode().toString(),
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Query Error]', error);
|
||||
// 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',
|
||||
@@ -174,6 +222,7 @@ export async function verifySatRequest(
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -183,6 +232,38 @@ export async function verifySatRequest(
|
||||
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
|
||||
@@ -200,7 +281,7 @@ export async function verifySatRequest(
|
||||
statusCode,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Verify Error]', error.message || error);
|
||||
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 {
|
||||
@@ -250,8 +331,34 @@ export async function downloadSatPackage(
|
||||
* 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 pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`;
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user