diff --git a/apps/api/.env.example b/apps/api/.env.example index e98e866..89274e1 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -49,6 +49,17 @@ SMTP_FROM=Horux360 # ----- Notificaciones admin -------------------------------------------------- ADMIN_EMAIL=carlos@horuxfin.com # destino de "nuevo cliente" + alertas internas +# ----- Monitoreo sincronización SAT ------------------------------------------ +# Email separado para alertas de SAT (fallos, jobs atorados, FIEL sin sync inicial). +# Si no se configura, usa ADMIN_EMAIL. +SAT_ALERT_EMAIL= +# Cron del monitor (default: cada 2 horas). Ej: 0 8 * * * para digest diario 8 AM. +SAT_MONITOR_SCHEDULE=0 */2 * * * +# Horas para considerar un job running/pending como atorado (default 2h). +SAT_STUCK_RUNNING_HOURS=2 +# Ventana hacia atrás para reportar jobs fallados (default 24h). +SAT_FAILED_LOOKBACK_HOURS=24 + # ----- Facturapi (emisión CFDI) — opcional ----------------------------------- # Sin esto, los tenants no pueden emitir facturas, pero la app arranca. FACTURAPI_USER_KEY= # sk_user_... (cuenta maestra Horux 360) diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 93a3a0a..79c564c 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -53,6 +53,12 @@ const envSchema = z.object({ // Admin notification email ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'), + // SAT sync monitoring alerts (optional; falls back to ADMIN_EMAIL) + SAT_ALERT_EMAIL: z.string().email().optional(), + SAT_MONITOR_SCHEDULE: z.string().default('0 */2 * * *'), + SAT_STUCK_RUNNING_HOURS: z.string().transform(v => parseInt(v, 10)).default('2'), + SAT_FAILED_LOOKBACK_HOURS: z.string().transform(v => parseInt(v, 10)).default('24'), + // Facturapi FACTURAPI_USER_KEY: z.string().optional(), diff --git a/apps/api/src/controllers/contribuyente.controller.ts b/apps/api/src/controllers/contribuyente.controller.ts index 343a985..75b9cb0 100644 --- a/apps/api/src/controllers/contribuyente.controller.ts +++ b/apps/api/src/controllers/contribuyente.controller.ts @@ -93,7 +93,7 @@ export async function create(req: Request, res: Response, next: NextFunction) { } } - const row = await contribuyenteService.createContribuyente(req.tenantPool!, data); + const { row, reactivated } = await contribuyenteService.createContribuyente(req.tenantPool!, data); // Si se asignó un supervisor, agregar el contribuyente a todas las carteras // top-level de ese supervisor para que aparezca directamente en su vista. @@ -119,7 +119,7 @@ export async function create(req: Request, res: Response, next: NextFunction) { console.error('[Contribuyente] Overage adjust failed (non-blocking):', err.message || err); } - return res.status(201).json({ ...row, overage }); + return res.status(reactivated ? 200 : 201).json({ ...row, reactivated, overage }); } catch (err: any) { if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); if (err.code === '23505') return next(new AppError(409, 'Ya existe un contribuyente con este RFC')); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b4e136d..6a6c1a1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -6,6 +6,7 @@ import { startSatSyncJob } from './jobs/sat-sync.job.js'; import { startWeeklyUpdateJob } from './jobs/weekly-update.job.js'; import { startMetricasInvalidationsJob } from './jobs/metricas-invalidations.job.js'; import { startNotificationsJob } from './jobs/notifications.job.js'; +import { startSatSyncMonitorJob } from './jobs/sat-sync-monitor.job.js'; const PORT = parseInt(env.PORT, 10); @@ -23,13 +24,14 @@ const server = app.listen(PORT, '0.0.0.0', () => { if (cronsEnabled) { startSatSyncJob(); startMetricasInvalidationsJob(); + startSatSyncMonitorJob(); if (sendRealEmails) { startWeeklyUpdateJob(); startNotificationsJob(); } else { console.log('[Cron] weekly-update + notifications omitidos en dev (evita emails reales)'); } - console.log(`[Cron] SAT + metricas activos (NODE_ENV=${env.NODE_ENV}, ENABLE_CRONS_IN_DEV=${process.env.ENABLE_CRONS_IN_DEV ?? 'unset'})`); + console.log(`[Cron] SAT + metricas + SAT monitor activos (NODE_ENV=${env.NODE_ENV}, ENABLE_CRONS_IN_DEV=${process.env.ENABLE_CRONS_IN_DEV ?? 'unset'})`); } else { console.log('[Cron] Jobs omitidos en dev (usar ENABLE_CRONS_IN_DEV=1 para activar)'); } diff --git a/apps/api/src/jobs/sat-sync-monitor.job.ts b/apps/api/src/jobs/sat-sync-monitor.job.ts new file mode 100644 index 0000000..72621e7 --- /dev/null +++ b/apps/api/src/jobs/sat-sync-monitor.job.ts @@ -0,0 +1,303 @@ +import cron from 'node-cron'; +import { prisma, tenantDb } from '../config/database.js'; +import { env } from '../config/env.js'; +import { emailService } from '../services/email/email.service.js'; +import { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js'; +import type { SatSyncAlertData } from '../services/email/templates/sat-sync-alert.js'; + +let monitorTask: ReturnType | null = null; + +interface TenantInfo { + id: string; + rfc: string; + nombre: string; + databaseName: string | null; +} + +interface ContribuyenteInfo { + id: string; + rfc: string; + nombre: string; +} + +function hoursAgo(hours: number): Date { + return new Date(Date.now() - hours * 60 * 60 * 1000); +} + +async function loadActiveTenants(): Promise> { + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true, rfc: true, nombre: true, databaseName: true }, + }); + const map = new Map(); + for (const t of tenants) { + map.set(t.id, t); + } + return map; +} + +async function loadContribuyentesForTenant(tenant: TenantInfo): Promise> { + const map = new Map(); + if (!tenant.databaseName) return map; + try { + const pool = await tenantDb.getPool(tenant.id, tenant.databaseName); + const { rows } = await pool.query(` + SELECT c.entidad_id AS id, c.rfc, eg.nombre + FROM contribuyentes c + JOIN entidades_gestionadas eg ON eg.id = c.entidad_id + `); + for (const r of rows) { + map.set(r.id, { id: r.id, rfc: r.rfc, nombre: r.nombre }); + } + } catch (err: any) { + console.error(`[SAT Monitor] Error cargando contribuyentes para tenant ${tenant.rfc}:`, err.message); + } + return map; +} + +async function findFailedJobs(lookbackHours: number) { + const cutoff = hoursAgo(lookbackHours); + return prisma.satSyncJob.findMany({ + where: { + status: 'failed', + completedAt: { gte: cutoff }, + }, + orderBy: { completedAt: 'desc' }, + }); +} + +async function findPendingOldJobs(pendingHours: number) { + const cutoff = hoursAgo(pendingHours); + return prisma.satSyncJob.findMany({ + where: { + status: 'pending', + createdAt: { lte: cutoff }, + OR: [ + { nextRetryAt: null }, + { nextRetryAt: { lte: new Date() } }, + ], + }, + orderBy: { createdAt: 'asc' }, + }); +} + +async function findMissingInitialSync(tenants: Map): Promise { + const missing: SatSyncAlertData['missingInitial'] = []; + + for (const tenant of tenants.values()) { + if (!tenant.databaseName) continue; + try { + const pool = await tenantDb.getPool(tenant.id, tenant.databaseName); + const { rows: contribuyentes } = await pool.query(` + SELECT c.entidad_id AS id, c.rfc, eg.nombre + FROM contribuyentes c + JOIN entidades_gestionadas eg ON eg.id = c.entidad_id + JOIN fiel_contribuyente f ON f.contribuyente_id = c.entidad_id + WHERE f.is_active = true + AND f.valid_until >= NOW() + ORDER BY eg.nombre + `); + + if (contribuyentes.length === 0) continue; + + const contribuyenteIds = contribuyentes.map((c: any) => c.id); + const initialJobs = await prisma.satSyncJob.findMany({ + where: { + tenantId: tenant.id, + contribuyenteId: { in: contribuyenteIds }, + type: 'initial', + status: { in: ['completed', 'running', 'pending'] }, + }, + select: { contribuyenteId: true, status: true }, + }); + const completedOrInProgressIds = new Set(initialJobs.map(j => j.contribuyenteId)); + + for (const c of contribuyentes) { + if (!completedOrInProgressIds.has(c.id)) { + missing.push({ + tenantName: tenant.nombre, + tenantRfc: tenant.rfc, + contribuyenteName: c.nombre, + contribuyenteRfc: c.rfc, + }); + } + } + } catch (err: any) { + console.error(`[SAT Monitor] Error revisando FIEL sin sync inicial para tenant ${tenant.rfc}:`, err.message); + } + } + + return missing; +} + +function buildAlertData( + tenants: Map, + contribuyenteMaps: Map>, + failed: Awaited>, + stale: Array<{ id: string; tenantId: string; kind: 'pending-stale' | 'running-stale'; ageHours: number }>, + staleJobsById: Map>>, + pendingOld: Awaited>, + missingInitial: SatSyncAlertData['missingInitial'] +): SatSyncAlertData { + const now = new Date(); + const generatedAt = now.toLocaleString('es-MX', { timeZone: 'America/Mexico_City' }); + + const resolveJob = (job: { tenantId: string; contribuyenteId: string | null }) => { + const tenant = tenants.get(job.tenantId); + const contribMap = contribuyenteMaps.get(job.tenantId); + const contrib = job.contribuyenteId ? contribMap?.get(job.contribuyenteId) : undefined; + return { + tenantName: tenant?.nombre || job.tenantId, + tenantRfc: tenant?.rfc || '—', + contribuyenteName: contrib?.nombre || null, + contribuyenteRfc: contrib?.rfc || null, + }; + }; + + return { + generatedAt, + recipient: env.SAT_ALERT_EMAIL ?? env.ADMIN_EMAIL, + summary: { + failed: failed.length, + stale: stale.length, + stuckRunning: 0, + pendingOld: pendingOld.length, + missingInitial: missingInitial.length, + }, + failed: failed.map(j => ({ + ...resolveJob(j), + type: j.type, + errorMessage: j.errorMessage, + completedAt: j.completedAt, + })), + stale: stale.map(e => { + const job = staleJobsById.get(e.id); + return { + id: e.id, + ...resolveJob({ tenantId: e.tenantId, contribuyenteId: job?.contribuyenteId ?? null }), + type: job?.type || '—', + kind: e.kind, + ageHours: e.ageHours, + }; + }), + stuckRunning: [], + pendingOld: pendingOld.map(j => ({ + id: j.id, + ...resolveJob(j), + type: j.type, + createdAt: j.createdAt, + nextRetryAt: j.nextRetryAt, + hoursPending: Math.round((now.getTime() - j.createdAt.getTime()) / 3_600_000), + })), + missingInitial, + }; +} + +export async function runSatSyncMonitor(): Promise { + console.log('[SAT Monitor] Iniciando revisión de sincronizaciones SAT'); + + const pendingHours = env.SAT_STUCK_RUNNING_HOURS; + const failedLookbackHours = env.SAT_FAILED_LOOKBACK_HOURS; + + try { + const tenants = await loadActiveTenants(); + + const [failed, staleResult, pendingOld, missingInitial] = await Promise.all([ + findFailedJobs(failedLookbackHours), + sweepStaleSatJobs({ apply: false }), + findPendingOldJobs(pendingHours), + findMissingInitialSync(tenants), + ]); + + const jobTenantIds = new Set(); + for (const j of [...failed, ...pendingOld]) { + jobTenantIds.add(j.tenantId); + } + for (const e of staleResult.entries) { + jobTenantIds.add(e.tenantId); + } + + const staleJobsById = new Map( + ( + await prisma.satSyncJob.findMany({ + where: { id: { in: staleResult.entries.map(e => e.id) } }, + }) + ).map(j => [j.id, j]) + ); + + for (const e of staleResult.entries) { + const job = staleJobsById.get(e.id); + if (job?.contribuyenteId && job.tenantId) { + jobTenantIds.add(job.tenantId); + } + } + + const contribuyenteMaps = new Map>(); + for (const tenantId of jobTenantIds) { + const tenant = tenants.get(tenantId); + if (!tenant) continue; + const map = await loadContribuyentesForTenant(tenant); + contribuyenteMaps.set(tenantId, map); + } + + const alertData = buildAlertData( + tenants, + contribuyenteMaps, + failed, + staleResult.entries, + staleJobsById, + pendingOld, + missingInitial + ); + + const hasIssues = + alertData.summary.failed > 0 || + alertData.summary.stale > 0 || + alertData.summary.pendingOld > 0 || + alertData.summary.missingInitial > 0; + + if (!hasIssues) { + console.log('[SAT Monitor] Sin anomalías detectadas'); + return; + } + + const recipient = env.SAT_ALERT_EMAIL ?? env.ADMIN_EMAIL; + console.log(`[SAT Monitor] Enviando alerta a ${recipient}:`, alertData.summary); + await emailService.sendSatSyncAlert(recipient, alertData); + console.log('[SAT Monitor] Alerta enviada'); + } catch (error: any) { + console.error('[SAT Monitor] Error en revisión:', error.message || error); + } +} + +export function startSatSyncMonitorJob(): void { + if (monitorTask) { + console.log('[SAT Monitor] Job ya está programado'); + return; + } + + const schedule = env.SAT_MONITOR_SCHEDULE; + if (!cron.validate(schedule)) { + console.error('[SAT Monitor] Expresión cron inválida:', schedule); + return; + } + + monitorTask = cron.schedule(schedule, async () => { + try { + await runSatSyncMonitor(); + } catch (error: any) { + console.error('[SAT Monitor Cron] Error:', error.message || error); + } + }, { + timezone: 'America/Mexico_City', + }); + + console.log(`[SAT Monitor] Programado: ${schedule}`); +} + +export function stopSatSyncMonitorJob(): void { + if (monitorTask) { + monitorTask.stop(); + monitorTask = null; + } +} diff --git a/apps/api/src/services/constancia.service.ts b/apps/api/src/services/constancia.service.ts index 0813771..c378b6d 100644 --- a/apps/api/src/services/constancia.service.ts +++ b/apps/api/src/services/constancia.service.ts @@ -11,7 +11,7 @@ import { loginSatCsf } from './sat/sat-csf-login.js'; import { extractCsfPdf } from './sat/sat-csf-scraper.js'; import { parseCsfPdf, type ConstanciaSituacionFiscal, type Domicilio, type RegimenCsf } from './sat/sat-csf-parser.js'; -const PROCESS_TIMEOUT = 180_000; +const PROCESS_TIMEOUT = 300_000; export interface ConstanciaRow { id: number; diff --git a/apps/api/src/services/contribuyente.service.ts b/apps/api/src/services/contribuyente.service.ts index 368e0d3..c281d66 100644 --- a/apps/api/src/services/contribuyente.service.ts +++ b/apps/api/src/services/contribuyente.service.ts @@ -135,10 +135,61 @@ export async function getContribuyenteById(pool: Pool, id: string, tenantId?: st return mergeContribuyenteWithTenant(row, tenantData); } -export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise { +export async function createContribuyente( + pool: Pool, + data: CreateContribuyenteData, +): Promise<{ row: ContribuyenteRow; reactivated: boolean }> { const client = await pool.connect(); try { await client.query('BEGIN'); + + // 1. Si el RFC ya existe, reactivar la entidad desactivada en lugar de + // violar el UNIQUE de contribuyentes.rfc. Si está activa, lanzar + // error 23505 para que el controller devuelva 409. + const { rows: existing } = await client.query<{ entidad_id: string; active: boolean }>(` + SELECT c.entidad_id, e.active + FROM contribuyentes c + JOIN entidades_gestionadas e ON e.id = c.entidad_id + WHERE UPPER(c.rfc) = UPPER($1) + `, [data.rfc]); + + if (existing.length > 0) { + const { entidad_id, active } = existing[0]; + if (active) { + await client.query('ROLLBACK'); + const err: any = new Error('Ya existe un contribuyente activo con este RFC'); + err.code = '23505'; + throw err; + } + + await client.query(` + UPDATE entidades_gestionadas + SET active = true, + nombre = $1, + identificador = $2, + supervisor_user_id = $3, + updated_at = now() + WHERE id = $4 + `, [data.razonSocial, data.rfc.toUpperCase(), data.supervisorUserId ?? null, entidad_id]); + + await client.query(` + UPDATE contribuyentes + SET regimen_fiscal = $1, + codigo_postal = $2, + domicilio = $3 + WHERE entidad_id = $4 + `, [data.regimenFiscal ?? null, data.codigoPostal ?? null, data.domicilio ? JSON.stringify(data.domicilio) : null, entidad_id]); + + await client.query('COMMIT'); + + await backfillCfdiContribuyente(pool, entidad_id, data.rfc.toUpperCase()).catch( + (err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err) + ); + + return { row: (await getContribuyenteById(pool, entidad_id))!, reactivated: true }; + } + + // 2. Caso normal: crear nuevo contribuyente const { rows: [entidad] } = await client.query(` INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id) VALUES ('CONTRIBUYENTE', $1, $2, $3) @@ -157,7 +208,7 @@ export async function createContribuyente(pool: Pool, data: CreateContribuyenteD (err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err) ); - return (await getContribuyenteById(pool, entidad.id))!; + return { row: (await getContribuyenteById(pool, entidad.id))!, reactivated: false }; } catch (err) { await client.query('ROLLBACK'); throw err; @@ -220,11 +271,39 @@ export async function updateContribuyente(pool: Pool, id: string, data: Partial< } export async function deactivateContribuyente(pool: Pool, id: string): Promise { - const { rowCount } = await pool.query( - 'UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1', - [id] - ); - return (rowCount ?? 0) > 0; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { rowCount } = await client.query( + 'UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1', + [id] + ); + const ok = (rowCount ?? 0) > 0; + + if (ok) { + // Limpiar asignaciones para que no aparezca en carteras ni accesos de cliente + await client.query('DELETE FROM cartera_entidades WHERE entidad_id = $1', [id]).catch((err) => { + console.error('[Contribuyente] Error limpiando cartera_entidades:', err); + }); + await client.query('DELETE FROM cliente_accesos WHERE entidad_id = $1', [id]).catch((err) => { + console.error('[Contribuyente] Error limpiando cliente_accesos:', err); + }); + + // Desactivar FIEL para que no siga sincronizándose mientras está inactivo + await client.query('UPDATE fiel_contribuyente SET is_active = false WHERE contribuyente_id = $1', [id]).catch((err) => { + console.error('[Contribuyente] Error desactivando FIEL:', err); + }); + } + + await client.query('COMMIT'); + return ok; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } } /** diff --git a/apps/api/src/services/email/email.service.ts b/apps/api/src/services/email/email.service.ts index 4998f48..cdd970a 100644 --- a/apps/api/src/services/email/email.service.ts +++ b/apps/api/src/services/email/email.service.ts @@ -44,6 +44,12 @@ export const emailService = { await sendEmail(env.ADMIN_EMAIL, `Pago fallido: ${data.nombre}`, paymentFailedEmail(data)); }, + sendSatSyncAlert: async (to: string, data: import('./templates/sat-sync-alert.js').SatSyncAlertData) => { + const { satSyncAlertEmail } = await import('./templates/sat-sync-alert.js'); + const total = data.summary.failed + data.summary.stale + data.summary.stuckRunning + data.summary.pendingOld + data.summary.missingInitial; + await sendEmail(to, `🚨 Alerta SAT: ${total} anomalía${total === 1 ? '' : 's'} detectada${total === 1 ? '' : 's'}`, satSyncAlertEmail(data)); + }, + sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => { const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring.js'); await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data)); diff --git a/apps/api/src/services/email/templates/sat-sync-alert.ts b/apps/api/src/services/email/templates/sat-sync-alert.ts new file mode 100644 index 0000000..9ad463c --- /dev/null +++ b/apps/api/src/services/email/templates/sat-sync-alert.ts @@ -0,0 +1,194 @@ +import { baseTemplate, heading, infoBox, BRAND_COLORS as C } from './base.js'; + +export interface SatSyncAlertData { + generatedAt: string; + recipient: string; + summary: { + failed: number; + stale: number; + stuckRunning: number; + pendingOld: number; + missingInitial: number; + }; + failed: Array<{ + tenantName: string; + tenantRfc: string; + contribuyenteName?: string | null; + contribuyenteRfc?: string | null; + type: string; + errorMessage?: string | null; + completedAt?: Date | string | null; + }>; + stale: Array<{ + id: string; + tenantName: string; + tenantRfc: string; + contribuyenteName?: string | null; + contribuyenteRfc?: string | null; + type: string; + kind: 'pending-stale' | 'running-stale'; + ageHours: number; + }>; + stuckRunning: Array<{ + id: string; + tenantName: string; + tenantRfc: string; + contribuyenteName?: string | null; + contribuyenteRfc?: string | null; + type: string; + progressPercent: number; + startedAt?: Date | string | null; + hoursRunning: number; + }>; + pendingOld: Array<{ + id: string; + tenantName: string; + tenantRfc: string; + contribuyenteName?: string | null; + contribuyenteRfc?: string | null; + type: string; + createdAt?: Date | string | null; + nextRetryAt?: Date | string | null; + hoursPending: number; + }>; + missingInitial: Array<{ + tenantName: string; + tenantRfc: string; + contribuyenteName: string; + contribuyenteRfc: string; + }>; +} + +function fmtDate(value?: Date | string | null): string { + if (!value) return 'N/A'; + const d = typeof value === 'string' ? new Date(value) : value; + return d.toLocaleString('es-MX', { timeZone: 'America/Mexico_City' }); +} + +function tableHeader(cells: string[]): string { + return ` + ${cells.map(c => `${c}`).join('')} + `; +} + +function tableRow(cells: string[]): string { + return ` + ${cells.map(c => `${c}`).join('')} + `; +} + +function section(title: string, color: string, rowsHtml: string, headers: string[]): string { + return ` +

${title}

+ + ${tableHeader(headers)} + ${rowsHtml} +
+ `; +} + +export function satSyncAlertEmail(data: SatSyncAlertData): string { + const { summary } = data; + + const summaryRows = [ + { label: 'Jobs fallidos recientes', value: summary.failed, color: summary.failed > 0 ? '#dc2626' : C.textPrimary }, + { label: 'Jobs stale detectados', value: summary.stale, color: summary.stale > 0 ? '#dc2626' : C.textPrimary }, + { label: 'Running atorados sin progreso', value: summary.stuckRunning, color: summary.stuckRunning > 0 ? '#f59e0b' : C.textPrimary }, + { label: 'Pending sin atender', value: summary.pendingOld, color: summary.pendingOld > 0 ? '#f59e0b' : C.textPrimary }, + { label: 'Contribuyentes con FIEL sin sync inicial', value: summary.missingInitial, color: summary.missingInitial > 0 ? '#dc2626' : C.textPrimary }, + ] + .map(r => `${r.label}${r.value}`) + .join(''); + + const failedHtml = data.failed.length > 0 + ? section( + `Jobs fallidos (${data.failed.length})`, + '#dc2626', + data.failed.map(j => tableRow([ + `${j.tenantName}
${j.tenantRfc}`, + j.contribuyenteName ? `${j.contribuyenteName}
${j.contribuyenteRfc || ''}` : '—', + j.type, + `${j.errorMessage || 'Sin mensaje'}`, + fmtDate(j.completedAt), + ])).join(''), + ['Tenant', 'Contribuyente', 'Tipo', 'Error', 'Fecha fallo'] + ) + : ''; + + const staleHtml = data.stale.length > 0 + ? section( + `Jobs stale detectados por el watchdog (${data.stale.length})`, + '#dc2626', + data.stale.map(j => tableRow([ + `${j.tenantName}
${j.tenantRfc}`, + j.contribuyenteName ? `${j.contribuyenteName}
${j.contribuyenteRfc || ''}` : '—', + j.type, + j.kind === 'running-stale' ? 'Running abandonado' : 'Pending abandonado', + `${j.ageHours}h`, + ])).join(''), + ['Tenant', 'Contribuyente', 'Tipo', 'Problema', 'Antigüedad'] + ) + : ''; + + const stuckHtml = data.stuckRunning.length > 0 + ? section( + `Running atorados sin avance (${data.stuckRunning.length})`, + '#f59e0b', + data.stuckRunning.map(j => tableRow([ + `${j.tenantName}
${j.tenantRfc}`, + j.contribuyenteName ? `${j.contribuyenteName}
${j.contribuyenteRfc || ''}` : '—', + j.type, + `${j.progressPercent}%`, + `${j.hoursRunning}h`, + fmtDate(j.startedAt), + ])).join(''), + ['Tenant', 'Contribuyente', 'Tipo', 'Progreso', 'Tiempo', 'Inicio'] + ) + : ''; + + const pendingHtml = data.pendingOld.length > 0 + ? section( + `Pending sin atender (${data.pendingOld.length})`, + '#f59e0b', + data.pendingOld.map(j => tableRow([ + `${j.tenantName}
${j.tenantRfc}`, + j.contribuyenteName ? `${j.contribuyenteName}
${j.contribuyenteRfc || ''}` : '—', + j.type, + `${j.hoursPending}h`, + j.nextRetryAt ? fmtDate(j.nextRetryAt) : 'Sin reintento', + ])).join(''), + ['Tenant', 'Contribuyente', 'Tipo', 'Tiempo pendiente', 'Próximo reintento'] + ) + : ''; + + const missingHtml = data.missingInitial.length > 0 + ? section( + `Contribuyentes con FIEL sin sync inicial (${data.missingInitial.length})`, + '#dc2626', + data.missingInitial.map(j => tableRow([ + `${j.tenantName}
${j.tenantRfc}`, + `${j.contribuyenteName}
${j.contribuyenteRfc}`, + ])).join(''), + ['Tenant', 'Contribuyente'] + ) + : ''; + + return baseTemplate(` + ${heading('🚨 Alerta de sincronización SAT')} +

+ El monitoreo de sincronizaciones SAT detectó anomalías que requieren revisión interna. +

+ ${infoBox(`${summaryRows}
`)} + + ${failedHtml} + ${staleHtml} + ${stuckHtml} + ${pendingHtml} + ${missingHtml} + +

+ Reporte generado el ${data.generatedAt} para ${data.recipient}.
+ Configura umbrales con SAT_STUCK_RUNNING_HOURS y SAT_FAILED_LOOKBACK_HOURS. +

+ `); +} diff --git a/apps/api/src/services/sat/sat-csf-scraper.ts b/apps/api/src/services/sat/sat-csf-scraper.ts index 37e4b8e..f811fb0 100644 --- a/apps/api/src/services/sat/sat-csf-scraper.ts +++ b/apps/api/src/services/sat/sat-csf-scraper.ts @@ -1,40 +1,113 @@ -import type { Page, Locator, Frame, Response } from 'playwright'; +import type { Page, Locator, Frame, Response, BrowserContext } from 'playwright'; import type { CsfLoginSession } from './sat-csf-login.js'; -async function tryFetchPdfFromUrl(page: Page, url: string): Promise { +async function tryFetchPdfFromUrl(frame: Frame, url: string): Promise { + if (!url || url === 'about:blank') return null; + + // Blob / data URI → fetchear dentro del navegador para respetar cookies/sesión if (url.startsWith('blob:') || url.startsWith('data:')) { - const arr = await page.evaluate(async (u) => { - const r = await fetch(u); - const buf = await r.arrayBuffer(); - return Array.from(new Uint8Array(buf)); - }, url); - return Buffer.from(arr); + try { + const page = frame.page(); + const arr = await page.evaluate(async (u) => { + const r = await fetch(u); + const buf = await r.arrayBuffer(); + return Array.from(new Uint8Array(buf)); + }, url); + const buf = Buffer.from(arr); + return buf.subarray(0, 5).toString().startsWith('%PDF-') ? buf : null; + } catch { + return null; + } } + + // URL http(s) relativa o absoluta → fetchear desde el frame para mantener sesión if (url.startsWith('http')) { - const response = await page.context().request.get(url); - if (!response.ok()) return null; - return Buffer.from(await response.body()); + try { + const response = await frame.page().context().request.get(url); + if (!response.ok()) return null; + const ct = response.headers()['content-type'] ?? ''; + if (!ct.includes('application/pdf') && !url.toLowerCase().includes('.pdf')) return null; + const buf = Buffer.from(await response.body()); + return buf.subarray(0, 5).toString().startsWith('%PDF-') ? buf : null; + } catch { + return null; + } } + return null; } -/** - * Busca "Generar Constancia" en cualquiera de los frames del appPage (vive - * típicamente en un iframe JSF legacy: rfcampc.siat.sat.gob.mx/PTSC/...). - * Intenta 3 rutas: download event, popup con viewer, response interception. - */ -export async function extractCsfPdf(session: CsfLoginSession): Promise { - const { context, appPage } = session; +async function findPdfInFrames(appPage: Page, deadlineMs: number): Promise { + const deadline = Date.now() + deadlineMs; - let interceptedPdf: Buffer | null = null; - const responseListener = async (response: Response) => { - const ct = response.headers()['content-type'] ?? ''; - if (ct.includes('application/pdf')) { - try { interceptedPdf = Buffer.from(await response.body()); } catch { /* ok */ } + while (Date.now() < deadline) { + const frames = appPage.frames(); + + for (const frame of frames) { + try { + const frameUrl = frame.url(); + + // 1. Frame cuya URL sea directamente un PDF + if ( + frameUrl.toLowerCase().includes('.pdf') || + frameUrl.includes('application/pdf') + ) { + const body = await frame.content().catch(() => null); + if (!body) continue; + // content() de un PDF no es util; intentar fetch por URL + const pdf = await tryFetchPdfFromUrl(frame, frameUrl); + if (pdf) return pdf; + } + + // 2. + const embed = frame.locator('embed[type="application/pdf"]').first(); + if ((await embed.count()) > 0) { + const src = await embed.getAttribute('src'); + if (src) { + const pdf = await tryFetchPdfFromUrl(frame, src); + if (pdf) return pdf; + } + } + + // 3.