37 KiB
Opinión de Cumplimiento Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Integrate automated SAT Opinión de Cumplimiento download into Horux360 with weekly cron, tenant DB storage, REST endpoints, auto-alert, and a new Documentos frontend page.
Architecture: Adapt the standalone Playwright prototype (sat-opinion-prototype) into Horux360's backend as new SAT opinion services. Store PDFs in tenant DB (bytea). Weekly cron downloads for all tenants with FIEL. Frontend shows last 5 opinions with status badges and PDF download.
Tech Stack: Playwright (Chromium headless), pdf-parse v2, existing Express/Prisma/pg stack, Next.js frontend
Task 1: Add shared types and plan feature
Files:
-
Create:
packages/shared/src/types/documentos.ts -
Modify:
packages/shared/src/index.ts -
Modify:
packages/shared/src/constants/plans.ts -
Step 1: Create shared types file
Create packages/shared/src/types/documentos.ts:
export interface OpinionCumplimiento {
id: number;
rfc: string;
razonSocial: string;
estatus: string;
folio: string;
cadenaOriginal: string;
fechaConsulta: string;
createdAt: string;
}
export interface OpinionCumplimientoFull extends OpinionCumplimiento {
pdf: Buffer;
}
- Step 2: Export from shared index
In packages/shared/src/index.ts, add after the last type export:
export * from './types/documentos';
- Step 3: Add
documentosfeature to business and enterprise plans
In packages/shared/src/constants/plans.ts, add 'documentos' to the features arrays:
-
business features: add
'documentos'after'xml_sat' -
enterprise features: add
'documentos'after'xml_sat' -
Step 4: Commit
git add packages/shared/src/types/documentos.ts packages/shared/src/index.ts packages/shared/src/constants/plans.ts
git commit -m "feat: add documentos shared types and plan feature"
Task 2: Create tenant DB migration
Files:
-
Create:
apps/api/src/migrations/tenant/002_create_opiniones_cumplimiento.sql -
Step 1: Create migration file
Create apps/api/src/migrations/tenant/002_create_opiniones_cumplimiento.sql:
-- 002_create_opiniones_cumplimiento
-- Table for storing SAT Opinión de Cumplimiento PDFs and metadata
CREATE TABLE IF NOT EXISTS opiniones_cumplimiento (
id SERIAL PRIMARY KEY,
rfc VARCHAR(14) NOT NULL,
razon_social VARCHAR(255),
estatus VARCHAR(50) NOT NULL,
folio VARCHAR(50),
cadena_original TEXT,
fecha_consulta TIMESTAMP NOT NULL,
pdf BYTEA NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_opiniones_fecha ON opiniones_cumplimiento(fecha_consulta DESC);
- Step 2: Run eager migration to apply to existing tenants
pnpm --filter @horux/api db:migrate-tenants
Expected: All tenants get the new table.
- Step 3: Commit
git add apps/api/src/migrations/tenant/002_create_opiniones_cumplimiento.sql
git commit -m "feat: add 002 migration for opiniones_cumplimiento table"
Task 3: Add Playwright and pdf-parse dependencies
Files:
-
Modify:
apps/api/package.json -
Step 1: Install dependencies
cd apps/api && pnpm add playwright pdf-parse
- Step 2: Install Chromium browser
npx playwright install chromium
- Step 3: Commit
git add apps/api/package.json ../../pnpm-lock.yaml
git commit -m "feat: add playwright and pdf-parse dependencies"
Task 4: Create SAT opinion login service
Files:
- Create:
apps/api/src/services/sat/sat-opinion-login.ts
This is adapted from the prototype's sat-login.ts. Key changes:
-
Accepts file paths (temp files from decrypted FIEL) instead of config object
-
No debug screenshots in production (controlled by NODE_ENV)
-
Returns the active page after login
-
Step 1: Create sat-opinion-login.ts
Create apps/api/src/services/sat/sat-opinion-login.ts:
import { type Page } from 'playwright';
const TIMEOUT = 60_000;
/**
* Navigates the SAT portal and authenticates with FIEL to reach the
* Opinión de Cumplimiento report page.
*
* Returns the active page (may be a new tab opened by SAT).
*/
export async function loginToSatOpinion(
page: Page,
cerPath: string,
keyPath: string,
password: string,
): Promise<Page> {
// Step 1: Navigate to SAT public page
const publicUrl = 'https://www.sat.gob.mx/portal/public/tramites/opinion-del-cumplimiento';
console.log('[SAT Opinion] Navigating to SAT public page...');
await page.goto(publicUrl, { waitUntil: 'domcontentloaded', timeout: TIMEOUT });
await page.waitForTimeout(2000);
// Step 2: Click "Obtén la Opinión del cumplimiento" tab
console.log('[SAT Opinion] Clicking "Obtén la Opinión del cumplimiento"...');
const obtenerOpcion = page.locator('text=Obt').first();
await obtenerOpcion.waitFor({ state: 'visible', timeout: TIMEOUT });
await obtenerOpcion.click();
await page.waitForTimeout(2000);
// Step 3: Expand "De tu empresa" accordion
console.log('[SAT Opinion] Expanding "De tu empresa" section...');
const empresaOption = page.locator('text=De tu empresa').first();
await empresaOption.waitFor({ state: 'visible', timeout: TIMEOUT });
await empresaOption.click();
await page.waitForTimeout(2000);
// Step 4: Click "Ingresa" link — opens new tab (target=_blank)
console.log('[SAT Opinion] Clicking "Ingresa" (opens new tab)...');
const ingresaLink = page.locator('a:has-text("Ingresa")').first();
await ingresaLink.waitFor({ state: 'visible', timeout: TIMEOUT });
const [loginPage] = await Promise.all([
page.context().waitForEvent('page', { timeout: TIMEOUT }),
ingresaLink.click(),
]);
await loginPage.waitForLoadState('domcontentloaded', { timeout: TIMEOUT });
await loginPage.waitForTimeout(2000);
// Step 5: Switch to e.firma login
console.log('[SAT Opinion] Clicking e.firma button...');
const efirmaButton = loginPage.locator('button:has-text("e.firma"), input[value*="firma"], a:has-text("e.firma")').first();
await efirmaButton.waitFor({ state: 'visible', timeout: TIMEOUT });
await efirmaButton.click();
await loginPage.waitForLoadState('domcontentloaded', { timeout: TIMEOUT });
await loginPage.waitForTimeout(2000);
// Step 6: Upload .cer
console.log('[SAT Opinion] Uploading .cer...');
const cerInput = loginPage.locator('input[type="file"]').first();
await cerInput.waitFor({ state: 'attached', timeout: TIMEOUT });
await cerInput.setInputFiles(cerPath);
await loginPage.waitForTimeout(500);
// Step 7: Upload .key
console.log('[SAT Opinion] Uploading .key...');
const keyInput = loginPage.locator('input[type="file"]').nth(1);
await keyInput.waitFor({ state: 'attached', timeout: TIMEOUT });
await keyInput.setInputFiles(keyPath);
await loginPage.waitForTimeout(500);
// Step 8: Enter password
console.log('[SAT Opinion] Entering password...');
const passwordInput = loginPage.locator('input[type="password"]').first();
await passwordInput.waitFor({ state: 'visible', timeout: TIMEOUT });
await passwordInput.fill(password);
// Step 9: Submit
console.log('[SAT Opinion] Submitting login...');
const submitButton = loginPage.locator('button:has-text("Enviar"), input[value="Enviar"], a:has-text("Enviar"), input[type="submit"], button[type="submit"]').first();
await submitButton.waitFor({ state: 'visible', timeout: TIMEOUT });
await submitButton.click();
// Step 10: Wait for auth + redirect to report
console.log('[SAT Opinion] Waiting for authentication...');
await loginPage.waitForTimeout(8000);
const currentUrl = loginPage.url();
if (!currentUrl.includes('reporteOpinion32DContribuyente')) {
const baseUrl = currentUrl.replace(/#.*$/, '').replace(/\?.*$/, '');
const reporteUrl = baseUrl + '#/reporteOpinion32DContribuyente';
console.log(`[SAT Opinion] Navigating to report: ${reporteUrl}`);
await loginPage.goto(reporteUrl, { waitUntil: 'domcontentloaded', timeout: TIMEOUT });
await loginPage.waitForTimeout(5000);
}
console.log('[SAT Opinion] Login completed.');
return loginPage;
}
- Step 2: Commit
git add apps/api/src/services/sat/sat-opinion-login.ts
git commit -m "feat: add SAT opinion login service (Playwright)"
Task 5: Create SAT opinion scraper service
Files:
- Create:
apps/api/src/services/sat/sat-opinion-scraper.ts
Adapted from prototype's opinion-scraper.ts. Simplified: no config dependency, no screenshots, returns Buffer directly.
- Step 1: Create sat-opinion-scraper.ts
Create apps/api/src/services/sat/sat-opinion-scraper.ts:
import { type Page } from 'playwright';
/**
* Extracts the Opinión de Cumplimiento PDF from the SAT Angular SPA.
* Uses 4 strategies to find the base64-encoded PDF in the DOM.
* Returns the raw PDF buffer.
*/
export async function extractOpinionPdf(page: Page): Promise<Buffer> {
const TIMEOUT = 120_000;
const POLL_INTERVAL = 3_000;
console.log('[SAT Opinion Scraper] Waiting for PDF to appear...');
// Set up network interception for PDF responses
let interceptedPdf: Buffer | null = null;
page.on('response', async (response) => {
try {
const contentType = response.headers()['content-type'] || '';
if (contentType.includes('application/pdf') || response.url().endsWith('.pdf')) {
const body = await response.body();
if (body.length > 100) {
interceptedPdf = body;
console.log(`[SAT Opinion Scraper] PDF intercepted via network: ${body.length} bytes`);
}
}
} catch { /* response body may not be available */ }
});
const startTime = Date.now();
while (Date.now() - startTime < TIMEOUT) {
// Check network interception first
if (interceptedPdf) return interceptedPdf;
// Strategy 1: <embed> or <object> with PDF data URI
const embedData = await page.evaluate(() => {
for (const el of document.querySelectorAll('embed, object')) {
const src = el.getAttribute('src') || el.getAttribute('data') || '';
if (src.startsWith('data:application/pdf;base64,')) return src;
}
return null;
}).catch(() => null);
if (embedData) {
console.log('[SAT Opinion Scraper] PDF found via <embed>/<object>');
return decodeDataUri(embedData);
}
// Strategy 2: Scan full HTML for base64 PDF
const html = await page.content().catch(() => '');
const match = html.match(/data:application\/pdf;base64,([A-Za-z0-9+/=\s]+)/);
if (match) {
console.log('[SAT Opinion Scraper] PDF found via page content scan');
return decodeDataUri(`data:application/pdf;base64,${match[1]}`);
}
// Strategy 3: Check iframes
for (const frame of page.frames()) {
try {
const frameUrl = frame.url();
if (frameUrl.startsWith('data:application/pdf;base64,')) {
console.log('[SAT Opinion Scraper] PDF found via iframe URL');
return decodeDataUri(frameUrl);
}
const frameHtml = await frame.content();
const frameMatch = frameHtml.match(/data:application\/pdf;base64,([A-Za-z0-9+/=\s]+)/);
if (frameMatch) {
console.log('[SAT Opinion Scraper] PDF found via iframe content');
return decodeDataUri(`data:application/pdf;base64,${frameMatch[1]}`);
}
} catch { /* cross-origin frame */ }
}
// Strategy 4: Page URL itself
if (page.url().startsWith('data:application/pdf;base64,')) {
console.log('[SAT Opinion Scraper] PDF found via page URL');
return decodeDataUri(page.url());
}
console.log(`[SAT Opinion Scraper] PDF not found, retrying in ${POLL_INTERVAL / 1000}s...`);
await page.waitForTimeout(POLL_INTERVAL);
}
throw new Error(`PDF not found after ${TIMEOUT / 1000}s`);
}
function decodeDataUri(dataUri: string): Buffer {
const prefix = 'data:application/pdf;base64,';
const base64 = dataUri.substring(prefix.length).replace(/\s/g, '');
return Buffer.from(base64, 'base64');
}
- Step 2: Commit
git add apps/api/src/services/sat/sat-opinion-scraper.ts
git commit -m "feat: add SAT opinion PDF scraper service"
Task 6: Create SAT opinion PDF parser service
Files:
- Create:
apps/api/src/services/sat/sat-opinion-parser.ts
Adapted from prototype's pdf-parser.ts.
- Step 1: Create sat-opinion-parser.ts
Create apps/api/src/services/sat/sat-opinion-parser.ts:
import { PDFParse } from 'pdf-parse';
export interface ParsedOpinion {
rfc: string;
razonSocial: string;
estatus: string;
folio: string;
cadenaOriginal: string;
fechaConsulta: string;
}
/**
* Parses the SAT Opinión de Cumplimiento PDF and extracts structured data.
* The PDF layout uses line-header / line-value format (NOT key:value).
*/
export async function parseOpinionPdf(pdfBuffer: Buffer): Promise<ParsedOpinion> {
const pdfParse = new PDFParse({ data: new Uint8Array(pdfBuffer) });
try {
const textResult = await pdfParse.getText();
const text = textResult.text;
return {
rfc: extractRfc(text),
razonSocial: extractRazonSocial(text),
estatus: extractEstatus(text),
folio: extractFolio(text),
cadenaOriginal: extractCadenaOriginal(text),
fechaConsulta: extractFecha(text),
};
} finally {
await pdfParse.destroy();
}
}
function extractRfc(text: string): string {
const match = text.match(/RFC\s+Folio\s*\n\s*([A-ZÑ&]{3,4}\d{6}[A-Z\d]{3})/i);
if (match) return match[1].trim();
const fallback = text.match(/\b([A-ZÑ&]{3,4}\d{6}[A-Z\d]{3})\b/);
return fallback ? fallback[1] : 'NO_ENCONTRADO';
}
function extractRazonSocial(text: string): string {
const match = text.match(/(?:Nombre|denominaci[oó]n|raz[oó]n social)\s+Sentido\s*\n\s*(.+)/i);
if (match) {
return match[1].trim().replace(/\s+(POSITIVO|NEGATIVO|EN SUSPENSI[OÓ]N|NO INSCRITO|INSCRITO SIN OBLIGACIONES)\s*$/i, '').trim();
}
return 'NO_ENCONTRADO';
}
function extractEstatus(text: string): string {
const match = text.match(/Sentido\s*\n\s*.+\s+(POSITIVO|NEGATIVO|EN SUSPENSI[OÓ]N|NO INSCRITO|INSCRITO SIN OBLIGACIONES)\s*$/im);
if (match) {
const raw = match[1].trim().toUpperCase();
if (raw === 'POSITIVO') return 'Positiva';
if (raw === 'NEGATIVO') return 'Negativa';
if (raw.includes('SUSPENSI')) return 'En suspensión';
if (raw.includes('NO INSCRITO')) return 'No inscrito';
if (raw.includes('SIN OBLIGACIONES')) return 'Inscrito sin obligaciones';
}
if (/POSITIVO/i.test(text)) return 'Positiva';
if (/NEGATIVO/i.test(text)) return 'Negativa';
return 'NO_DETERMINADO';
}
function extractFolio(text: string): string {
const match = text.match(/RFC\s+Folio\s*\n\s*[A-ZÑ&]{3,4}\d{6}[A-Z\d]{3}\s+(\S+)/i);
return match ? match[1].trim() : 'NO_ENCONTRADO';
}
function extractCadenaOriginal(text: string): string {
const match = text.match(/Cadena Original\s*\n\s*(\|\|.+\|\|)/i);
return match ? match[1].trim() : 'NO_ENCONTRADO';
}
function extractFecha(text: string): string {
const match = text.match(/Fecha\s+y\s+hora\s+de\s+emisi[oó]n\s*\n\s*(.+)/i);
if (match) return match[1].trim();
const fallback = text.match(/(\d{1,2}\s+de\s+\w+\s+de\s+\d{4}\s+a\s+las\s+[\d:]+\s+horas)/i);
return fallback ? fallback[1].trim() : 'NO_ENCONTRADO';
}
- Step 2: Commit
git add apps/api/src/services/sat/sat-opinion-parser.ts
git commit -m "feat: add SAT opinion PDF parser service"
Task 7: Create opinion-cumplimiento orchestration service
Files:
- Create:
apps/api/src/services/opinion-cumplimiento.service.ts
This is the main service that ties everything together: decrypt FIEL → temp files → Playwright → scrape → parse → save to DB → cleanup.
- Step 1: Create opinion-cumplimiento.service.ts
Create apps/api/src/services/opinion-cumplimiento.service.ts:
import { chromium } from 'playwright';
import { writeFileSync, unlinkSync, mkdirSync } 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 { 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.
* Decrypts FIEL → writes temp files → Playwright login → extract PDF → parse → save to DB.
*/
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');
}
// Write temp files with restricted permissions
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 });
// Launch Playwright
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
});
const page = await context.newPage();
// Set a global timeout
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout: proceso de opinión excedió 3 minutos')), PROCESS_TIMEOUT)
);
const resultPromise = (async () => {
// Login and navigate to report
const reportPage = await loginToSatOpinion(page, cerPath, keyPath, fiel.password);
// Extract PDF
const pdfBuffer = await extractOpinionPdf(reportPage);
// Parse PDF
const parsed = await parseOpinionPdf(pdfBuffer);
// Get tenant pool and save to DB
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 {
// Cleanup temp files — guaranteed
try { unlinkSync(cerPath); } catch { /* already deleted or never created */ }
try { unlinkSync(keyPath); } catch { /* already deleted or never created */ }
try { const { rmdirSync } = await import('fs'); rmdirSync(tempDir); } catch { /* ignore */ }
}
}
/**
* Get last N opinions for a tenant (metadata only, no PDF).
*/
export async function getOpiniones(pool: Pool, limit = 5): Promise<OpinionCumplimiento[]> {
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
ORDER BY fecha_consulta DESC
LIMIT $1
`, [limit]);
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;
}
- Step 2: Commit
git add apps/api/src/services/opinion-cumplimiento.service.ts
git commit -m "feat: add opinion-cumplimiento orchestration service"
Task 8: Create documentos controller and routes
Files:
-
Create:
apps/api/src/controllers/documentos.controller.ts -
Create:
apps/api/src/routes/documentos.routes.ts -
Modify:
apps/api/src/app.ts -
Step 1: Create documentos.controller.ts
Create apps/api/src/controllers/documentos.controller.ts:
import type { Request, Response, NextFunction } from 'express';
import { getOpiniones, getOpinionPdf, consultarOpinion } from '../services/opinion-cumplimiento.service.js';
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
/**
* GET /api/documentos/opiniones — Last 5 opinions (metadata only)
*/
export async function listarOpiniones(req: Request, res: Response, next: NextFunction) {
try {
const opiniones = await getOpiniones(req.tenantPool!);
res.json(opiniones);
} catch (error) {
next(error);
}
}
/**
* GET /api/documentos/opiniones/:id/pdf — Download PDF binary
*/
export async function descargarPdf(req: Request, res: Response, next: NextFunction) {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'ID inválido' });
const pdf = await getOpinionPdf(req.tenantPool!, id);
if (!pdf) return res.status(404).json({ error: 'Opinión no encontrada' });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="opinion_cumplimiento_${id}.pdf"`);
res.send(pdf);
} catch (error) {
next(error);
}
}
/**
* POST /api/documentos/opiniones/consultar — Trigger manual download (admin only)
*/
export async function consultarManual(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = effectiveTenantId(req);
const opinion = await consultarOpinion(tenantId);
res.json(opinion);
} catch (error: any) {
if (error.message?.includes('FIEL')) {
return res.status(400).json({ error: error.message });
}
next(error);
}
}
- Step 2: Create documentos.routes.ts
Create apps/api/src/routes/documentos.routes.ts:
import { Router, type IRouter } from 'express';
import { authenticate, authorize } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
import * as documentosController from '../controllers/documentos.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.use(requireFeature('documentos'));
// Opinión de Cumplimiento
router.get('/opiniones', documentosController.listarOpiniones);
router.get('/opiniones/:id/pdf', documentosController.descargarPdf);
router.post('/opiniones/consultar', authorize('admin'), documentosController.consultarManual);
export { router as documentosRoutes };
- Step 3: Register routes in app.ts
In apps/api/src/app.ts, add the import and route registration:
Import (after the last route import):
import { documentosRoutes } from './routes/documentos.routes.js';
Route registration (after the last app.use route, before error middleware):
app.use('/api/documentos', documentosRoutes);
- Step 4: Commit
git add apps/api/src/controllers/documentos.controller.ts apps/api/src/routes/documentos.routes.ts apps/api/src/app.ts
git commit -m "feat: add documentos controller, routes, and register in app"
Task 9: Add weekly cron job and retention cleanup
Files:
-
Modify:
apps/api/src/jobs/sat-sync.job.ts -
Step 1: Add opinion cron to sat-sync.job.ts
Add imports at the top:
import { consultarOpinion, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js';
import { tenantDb } from '../config/database.js';
Add the opinion sync function before startSatSyncJob():
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
async function runOpinionJob(): Promise<void> {
console.log('[Opinion Cron] Iniciando descarga semanal de Opinión de Cumplimiento');
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
let success = 0;
let failed = 0;
let skipped = 0;
for (const tenant of tenants) {
const hasFiel = await hasFielConfigured(tenant.id);
if (!hasFiel) {
skipped++;
continue;
}
try {
console.log(`[Opinion Cron] Consultando opinión para ${tenant.rfc}...`);
await consultarOpinion(tenant.id);
success++;
// Cleanup old records
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const deleted = await limpiarOpinionesAntiguas(pool);
if (deleted > 0) {
console.log(`[Opinion Cron] ${tenant.rfc}: ${deleted} opiniones antiguas eliminadas`);
}
} catch (error: any) {
console.error(`[Opinion Cron] Error para ${tenant.rfc}:`, error.message);
failed++;
}
}
console.log(`[Opinion Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`);
}
Add opinionTask variable next to retryTask:
let opinionTask: ReturnType<typeof cron.schedule> | null = null;
In startSatSyncJob(), after the retry cron setup, add:
opinionTask = cron.schedule(OPINION_CRON_SCHEDULE, async () => {
try {
await runOpinionJob();
} catch (error: any) {
console.error('[Opinion Cron] Error:', error.message);
}
}, {
timezone: 'America/Mexico_City',
});
console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
In stopSatSyncJob(), add:
if (opinionTask) {
opinionTask.stop();
opinionTask = null;
}
- Step 2: Commit
git add apps/api/src/jobs/sat-sync.job.ts
git commit -m "feat: add weekly cron for opinion de cumplimiento download"
Task 10: Add auto-alert for non-Positiva status
Files:
-
Modify:
apps/api/src/services/alertas-auto.service.ts -
Step 1: Add alertaOpinionCumplimiento function
Add before generarAlertasAutomaticas():
/**
* Alerta si la última Opinión de Cumplimiento no es Positiva.
*/
async function alertaOpinionCumplimiento(pool: Pool): Promise<AlertaAuto | null> {
const { rows } = await pool.query(`
SELECT estatus, fecha_consulta
FROM opiniones_cumplimiento
ORDER BY fecha_consulta DESC
LIMIT 1
`);
if (rows.length === 0) return null;
const { estatus, fecha_consulta } = rows[0];
if (estatus === 'Positiva') return null;
const fecha = new Date(fecha_consulta).toLocaleDateString('es-MX');
return {
id: 'opinion-cumplimiento-negativa',
tipo: 'opinion-cumplimiento',
titulo: `Opinión de Cumplimiento: ${estatus}`,
mensaje: `Tu Opinión de Cumplimiento ante el SAT es ${estatus}. Última consulta: ${fecha}. Revisa tus obligaciones fiscales.`,
prioridad: 'alta',
valor: 1,
};
}
- Step 2: Register in generarAlertasAutomaticas
Add alertaOpinionCumplimiento(pool) to the Promise.all array, after alertaCancelacionPeriodoAnterior(pool):
alertaCancelacionPeriodoAnterior(pool),
alertaOpinionCumplimiento(pool),
- Step 3: Commit
git add apps/api/src/services/alertas-auto.service.ts
git commit -m "feat: add auto-alert for non-Positiva opinion de cumplimiento"
Task 11: Create frontend API client and hooks
Files:
-
Create:
apps/web/lib/api/documentos.ts -
Create:
apps/web/lib/hooks/use-documentos.ts -
Step 1: Create API client
Create apps/web/lib/api/documentos.ts:
import { apiClient } from './client';
import type { OpinionCumplimiento } from '@horux/shared';
export async function getOpiniones(): Promise<OpinionCumplimiento[]> {
const { data } = await apiClient.get('/documentos/opiniones');
return data;
}
export async function descargarOpinionPdf(id: number): Promise<Blob> {
const { data } = await apiClient.get(`/documentos/opiniones/${id}/pdf`, {
responseType: 'blob',
});
return data;
}
export async function consultarOpinion(): Promise<OpinionCumplimiento> {
const { data } = await apiClient.post('/documentos/opiniones/consultar');
return data;
}
- Step 2: Create React Query hooks
Create apps/web/lib/hooks/use-documentos.ts:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getOpiniones, descargarOpinionPdf, consultarOpinion } from '../api/documentos';
import { useTenantViewStore } from '../../stores/tenant-view-store';
export function useOpiniones() {
const viewingTenantId = useTenantViewStore((s) => s.viewingTenantId);
return useQuery({
queryKey: ['opiniones', viewingTenantId],
queryFn: getOpiniones,
});
}
export function useConsultarOpinion() {
const queryClient = useQueryClient();
const viewingTenantId = useTenantViewStore((s) => s.viewingTenantId);
return useMutation({
mutationFn: consultarOpinion,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['opiniones', viewingTenantId] });
},
});
}
export function useDescargarPdf() {
return useMutation({
mutationFn: async (id: number) => {
const blob = await descargarOpinionPdf(id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `opinion_cumplimiento_${id}.pdf`;
a.click();
URL.revokeObjectURL(url);
},
});
}
- Step 3: Commit
git add apps/web/lib/api/documentos.ts apps/web/lib/hooks/use-documentos.ts
git commit -m "feat: add documentos API client and React Query hooks"
Task 12: Create Documentos frontend page
Files:
-
Create:
apps/web/app/(dashboard)/documentos/page.tsx -
Modify:
apps/web/components/layouts/sidebar.tsx -
Step 1: Create Documentos page
Create apps/web/app/(dashboard)/documentos/page.tsx:
'use client';
import { useOpiniones, useConsultarOpinion, useDescargarPdf } from '../../../lib/hooks/use-documentos';
import { useAuthStore } from '../../../stores/auth-store';
import { FileCheck, Download, RefreshCw, Loader2, AlertTriangle, CheckCircle2, XCircle, Clock } from 'lucide-react';
function EstatusBadge({ estatus }: { estatus: string }) {
if (estatus === 'Positiva') {
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<CheckCircle2 className="h-3 w-3" /> {estatus}
</span>
);
}
if (estatus === 'Negativa') {
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">
<XCircle className="h-3 w-3" /> {estatus}
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
<AlertTriangle className="h-3 w-3" /> {estatus}
</span>
);
}
export default function DocumentosPage() {
const { data: opiniones, isLoading, error } = useOpiniones();
const consultar = useConsultarOpinion();
const descargar = useDescargarPdf();
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'admin';
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Documentos</h1>
<p className="text-sm text-muted-foreground mt-1">Documentos fiscales consultados ante el SAT</p>
</div>
{isAdmin && (
<button
onClick={() => consultar.mutate()}
disabled={consultar.isPending}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 text-sm font-medium"
>
{consultar.isPending ? (
<><Loader2 className="h-4 w-4 animate-spin" /> Consultando...</>
) : (
<><RefreshCw className="h-4 w-4" /> Consultar ahora</>
)}
</button>
)}
</div>
{consultar.isError && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">
Error: {(consultar.error as Error).message}
</div>
)}
<div>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<FileCheck className="h-5 w-5" /> Opinión de Cumplimiento
</h2>
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">
Error al cargar opiniones: {(error as Error).message}
</div>
)}
{!isLoading && !error && opiniones?.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<FileCheck className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>No hay opiniones registradas.</p>
<p className="text-sm mt-1">La consulta automática se ejecuta cada semana.</p>
</div>
)}
{!isLoading && opiniones && opiniones.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 font-medium">Fecha de consulta</th>
<th className="pb-2 font-medium">Estatus</th>
<th className="pb-2 font-medium">Folio</th>
<th className="pb-2 font-medium">RFC</th>
<th className="pb-2 font-medium text-right">PDF</th>
</tr>
</thead>
<tbody className="divide-y">
{opiniones.map((op) => (
<tr key={op.id} className="hover:bg-muted/50">
<td className="py-3">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
{new Date(op.fechaConsulta).toLocaleDateString('es-MX', {
year: 'numeric', month: 'long', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</div>
</td>
<td className="py-3"><EstatusBadge estatus={op.estatus} /></td>
<td className="py-3 font-mono text-xs">{op.folio}</td>
<td className="py-3 font-mono text-xs">{op.rfc}</td>
<td className="py-3 text-right">
<button
onClick={() => descargar.mutate(op.id)}
disabled={descargar.isPending}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs hover:bg-muted"
>
<Download className="h-3.5 w-3.5" /> Descargar
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
- Step 2: Add Documentos to sidebar
In apps/web/components/layouts/sidebar.tsx, add FileCheck to the lucide imports:
import { ..., FileCheck } from 'lucide-react';
Add the nav item in the navigation array, after Facturación and before Usuarios:
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
- Step 3: Commit
git add apps/web/app/\(dashboard\)/documentos/page.tsx apps/web/components/layouts/sidebar.tsx
git commit -m "feat: add Documentos page with Opinión de Cumplimiento UI"
Task 13: Update documentation
Files:
-
Modify:
CLAUDE.md -
Modify:
README.md -
Step 1: Update CLAUDE.md
Add a new section after "Calendario y Recordatorios" documenting:
-
Documentos page and Opinión de Cumplimiento
-
Files involved (service, controller, routes, cron)
-
Weekly cron schedule
-
FIEL security (temp files, cleanup)
-
Data retention (6 months DB, 5 shown)
-
Step 2: Update README.md changelog
Add entry for this feature in the changelog.
- Step 3: Commit
git add CLAUDE.md README.md
git commit -m "docs: document Opinión de Cumplimiento integration"