Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View 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 */ }
}
}