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:
Horux Dev
2026-08-02 20:06:51 +00:00
parent dfc0183c12
commit 284c7620a9
14 changed files with 409 additions and 45 deletions

View File

@@ -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);
}