- 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.
246 lines
9.0 KiB
TypeScript
246 lines
9.0 KiB
TypeScript
import { chromium } from 'playwright';
|
|
import { writeFileSync, mkdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { randomUUID } from 'crypto';
|
|
import { tenantDb, prisma } from '../src/config/database.js';
|
|
import { getDecryptedFielContribuyente } from '../src/services/contribuyente-fiel.service.js';
|
|
|
|
const CONTRIBUYENTE_ID = 'cc41d462-4270-4949-aa9b-f0a175784659';
|
|
const DEBUG_DIR = join(tmpdir(), `horux-csf-debug-${Date.now()}`);
|
|
|
|
let step = 0;
|
|
async function snapshot(page: any, label: string) {
|
|
step++;
|
|
const prefix = `${String(step).padStart(2, '0')}-${label}`;
|
|
try {
|
|
await page.screenshot({ path: join(DEBUG_DIR, `${prefix}.png`), fullPage: true });
|
|
const html = await page.content().catch(() => '');
|
|
writeFileSync(join(DEBUG_DIR, `${prefix}.html`), html);
|
|
console.log(`[DEBUG] ${prefix} — screenshot + html guardados`);
|
|
} catch (err: any) {
|
|
console.error(`[DEBUG] ${prefix} — error al guardar snapshot:`, err.message);
|
|
}
|
|
}
|
|
|
|
function log(msg: string) {
|
|
console.log(`[DEBUG] ${new Date().toISOString()} — ${msg}`);
|
|
}
|
|
|
|
async function main() {
|
|
mkdirSync(DEBUG_DIR, { recursive: true });
|
|
log(`Directorio debug: ${DEBUG_DIR}`);
|
|
|
|
const tenant = await prisma.tenant.findUnique({
|
|
where: { rfc: 'DESPACHO_MPG95QP7_XZVFF' },
|
|
select: { id: true, databaseName: true },
|
|
});
|
|
if (!tenant) throw new Error('Tenant no encontrado');
|
|
|
|
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
|
const fiel = await getDecryptedFielContribuyente(pool, CONTRIBUYENTE_ID);
|
|
if (!fiel) throw new Error('No hay FIEL');
|
|
|
|
log(`FIEL obtenida para RFC ${fiel.rfc}`);
|
|
|
|
const tempId = randomUUID();
|
|
const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
|
|
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
|
|
const cerPath = join(tempDir, 'cert.cer');
|
|
const keyPath = join(tempDir, 'key.key');
|
|
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
|
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
|
|
|
const PUBLIC_URL = 'https://www.sat.gob.mx/portal/public/tramites/constancia-de-situacion-fiscal';
|
|
|
|
log('Lanzando Chromium...');
|
|
const start = Date.now();
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
args: ['--disable-blink-features=AutomationControlled'],
|
|
ignoreDefaultArgs: ['--enable-automation'],
|
|
});
|
|
|
|
try {
|
|
const context = await browser.newContext({
|
|
acceptDownloads: true,
|
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
});
|
|
await context.addInitScript(() => {
|
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
});
|
|
|
|
const publicPage = await context.newPage();
|
|
publicPage.setDefaultTimeout(120_000);
|
|
|
|
log(`Navegando a ${PUBLIC_URL}`);
|
|
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle', timeout: 120_000 });
|
|
await publicPage.waitForTimeout(3000);
|
|
await snapshot(publicPage, 'public-page');
|
|
|
|
log('Buscando acordeón "Obtén la 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();
|
|
|
|
try {
|
|
await obtenerLocator.waitFor({ state: 'visible', timeout: 120_000 });
|
|
log('Acordeón encontrado, haciendo click...');
|
|
await obtenerLocator.scrollIntoViewIfNeeded();
|
|
await obtenerLocator.click();
|
|
await publicPage.waitForTimeout(1500);
|
|
await snapshot(publicPage, 'after-accordion-click');
|
|
} catch (err: any) {
|
|
log(`ERROR acordeón: ${err.message}`);
|
|
await snapshot(publicPage, 'accordion-error');
|
|
throw err;
|
|
}
|
|
|
|
log('Buscando botón SERVICIO...');
|
|
const servicioLocator = publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first();
|
|
try {
|
|
await servicioLocator.waitFor({ state: 'visible', timeout: 120_000 });
|
|
log('Botón SERVICIO encontrado, haciendo click...');
|
|
const popupPromise = context.waitForEvent('page', { timeout: 120_000 });
|
|
await servicioLocator.click();
|
|
log('Esperando popup de login...');
|
|
var loginPage = await popupPromise;
|
|
log(`Popup abierto: ${loginPage.url()}`);
|
|
await loginPage.waitForLoadState('domcontentloaded');
|
|
await snapshot(loginPage, 'login-popup');
|
|
} catch (err: any) {
|
|
log(`ERROR SERVICIO/popup: ${err.message}`);
|
|
await snapshot(publicPage, 'servicio-error');
|
|
throw err;
|
|
}
|
|
|
|
loginPage!.setDefaultTimeout(120_000);
|
|
|
|
log('Buscando botón e.firma...');
|
|
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();
|
|
try {
|
|
await efirmaBtn.waitFor({ state: 'visible', timeout: 120_000 });
|
|
log('Botón e.firma encontrado, haciendo click...');
|
|
await efirmaBtn.scrollIntoViewIfNeeded();
|
|
await efirmaBtn.click();
|
|
await snapshot(loginPage!, 'after-efirma-click');
|
|
} catch (err: any) {
|
|
log(`ERROR e.firma: ${err.message}`);
|
|
await snapshot(loginPage!, 'efirma-error');
|
|
throw err;
|
|
}
|
|
|
|
log('Esperando inputs de archivo...');
|
|
const fileInputs = loginPage!.locator('input[type="file"]');
|
|
try {
|
|
await fileInputs.first().waitFor({ state: 'attached', timeout: 60_000 });
|
|
} catch {
|
|
log('Click no disparó handler, reintentando con dispatchEvent...');
|
|
await efirmaBtn.dispatchEvent('click');
|
|
await fileInputs.first().waitFor({ state: 'attached', timeout: 60_000 });
|
|
await snapshot(loginPage!, 'after-dispatch-click');
|
|
}
|
|
|
|
log('Subiendo .cer y .key...');
|
|
await fileInputs.nth(0).setInputFiles(cerPath);
|
|
await fileInputs.nth(1).setInputFiles(keyPath);
|
|
await snapshot(loginPage!, 'after-file-upload');
|
|
|
|
log('Esperando auto-populado de RFC...');
|
|
try {
|
|
await loginPage!.waitForFunction(
|
|
() => {
|
|
const rfc = document.getElementById('rfc') as HTMLInputElement | null;
|
|
return rfc !== null && rfc.value.length >= 12;
|
|
},
|
|
null,
|
|
{ timeout: 120_000 },
|
|
);
|
|
log('RFC auto-populado');
|
|
} catch (err: any) {
|
|
log(`ERROR RFC auto-populate: ${err.message}`);
|
|
await snapshot(loginPage!, 'rfc-autopopulate-error');
|
|
throw err;
|
|
}
|
|
|
|
log('Llenando password y enviando...');
|
|
await loginPage!.locator('input[type="password"]').first().fill(fiel.password);
|
|
await snapshot(loginPage!, 'before-submit');
|
|
await loginPage!.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click({ noWaitAfter: true });
|
|
|
|
log('Esperando redirección a portal SAT...');
|
|
await loginPage!.waitForURL(
|
|
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
|
|
{ timeout: 180_000 },
|
|
);
|
|
await loginPage!.waitForLoadState('networkidle').catch(() => undefined);
|
|
await loginPage!.waitForTimeout(2000);
|
|
await snapshot(loginPage!, 'after-login-redirect');
|
|
log(`URL post-login: ${loginPage!.url()}`);
|
|
|
|
const bodyText = await loginPage!.locator('body').innerText().catch(() => '');
|
|
if (/contrase[nñ]a\\s+incorrecta|usuario.*no.*v[aá]lido|firma\\s+inv[aá]lida/i.test(bodyText)) {
|
|
throw new Error('FIEL inválida o contraseña incorrecta');
|
|
}
|
|
|
|
log('Buscando botón Generar Constancia...');
|
|
const GENERAR_SELECTORS = [
|
|
'button:has-text("Generar Constancia")',
|
|
'button:has-text("Generar constancia")',
|
|
'input[type="button"][value*="Generar" i]',
|
|
'input[type="submit"][value*="Generar" i]',
|
|
'a:has-text("Generar Constancia")',
|
|
'a:has-text("Generar constancia")',
|
|
].join(', ');
|
|
|
|
let generarLocator: any = null;
|
|
let foundFrame: any = null;
|
|
const deadline = Date.now() + 120_000;
|
|
while (Date.now() < deadline) {
|
|
for (const frame of loginPage!.frames()) {
|
|
const loc = frame.locator(GENERAR_SELECTORS).first();
|
|
const count = await loc.count().catch(() => 0);
|
|
if (count > 0 && await loc.isVisible().catch(() => false)) {
|
|
generarLocator = loc;
|
|
foundFrame = frame;
|
|
break;
|
|
}
|
|
}
|
|
if (generarLocator) break;
|
|
await loginPage!.waitForTimeout(1000);
|
|
}
|
|
|
|
if (!generarLocator) {
|
|
log('No se encontró botón Generar Constancia');
|
|
await snapshot(loginPage!, 'generar-not-found');
|
|
throw new Error('Botón Generar Constancia no encontrado');
|
|
}
|
|
|
|
log('Botón Generar Constancia encontrado, haciendo click...');
|
|
await generarLocator.scrollIntoViewIfNeeded();
|
|
await generarLocator.click();
|
|
await loginPage!.waitForTimeout(5000);
|
|
await snapshot(loginPage!, 'after-generar-click');
|
|
|
|
log('Proceso completado sin timeout');
|
|
} catch (err: any) {
|
|
log(`ERROR: ${err.message}`);
|
|
throw err;
|
|
} finally {
|
|
log(`Cerrando browser. Tiempo total: ${(Date.now() - start) / 1000}s`);
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
main()
|
|
.then(() => {
|
|
log('FIN OK');
|
|
process.exit(0);
|
|
})
|
|
.catch((e) => {
|
|
log(`FIN ERROR: ${e.message}`);
|
|
process.exit(1);
|
|
});
|