# 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`: ```typescript 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: ```typescript 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** ```bash 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`: ```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** ```bash pnpm --filter @horux/api db:migrate-tenants ``` Expected: All tenants get the new table. - [ ] **Step 3: Commit** ```bash 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** ```bash cd apps/api && pnpm add playwright pdf-parse ``` - [ ] **Step 2: Install Chromium browser** ```bash npx playwright install chromium ``` - [ ] **Step 3: Commit** ```bash 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`: ```typescript 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 { // 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** ```bash 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`: ```typescript 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 { 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: or 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 /'); 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** ```bash 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`: ```typescript 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 { 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** ```bash 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`: ```typescript 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 { 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((_, 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 { 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 { 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 { const { rowCount } = await pool.query( `DELETE FROM opiniones_cumplimiento WHERE fecha_consulta < NOW() - interval '6 months'` ); return rowCount ?? 0; } ``` - [ ] **Step 2: Commit** ```bash 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`: ```typescript 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`: ```typescript 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): ```typescript import { documentosRoutes } from './routes/documentos.routes.js'; ``` Route registration (after the last `app.use` route, before error middleware): ```typescript app.use('/api/documentos', documentosRoutes); ``` - [ ] **Step 4: Commit** ```bash 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: ```typescript import { consultarOpinion, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js'; import { tenantDb } from '../config/database.js'; ``` Add the opinion sync function before `startSatSyncJob()`: ```typescript const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM async function runOpinionJob(): Promise { 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`: ```typescript let opinionTask: ReturnType | null = null; ``` In `startSatSyncJob()`, after the retry cron setup, add: ```typescript 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: ```typescript if (opinionTask) { opinionTask.stop(); opinionTask = null; } ``` - [ ] **Step 2: Commit** ```bash 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()`: ```typescript /** * Alerta si la última Opinión de Cumplimiento no es Positiva. */ async function alertaOpinionCumplimiento(pool: Pool): Promise { 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)`: ```typescript alertaCancelacionPeriodoAnterior(pool), alertaOpinionCumplimiento(pool), ``` - [ ] **Step 3: Commit** ```bash 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`: ```typescript import { apiClient } from './client'; import type { OpinionCumplimiento } from '@horux/shared'; export async function getOpiniones(): Promise { const { data } = await apiClient.get('/documentos/opiniones'); return data; } export async function descargarOpinionPdf(id: number): Promise { const { data } = await apiClient.get(`/documentos/opiniones/${id}/pdf`, { responseType: 'blob', }); return data; } export async function consultarOpinion(): Promise { const { data } = await apiClient.post('/documentos/opiniones/consultar'); return data; } ``` - [ ] **Step 2: Create React Query hooks** Create `apps/web/lib/hooks/use-documentos.ts`: ```typescript 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** ```bash 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`: ```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 ( {estatus} ); } if (estatus === 'Negativa') { return ( {estatus} ); } return ( {estatus} ); } 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 (

Documentos

Documentos fiscales consultados ante el SAT

{isAdmin && ( )}
{consultar.isError && (
Error: {(consultar.error as Error).message}
)}

Opinión de Cumplimiento

{isLoading && (
)} {error && (
Error al cargar opiniones: {(error as Error).message}
)} {!isLoading && !error && opiniones?.length === 0 && (

No hay opiniones registradas.

La consulta automática se ejecuta cada semana.

)} {!isLoading && opiniones && opiniones.length > 0 && (
{opiniones.map((op) => ( ))}
Fecha de consulta Estatus Folio RFC PDF
{new Date(op.fechaConsulta).toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })}
{op.folio} {op.rfc}
)}
); } ``` - [ ] **Step 2: Add Documentos to sidebar** In `apps/web/components/layouts/sidebar.tsx`, add `FileCheck` to the lucide imports: ```typescript import { ..., FileCheck } from 'lucide-react'; ``` Add the nav item in the `navigation` array, after Facturación and before Usuarios: ```typescript { name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' }, ``` - [ ] **Step 3: Commit** ```bash 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** ```bash git add CLAUDE.md README.md git commit -m "docs: document Opinión de Cumplimiento integration" ```