diff --git a/apps/api/prisma/migrations/20260803054000_add_sat_sync_jobs_proxy_used/migration.sql b/apps/api/prisma/migrations/20260803054000_add_sat_sync_jobs_proxy_used/migration.sql new file mode 100644 index 0000000..7a16379 --- /dev/null +++ b/apps/api/prisma/migrations/20260803054000_add_sat_sync_jobs_proxy_used/migration.sql @@ -0,0 +1,2 @@ +-- Agrega proxy_used a sat_sync_jobs para diagnosticar bloqueos por IP +ALTER TABLE "sat_sync_jobs" ADD COLUMN IF NOT EXISTS "proxy_used" VARCHAR(255); diff --git a/apps/api/prisma/migrations/20260803054500_add_sat_proxy_errors/migration.sql b/apps/api/prisma/migrations/20260803054500_add_sat_proxy_errors/migration.sql new file mode 100644 index 0000000..119225d --- /dev/null +++ b/apps/api/prisma/migrations/20260803054500_add_sat_proxy_errors/migration.sql @@ -0,0 +1,13 @@ +-- Tabla de errores SAT por proxy para reportes diarios +CREATE TABLE IF NOT EXISTS "sat_proxy_errors" ( + "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid(), + "job_id" TEXT NOT NULL REFERENCES "sat_sync_jobs"("id") ON DELETE CASCADE, + "proxy_used" VARCHAR(255), + "error_code" VARCHAR(50), + "stage" VARCHAR(255), + "message" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS "sat_proxy_errors_created_at_idx" ON "sat_proxy_errors"("created_at"); +CREATE INDEX IF NOT EXISTS "sat_proxy_errors_proxy_used_created_at_idx" ON "sat_proxy_errors"("proxy_used", "created_at"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 3eb8733..e1e1d16 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -677,8 +677,12 @@ model SatSyncJob { // usuario (botón UI). Cambia la política de retry: 2 intentos vs 3 del // bootstrap puro. Daily/incremental ignoran este campo. isCustomRange Boolean @default(false) @map("is_custom_range") + // Proxy usado en el último request SAT que falló (host:port). Ayuda a diagnosticar + // bloqueos por IP y a generar reportes diarios de errores por proxy. + proxyUsed String? @map("proxy_used") @db.VarChar(255) tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + proxyErrors SatProxyError[] @@index([tenantId]) @@index([status]) @@ -686,6 +690,24 @@ model SatSyncJob { @@map("sat_sync_jobs") } +// Errores de bloqueo/devolución del SAT por proxy. +// Permite reportes diarios de cuántos 404/500X/etc. ocurrieron en cada IP. +model SatProxyError { + id String @id @default(uuid()) + jobId String @map("job_id") + proxyUsed String? @map("proxy_used") @db.VarChar(255) + errorCode String? @map("error_code") @db.VarChar(50) + stage String? @map("stage") @db.VarChar(255) + message String? @map("message") + createdAt DateTime @default(now()) @map("created_at") + + job SatSyncJob @relation(fields: [jobId], references: [id], onDelete: Cascade) + + @@index([createdAt]) + @@index([proxyUsed, createdAt]) + @@map("sat_proxy_errors") +} + enum SatSyncType { initial daily diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b108ccf..19218ee 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -7,6 +7,7 @@ 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'; +import { startSatProxyReportJob } from './jobs/sat-proxy-report.job.js'; import { startRecordatoriosPeriodicosJob } from './jobs/recordatorios-periodicos.job.js'; const PORT = parseInt(env.PORT, 10); @@ -26,6 +27,7 @@ const server = app.listen(PORT, '0.0.0.0', () => { startSatSyncJob(); startMetricasInvalidationsJob(); startSatSyncMonitorJob(); + startSatProxyReportJob(); startRecordatoriosPeriodicosJob(); if (sendRealEmails) { startWeeklyUpdateJob(); diff --git a/apps/api/src/jobs/sat-proxy-report.job.ts b/apps/api/src/jobs/sat-proxy-report.job.ts new file mode 100644 index 0000000..1c902e3 --- /dev/null +++ b/apps/api/src/jobs/sat-proxy-report.job.ts @@ -0,0 +1,93 @@ +import cron from 'node-cron'; +import { prisma } from '../config/database.js'; +import { env } from '../config/env.js'; +import { emailService } from '../services/email/email.service.js'; +import type { SatProxyReportData } from '../services/email/templates/sat-proxy-report.js'; + +const PROXY_REPORT_CRON_SCHEDULE = '0 8 * * *'; // 8:00 AM CDMX diario + +let reportTask: ReturnType | null = null; + +function cdmxDateString(d: Date): string { + return d.toLocaleDateString('es-MX', { timeZone: 'America/Mexico_City' }); +} + +function hoursAgo(hours: number): Date { + return new Date(Date.now() - hours * 60 * 60 * 1000); +} + +export async function runSatProxyReport(): Promise { + console.log('[SAT Proxy Report] Generando reporte diario de errores por proxy'); + + try { + const cutoff = hoursAgo(24); + const rows = await prisma.$queryRaw>` + SELECT proxy_used AS proxy, error_code AS "errorCode", COUNT(*) AS count + FROM sat_proxy_errors + WHERE created_at >= ${cutoff} + GROUP BY proxy_used, error_code + ORDER BY count DESC, proxy_used ASC, error_code ASC + `; + + const byProxy = rows.map(r => ({ + proxy: r.proxy || 'IP directa del servidor', + errorCode: r.errorCode || '—', + count: Number(r.count), + })); + + const totalErrors = byProxy.reduce((sum, r) => sum + r.count, 0); + + const now = new Date(); + const data: SatProxyReportData = { + generatedAt: now.toLocaleString('es-MX', { timeZone: 'America/Mexico_City' }), + recipient: env.ADMIN_EMAIL, + dateFrom: cdmxDateString(hoursAgo(24)), + dateTo: cdmxDateString(now), + totalErrors, + byProxy, + }; + + const recipient = env.ADMIN_EMAIL; + if (!recipient) { + console.warn('[SAT Proxy Report] ADMIN_EMAIL no configurado, no se envía reporte'); + return; + } + + console.log(`[SAT Proxy Report] Enviando reporte a ${recipient}: ${totalErrors} errores en ${byProxy.length} grupos`); + await emailService.sendSatProxyReport(recipient, data); + console.log('[SAT Proxy Report] Reporte enviado'); + } catch (error: any) { + console.error('[SAT Proxy Report] Error generando reporte:', error.message || error); + } +} + +export function startSatProxyReportJob(): void { + if (reportTask) { + console.log('[SAT Proxy Report] Job ya está programado'); + return; + } + + if (!cron.validate(PROXY_REPORT_CRON_SCHEDULE)) { + console.error('[SAT Proxy Report] Expresión cron inválida:', PROXY_REPORT_CRON_SCHEDULE); + return; + } + + reportTask = cron.schedule(PROXY_REPORT_CRON_SCHEDULE, async () => { + try { + await runSatProxyReport(); + } catch (error: any) { + console.error('[SAT Proxy Report Cron] Error:', error.message || error); + } + }, { + timezone: 'America/Mexico_City', + }); + + console.log(`[SAT Proxy Report] Programado: ${PROXY_REPORT_CRON_SCHEDULE} (America/Mexico_City)`); +} + +export function stopSatProxyReportJob(): void { + if (reportTask) { + reportTask.stop(); + reportTask = null; + } +} diff --git a/apps/api/src/services/email/email.service.ts b/apps/api/src/services/email/email.service.ts index cdd970a..b42c189 100644 --- a/apps/api/src/services/email/email.service.ts +++ b/apps/api/src/services/email/email.service.ts @@ -50,6 +50,11 @@ export const emailService = { await sendEmail(to, `🚨 Alerta SAT: ${total} anomalía${total === 1 ? '' : 's'} detectada${total === 1 ? '' : 's'}`, satSyncAlertEmail(data)); }, + sendSatProxyReport: async (to: string, data: import('./templates/sat-proxy-report.js').SatProxyReportData) => { + const { satProxyReportEmail } = await import('./templates/sat-proxy-report.js'); + await sendEmail(to, `📊 Reporte diario SAT: ${data.totalErrors} errores por proxy`, satProxyReportEmail(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-proxy-report.ts b/apps/api/src/services/email/templates/sat-proxy-report.ts new file mode 100644 index 0000000..2a9774f --- /dev/null +++ b/apps/api/src/services/email/templates/sat-proxy-report.ts @@ -0,0 +1,63 @@ +import { baseTemplate, heading, infoBox, BRAND_COLORS as C } from './base.js'; + +export interface ProxyErrorRow { + proxy: string; + errorCode: string; + count: number; +} + +export interface SatProxyReportData { + generatedAt: string; + recipient: string; + dateFrom: string; + dateTo: string; + totalErrors: number; + byProxy: ProxyErrorRow[]; +} + +function tableHeader(cells: string[]): string { + return ` + ${cells.map(c => `${c}`).join('')} + `; +} + +function tableRow(cells: string[]): string { + return ` + ${cells.map(c => `${c}`).join('')} + `; +} + +export function satProxyReportEmail(data: SatProxyReportData): string { + const rowsHtml = data.byProxy.length > 0 + ? data.byProxy.map(r => tableRow([ + r.proxy ? `${r.proxy}` : 'IP directa del servidor', + r.errorCode || '—', + `${r.count}`, + ])).join('') + : tableRow(['—', '—', 'Sin errores de bloqueo']); + + return baseTemplate(` + ${heading('📊 Reporte diario de errores SAT por proxy')} +

+ Resumen de errores de bloqueo/devolución del SAT en las últimas 24 horas, + agrupados por proxy/IP usada. +

+ ${infoBox(` + + + +
Período${data.dateFrom} → ${data.dateTo}
Errores totales${data.totalErrors}
+ `)} + +

Errores por proxy (${data.byProxy.length})

+ + ${tableHeader(['Proxy / IP', 'Código', 'Cantidad'])} + ${rowsHtml} +
+ +

+ Reporte generado el ${data.generatedAt} para ${data.recipient}.
+ Configura la lista de proxies con SAT_PROXY_LIST y la concurrencia con SAT_CONCURRENT_CONTRIBUYENTES. +

+ `); +} diff --git a/apps/api/src/services/sat/sat-client.service.ts b/apps/api/src/services/sat/sat-client.service.ts index fc1b585..6de50e3 100644 --- a/apps/api/src/services/sat/sat-client.service.ts +++ b/apps/api/src/services/sat/sat-client.service.ts @@ -18,6 +18,12 @@ export interface FielData { password: string; } +export interface ProxyInfo { + host: string; + port: number; + url: string; +} + /** * Timeout explícito para el cliente HTTP del SAT (ms). * @@ -38,7 +44,7 @@ const SAT_WEB_CLIENT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutos /** * Crea el servicio de descarga masiva del SAT usando los datos de la FIEL */ -export function createSatService(fielData: FielData): Service { +export function createSatService(fielData: FielData): { service: Service; proxyInfo: ProxyInfo | null } { // Crear FIEL usando el método estático create const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password); @@ -50,7 +56,8 @@ export function createSatService(fielData: FielData): Service { // Crear cliente HTTP con timeout explícito para evitar el bug de la librería // cuando ocurre un timeout de red. Si hay proxies configurados, se usa uno // del pool para reducir el riesgo de bloqueo por IP del SAT. - const proxyAgent = proxyManager.createNextAgent(); + const proxy = proxyManager.getNextProxy(); + const proxyAgent = proxy ? proxyManager.createAgent(proxy) : null; if (proxyAgent) { console.log('[SAT] Usando proxy para la conexión con el SAT'); } else { @@ -68,7 +75,11 @@ export function createSatService(fielData: FielData): Service { const requestBuilder = new FielRequestBuilder(fiel); // Crear y retornar el servicio - return new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi()); + const service = new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi()); + return { + service, + proxyInfo: proxy ? { host: proxy.host, port: proxy.port, url: proxy.url } : null, + }; } export interface QueryResult { diff --git a/apps/api/src/services/sat/sat.service.ts b/apps/api/src/services/sat/sat.service.ts index d0cd968..734d295 100644 --- a/apps/api/src/services/sat/sat.service.ts +++ b/apps/api/src/services/sat/sat.service.ts @@ -8,6 +8,7 @@ import { verifySatRequest, downloadSatPackage, type FielData, + type ProxyInfo, } from './sat-client.service.js'; import { processPackage, processMetadataPackage, extractXmlsFromZip, type CfdiParsed, type CfdiMetadata } from './sat-parser.service.js'; import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../../utils/saldo.js'; @@ -91,6 +92,7 @@ function computeNextRetryAt( interface SyncContext { fielData: FielData; service: Service; + proxyInfo: ProxyInfo | null; rfc: string; tenantId: string; databaseName: string; @@ -98,6 +100,33 @@ interface SyncContext { getPool: () => Promise; } +/** + * Registra un error de bloqueo/devolución del SAT junto con el proxy usado. + * Permite reportes diarios de errores por IP. + */ +async function recordSatProxyError( + jobId: string, + ctx: SyncContext, + errorCode: string | null, + stage: string, + message: string, +): Promise { + try { + const proxyUsed = ctx.proxyInfo ? `${ctx.proxyInfo.host}:${ctx.proxyInfo.port}` : null; + await prisma.satProxyError.create({ + data: { + jobId, + proxyUsed, + errorCode, + stage, + message, + }, + }); + } catch (err: any) { + console.error('[SAT] Error guardando sat_proxy_error:', err.message); + } +} + /** * Actualiza el progreso de un job */ @@ -117,6 +146,7 @@ async function updateJobProgress( completedAt: Date; retryCount: number; nextRetryAt: Date; + proxyUsed: string | null; }> ): Promise { await prisma.satSyncJob.update({ @@ -700,6 +730,7 @@ async function requestAndDownload( return { packageContents: [], totalCfdis: 0 }; } if (/error no controlado/i.test(queryResult.message || '')) { + await recordSatProxyError(jobId, ctx, queryResult.statusCode || '404', stageIdForTimeout(label), queryResult.message || 'Error no controlado'); if (isDaily) { // En daily no detenemos el job por un 404 transitorio del SAT; se // registra como no fatal para diagnóstico y se continúa. @@ -709,6 +740,7 @@ async function requestAndDownload( console.warn(`[SAT] Rechazo transitorio del SAT (${label}): ${queryResult.message} — se reintentará`); throw new SatTransientError(stageIdForTimeout(label), queryResult.message); } + await recordSatProxyError(jobId, ctx, queryResult.statusCode || null, stageIdForTimeout(label), queryResult.message || 'Error SAT'); throw new Error(`Error SAT (${label}): ${queryResult.message}`); } @@ -735,6 +767,7 @@ async function requestAndDownload( console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite este rango.`); return { packageContents: [], totalCfdis: 0 }; } + await recordSatProxyError(jobId, ctx, verifyResult.status, stageIdForTimeout(label), verifyResult.message || `Solicitud ${verifyResult.status}`); throw new Error(`Solicitud fallida (${label}): ${verifyResult.message}`); } } @@ -1523,7 +1556,7 @@ export async function startSync( password: decryptedFiel.password, }; - const service = createSatService(fielData); + const { service, proxyInfo } = createSatService(fielData); const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, @@ -1564,12 +1597,14 @@ export async function startSync( dateTo: dateTo || now, startedAt: now, isCustomRange, + proxyUsed: proxyInfo ? `${proxyInfo.host}:${proxyInfo.port}` : null, }, }); const ctx: SyncContext = { fielData, service, + proxyInfo, rfc: decryptedFiel.rfc, tenantId, databaseName: tenant.databaseName, @@ -1721,7 +1756,7 @@ export async function retryTimedOutJobs(): Promise { continue; } - const service = createSatService({ + const { service, proxyInfo } = createSatService({ cerContent: decryptedFiel.cerContent, keyContent: decryptedFiel.keyContent, password: decryptedFiel.password, @@ -1734,6 +1769,7 @@ export async function retryTimedOutJobs(): Promise { password: decryptedFiel.password, }, service, + proxyInfo, rfc: decryptedFiel.rfc, tenantId: job.tenantId, databaseName: job.tenant.databaseName, @@ -1744,7 +1780,7 @@ export async function retryTimedOutJobs(): Promise { // B: resetear startedAt al inicio de ESTE intento para que el watchdog // mida el intento actual (y no mate un retry legítimo por el startedAt // original del job). La política de retries se ancla a createdAt. - await updateJobProgress(job.id, { status: 'running', errorMessage: null as any, startedAt: new Date() }); + await updateJobProgress(job.id, { status: 'running', errorMessage: null as any, startedAt: new Date(), proxyUsed: proxyInfo ? `${proxyInfo.host}:${proxyInfo.port}` : null }); // Para jobs daily, intentamos retomar desde la última etapa completada. let resumeFromStage: string | undefined; @@ -1890,7 +1926,7 @@ export async function continuePendingDailyRequests(): Promise { continue; } - const service = createSatService({ + const { service, proxyInfo } = createSatService({ cerContent: decryptedFiel.cerContent, keyContent: decryptedFiel.keyContent, password: decryptedFiel.password, @@ -1903,6 +1939,7 @@ export async function continuePendingDailyRequests(): Promise { password: decryptedFiel.password, }, service, + proxyInfo, rfc: decryptedFiel.rfc, tenantId: job.tenantId, databaseName: job.tenant.databaseName, diff --git a/docs/SAT-SYNC-IMPLEMENTATION.md b/docs/SAT-SYNC-IMPLEMENTATION.md index 9f82b8d..4e3c30c 100644 --- a/docs/SAT-SYNC-IMPLEMENTATION.md +++ b/docs/SAT-SYNC-IMPLEMENTATION.md @@ -94,6 +94,7 @@ Definidos en `apps/api/src/jobs/sat-sync.job.ts`: | Incremental Enterprise | `0 11,15,19 * * *` | 11 AM, 3 PM, 7 PM | Sync incremental | | SAT Watchdog | `0 */2 * * *` | Cada 2 horas | Marcar jobs `running` sin heartbeat como failed | | SAT Monitor | `0 */2 * * *` | Cada 2 horas | Alertar por email de jobs fallidos | +| SAT Proxy Report | `0 8 * * *` | 8:00 AM | Reporte diario de errores SAT por proxy | ## 6. Flujo de sincronización @@ -144,6 +145,21 @@ SAT_CONCURRENT_CONTRIBUYENTES=10 - **`ProxyManager`** (`apps/api/src/services/sat/proxy.service.ts`): parsea `SAT_PROXY_LIST`, rota proxies y crea `HttpsProxyAgent`. - **`sat-client.service.ts`**: usa el agente del proxy en cada petición SOAP al SAT. +### Reporte diario de errores por proxy + +Cada error de bloqueo/devolución del SAT se guarda en `public.sat_proxy_errors` con el proxy usado. + +Un cron a las **8:00 AM CDMX** envía un email a `ADMIN_EMAIL` con: + +- Total de errores en las últimas 24 horas. +- Tabla agrupada por `proxy` + `error_code`. + +Archivos: + +- `apps/api/src/jobs/sat-proxy-report.job.ts` — cron y consulta. +- `apps/api/src/services/email/templates/sat-proxy-report.ts` — template del email. +- `apps/api/src/services/email/email.service.ts` — `sendSatProxyReport`. + ### Comportamiento - Cada llamada a `getNextProxy()` devuelve el siguiente proxy del pool (round-robin). @@ -269,6 +285,8 @@ psql "$DATABASE_URL" -c " - **Proxies SAT rotativos**: integración de `ProxyManager` y pool de proxies HTTP/HTTPS para evitar bloqueo por IP del SAT. - **Concurrencia por contribuyente**: el scheduler daily e incremental procesa hasta `SAT_CONCURRENT_CONTRIBUYENTES=10` RFCs en paralelo, sin importar a cuántos tenants pertenezcan. +- **Reporte diario de errores por proxy**: cron a las 8 AM CDMX que envía a `ADMIN_EMAIL` un resumen de errores SAT agrupados por proxy/IP. +- Tabla `public.sat_proxy_errors` para trazabilidad de bloqueos por IP. - Variables de entorno: `SAT_PROXY_LIST`, `SAT_PROXY_STRATEGY`, `SAT_PROXY_FALLBACK_DIRECT`, `SAT_CONCURRENT_CONTRIBUYENTES`. ### 2026-07-31 @@ -288,6 +306,7 @@ psql "$DATABASE_URL" -c " ## 15. Próximos pasos - [x] Implementar proxies rotativos para evitar bloqueo por IP del SAT. +- [x] Reporte diario de errores por proxy. - [ ] Monitorear tasa de éxito tras proxies + concurrencia por contribuyente. - [ ] Revisar/renovar FIELs inválidas reportadas por el monitor. - [ ] Evaluar ampliar ventana horaria del daily (6–10 AM) si el volumen de RFCs supera el throughput con 10 paralelos.