import { chromium } from 'playwright'; import { writeFileSync, unlinkSync, mkdirSync, rmdirSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { randomUUID } from 'crypto'; import type { Pool } from 'pg'; import type { OpinionCumplimiento } from '@horux/shared'; import { getDecryptedFiel } from './fiel.service.js'; import { getDecryptedFielContribuyente } from './contribuyente-fiel.service.js'; import { loginToSatOpinion } from './sat/sat-opinion-login.js'; import { extractOpinionPdf } from './sat/sat-opinion-scraper.js'; import { parseOpinionPdf } from './sat/sat-opinion-parser.js'; import { prisma, tenantDb } from '../config/database.js'; const PROCESS_TIMEOUT = 180_000; // 3 minutes per tenant /** * Downloads and stores the Opinión de Cumplimiento for a tenant. */ export async function consultarOpinion(tenantId: string): Promise { const fiel = await getDecryptedFiel(tenantId); if (!fiel) { throw new Error('No hay FIEL configurada o está vencida'); } const tempId = randomUUID(); const tempDir = join(tmpdir(), `horux-fiel-${tempId}`); mkdirSync(tempDir, { recursive: true, mode: 0o700 }); const cerPath = join(tempDir, 'cert.cer'); const keyPath = join(tempDir, 'key.key'); try { writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 }); writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 }); const browser = await chromium.launch({ headless: true }); try { const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, }); const page = await context.newPage(); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout: proceso de opinión excedió 3 minutos')), PROCESS_TIMEOUT) ); const resultPromise = (async () => { const reportPage = await loginToSatOpinion(page, cerPath, keyPath, fiel.password); const pdfBuffer = await extractOpinionPdf(reportPage); const parsed = await parseOpinionPdf(pdfBuffer); const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { databaseName: true }, }); if (!tenant) throw new Error('Tenant no encontrado'); const pool = await tenantDb.getPool(tenantId, tenant.databaseName); const { rows } = await pool.query(` INSERT INTO opiniones_cumplimiento (rfc, razon_social, estatus, folio, cadena_original, fecha_consulta, pdf) VALUES ($1, $2, $3, $4, $5, NOW(), $6) RETURNING id, rfc, razon_social as "razonSocial", estatus, folio, cadena_original as "cadenaOriginal", fecha_consulta as "fechaConsulta", created_at as "createdAt" `, [parsed.rfc, parsed.razonSocial, parsed.estatus, parsed.folio, parsed.cadenaOriginal, pdfBuffer]); return rows[0] as OpinionCumplimiento; })(); return await Promise.race([resultPromise, timeoutPromise]); } finally { await browser.close(); } } finally { try { unlinkSync(cerPath); } catch { /* ok */ } try { unlinkSync(keyPath); } catch { /* ok */ } try { rmdirSync(tempDir); } catch { /* ok */ } } } /** * Get last N opinions for a tenant (metadata only, no PDF). */ export async function getOpiniones(pool: Pool, limit = 5, rfc?: string): Promise { const params: unknown[] = [limit]; let rfcFilter = ''; if (rfc) { rfcFilter = 'WHERE rfc = $2'; params.push(rfc); } const { rows } = await pool.query(` SELECT id, rfc, razon_social as "razonSocial", estatus, folio, cadena_original as "cadenaOriginal", fecha_consulta as "fechaConsulta", created_at as "createdAt" FROM opiniones_cumplimiento ${rfcFilter} ORDER BY fecha_consulta DESC LIMIT $1 `, params); return rows; } /** * Get PDF binary for a specific opinion. */ export async function getOpinionPdf(pool: Pool, id: number): Promise { const { rows } = await pool.query( `SELECT pdf FROM opiniones_cumplimiento WHERE id = $1`, [id] ); return rows.length > 0 ? rows[0].pdf : null; } /** * Delete opinions older than 6 months. */ export async function limpiarOpinionesAntiguas(pool: Pool): Promise { const { rowCount } = await pool.query( `DELETE FROM opiniones_cumplimiento WHERE fecha_consulta < NOW() - interval '6 months'` ); return rowCount ?? 0; } /** * Downloads and stores the Opinión de Cumplimiento for a specific contribuyente * (despacho mode). Uses FIEL stored in the tenant BD instead of the central BD. */ export async function consultarOpinionContribuyente( pool: Pool, contribuyenteId: string, ): Promise { const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); const fiel = await getDecryptedFielContribuyente(pool, safeId); if (!fiel) { throw new Error('No hay FIEL configurada para este contribuyente o está vencida'); } const tempId = randomUUID(); const tempDir = join(tmpdir(), `horux-fiel-${tempId}`); mkdirSync(tempDir, { recursive: true, mode: 0o700 }); const cerPath = join(tempDir, 'cert.cer'); const keyPath = join(tempDir, 'key.key'); try { writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 }); writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 }); const browser = await chromium.launch({ headless: true }); try { const context = await browser.newContext({ viewport: { width: 1280, height: 720 } }); const page = await context.newPage(); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout: proceso de opinión excedió 3 minutos')), PROCESS_TIMEOUT) ); const resultPromise = (async () => { const reportPage = await loginToSatOpinion(page, cerPath, keyPath, fiel.password); const pdfBuffer = await extractOpinionPdf(reportPage); const parsed = await parseOpinionPdf(pdfBuffer); const { rows } = await pool.query(` INSERT INTO opiniones_cumplimiento (rfc, razon_social, estatus, folio, cadena_original, fecha_consulta, pdf) VALUES ($1, $2, $3, $4, $5, NOW(), $6) RETURNING id, rfc, razon_social as "razonSocial", estatus, folio, cadena_original as "cadenaOriginal", fecha_consulta as "fechaConsulta", created_at as "createdAt" `, [parsed.rfc, parsed.razonSocial, parsed.estatus, parsed.folio, parsed.cadenaOriginal, pdfBuffer]); return rows[0] as OpinionCumplimiento; })(); return await Promise.race([resultPromise, timeoutPromise]); } finally { await browser.close(); } } finally { try { unlinkSync(cerPath); } catch { /* ok */ } try { unlinkSync(keyPath); } catch { /* ok */ } try { rmdirSync(tempDir); } catch { /* ok */ } } }