Initial commit - Horux Despachos NL
This commit is contained in:
185
apps/api/src/services/opinion-cumplimiento.service.ts
Normal file
185
apps/api/src/services/opinion-cumplimiento.service.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user