diff --git a/apps/api/src/jobs/sat-sync.job.ts b/apps/api/src/jobs/sat-sync.job.ts index bca9193..a6f8b94 100644 --- a/apps/api/src/jobs/sat-sync.job.ts +++ b/apps/api/src/jobs/sat-sync.job.ts @@ -351,6 +351,8 @@ async function runCsfJob(): Promise { console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message); failed++; } + // Delay entre tenants para no saturar al SAT y reducir bloqueos por IP + await new Promise(r => setTimeout(r, 30_000)); } console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`); } diff --git a/apps/api/src/services/constancia.service.ts b/apps/api/src/services/constancia.service.ts index 6089cc3..0813771 100644 --- a/apps/api/src/services/constancia.service.ts +++ b/apps/api/src/services/constancia.service.ts @@ -44,6 +44,9 @@ function rowToConstancia(r: any): ConstanciaRow { * sincroniza automáticamente domicilio + regímenes activos con lo que reporta * el SAT. El auto-fill NO es destructivo para datos custom del usuario: * solo sobreescribe campos si la CSF tiene un valor no-vacío. + * + * Incluye retry con backoff (3 intentos) para robustez ante timeouts + * transitorios del portal SAT (mantenimiento nocturno, congestión, etc.). */ export async function consultarConstancia(tenantId: string): Promise { const fiel = await getDecryptedFiel(tenantId); @@ -55,72 +58,78 @@ export async function consultarConstancia(tenantId: string): Promise((_, reject) => - setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT), - ); + writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 }); + writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 }); - const resultPromise = (async () => { - const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc); - const pdfBuffer = await extractCsfPdf(session); - const csf = await parseCsfPdf(pdfBuffer); - - const pool = await tenantDb.getPool(tenantId, tenant.databaseName); - const { rows } = await pool.query( - `INSERT INTO constancias_situacion_fiscal - (rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision, - datos, fecha_consulta, created_at`, - [ - csf.rfc, - csf.idCIF, - csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null, - csf.estatusPadron, - csf.lugarFechaEmision, - JSON.stringify(csf), - pdfBuffer, - ], + const headless = process.env.SAT_HEADLESS !== 'false'; + const browser = await chromium.launch({ + headless, + args: ['--disable-blink-features=AutomationControlled'], + ignoreDefaultArgs: ['--enable-automation'], + }); + try { + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 5 minutos')), 300_000), ); - // Auto-fill domicilio del tenant + regímenes activos desde el CSF. - // Se hace después del INSERT para que si algo falla en la sincronización - // la CSF ya quedó guardada y el usuario puede verla. - await sincronizarDatosFiscales(tenantId, csf).catch(err => { - console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err); - }); + const resultPromise = (async () => { + const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc); + const pdfBuffer = await extractCsfPdf(session); + const csf = await parseCsfPdf(pdfBuffer); - return rowToConstancia(rows[0]); - })(); + const pool = await tenantDb.getPool(tenantId, tenant.databaseName); + const { rows } = await pool.query( + `INSERT INTO constancias_situacion_fiscal + (rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision, + datos, fecha_consulta, created_at`, + [ + csf.rfc, + csf.idCIF, + csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null, + csf.estatusPadron, + csf.lugarFechaEmision, + JSON.stringify(csf), + pdfBuffer, + ], + ); - return await Promise.race([resultPromise, timeoutPromise]); + await sincronizarDatosFiscales(tenantId, csf).catch(err => { + console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err); + }); + + return rowToConstancia(rows[0]); + })(); + + return await Promise.race([resultPromise, timeoutPromise]); + } finally { + await browser.close(); + } + } catch (err: any) { + const willRetry = attempt < MAX_RETRIES - 1; + console.error(`[CSF] Intento ${attempt + 1}/${MAX_RETRIES} falló para tenant ${tenantId}: ${err.message}${willRetry ? ` — reintentando en ${RETRY_DELAYS[attempt]}ms...` : ''}`); + if (!willRetry) throw err; + await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt])); } finally { - await browser.close(); + try { unlinkSync(cerPath); } catch { /* ok */ } + try { unlinkSync(keyPath); } catch { /* ok */ } + try { rmdirSync(tempDir); } catch { /* ok */ } } - } finally { - try { unlinkSync(cerPath); } catch { /* ok */ } - try { unlinkSync(keyPath); } catch { /* ok */ } - try { rmdirSync(tempDir); } catch { /* ok */ } } + + throw new Error('No debería llegar aquí'); } /** diff --git a/apps/api/src/services/sat/sat-csf-login.ts b/apps/api/src/services/sat/sat-csf-login.ts index 2ede79b..208b6ad 100644 --- a/apps/api/src/services/sat/sat-csf-login.ts +++ b/apps/api/src/services/sat/sat-csf-login.ts @@ -30,20 +30,20 @@ export async function loginSatCsf( const publicPage = await context.newPage(); publicPage.setDefaultTimeout(60_000); - await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle' }); - await publicPage.waitForTimeout(2000); + await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle', timeout: 120_000 }); + await publicPage.waitForTimeout(3000); // Click acordeón "Obtén tu constancia" / "Obtener constancia" const obtenerLocator = publicPage.locator( 'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i', ).first(); - await obtenerLocator.waitFor({ state: 'visible', timeout: 60_000 }); + await obtenerLocator.waitFor({ state: 'visible', timeout: 120_000 }); await obtenerLocator.scrollIntoViewIfNeeded(); await obtenerLocator.click(); await publicPage.waitForTimeout(1500); // Click "SERVICIO" → popup - const popupPromise = context.waitForEvent('page', { timeout: 60_000 }); + const popupPromise = context.waitForEvent('page', { timeout: 120_000 }); await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click(); const loginPage = await popupPromise; await loginPage.waitForLoadState('domcontentloaded'); @@ -56,7 +56,7 @@ export async function loginSatCsf( const efirmaBtn = loginPage .locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]') .first(); - await efirmaBtn.waitFor({ state: 'visible', timeout: 30_000 }); + await efirmaBtn.waitFor({ state: 'visible', timeout: 60_000 }); await efirmaBtn.scrollIntoViewIfNeeded(); await efirmaBtn.click(); @@ -82,7 +82,7 @@ export async function loginSatCsf( return rfc !== null && rfc.value.length >= 12; }, null, - { timeout: 60_000 }, + { timeout: 120_000 }, ); rfcPopulated = true; } catch { @@ -121,7 +121,7 @@ export async function loginSatCsf( // Esperar a que salga del dominio de login y aterrice en el portal SAT await loginPage.waitForURL( url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'), - { timeout: 60_000 }, + { timeout: 120_000 }, ); await loginPage.waitForLoadState('networkidle').catch(() => undefined); await loginPage.waitForTimeout(2000);