186 lines
6.8 KiB
TypeScript
186 lines
6.8 KiB
TypeScript
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<OpinionCumplimiento> {
|
|
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<never>((_, 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<OpinionCumplimiento[]> {
|
|
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<Buffer | null> {
|
|
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<number> {
|
|
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<OpinionCumplimiento> {
|
|
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<never>((_, 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 */ }
|
|
}
|
|
}
|