Files
HoruxDespachos/docs/superpowers/plans/2026-04-13-opinion-cumplimiento.md
2026-04-27 22:09:36 -06:00

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 documentos feature 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"