# Sincronización SAT — Implementación y Operación Documentación viva del sistema de sincronización de CFDIs con el SAT para Horux Despachos / Horux 360. ## 1. Resumen El sistema descarga periódicamente XMLs y metadata de CFDIs emitidos y recibidos desde el servicio web de descarga masiva del SAT (`@nodecfdi/sat-ws-descarga-masiva`), usando la FIEL de cada contribuyente o del tenant (modo legacy Horux 360). Los datos se almacenan en la base de datos del tenant correspondiente. ## 2. Arquitectura ### Backend | Archivo | Responsabilidad | |---------|-----------------| | `apps/api/src/services/sat/sat.service.ts` | Lógica principal de sincronización, políticas de reintento, polling | | `apps/api/src/services/sat/sat-client.service.ts` | Cliente del SAT, `query`, `verify`, `download` | | `apps/api/src/services/sat/sat-parser.service.ts` | Parseo de XMLs y metadata | | `apps/api/src/services/sat/sat-crypto.service.ts` | Encriptación AES-256-GCM de credenciales FIEL | | `apps/api/src/services/fiel.service.ts` | FIEL a nivel tenant (legacy) | | `apps/api/src/services/contribuyente-fiel.service.ts` | FIEL por contribuyente (modelo despacho) | | `apps/api/src/controllers/sat.controller.ts` | Endpoints HTTP | | `apps/api/src/jobs/sat-sync.job.ts` | Crons de sincronización | | `apps/api/src/jobs/sat-sync-monitor.job.ts` | Watchdog de jobs atorados/fallidos | ### Frontend | Archivo | Responsabilidad | |---------|-----------------| | `apps/web/components/sat/FielUploadModal.tsx` | Subir FIEL | | `apps/web/components/sat/SyncStatus.tsx` | Estado y selector de fechas | | `apps/web/components/sat/SyncHistory.tsx` | Historial de sincronizaciones | | `apps/web/app/(dashboard)/configuracion/sat/page.tsx` | Página de configuración SAT | ## 3. Modelo de datos ### Tabla global `public.sat_sync_jobs` ```sql CREATE TABLE sat_sync_jobs ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL REFERENCES tenants(id), contribuyente_id TEXT, -- NULL = modo legacy Horux 360 type "SatSyncType" NOT NULL, -- 'initial' | 'daily' | 'incremental' status "SatSyncStatus" NOT NULL, -- 'pending' | 'running' | 'completed' | 'failed' date_from DATE NOT NULL, date_to DATE NOT NULL, cfdi_type "CfdiSyncType", -- 'emitidos' | 'recibidos' (no siempre usado) sat_request_id VARCHAR(50), -- legacy, preferir sat_request_ids sat_package_ids TEXT[], cfdis_found INTEGER NOT NULL DEFAULT 0, cfdis_downloaded INTEGER NOT NULL DEFAULT 0, cfdis_inserted INTEGER NOT NULL DEFAULT 0, cfdis_updated INTEGER NOT NULL DEFAULT 0, progress_percent INTEGER NOT NULL DEFAULT 0, error_message TEXT, started_at TIMESTAMP(3), completed_at TIMESTAMP(3), created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, retry_count INTEGER NOT NULL DEFAULT 0, next_retry_at TIMESTAMP(3), is_custom_range BOOLEAN NOT NULL DEFAULT false, sat_request_ids JSONB NOT NULL DEFAULT '{}' ); ``` ### FIEL - **Legacy:** `public.fiel_credentials` (una por tenant). - **Por contribuyente:** `fiel_contribuyente` dentro de la base del tenant. El sistema intenta primero la FIEL del contribuyente; si no existe, cae a la FIEL del tenant. ## 4. Tipos de sincronización | Tipo | Descripción | Cuándo corre | |------|-------------|--------------| | `initial` | Primer sync de un contribuyente/tenant. Descarga XMLs + metadata en bloques. | Manual o cuando no hay un `initial` completado | | `daily` | Sync diaria de los últimos 7 días de XMLs + metadata histórica solo los domingos | Cron 6–10 AM CDMX y retries 9 AM / 4 PM | | `incremental` | Ventana de las últimas 8 horas | Cron 11 AM, 3 PM, 7 PM CDMX (Enterprise) | | Custom range | `daily` con `dateFrom`/`dateTo` explícitos, llamado por el UI | Manual | ## 5. Cronograma de jobs Definidos en `apps/api/src/jobs/sat-sync.job.ts`: | Job | Expresión | Horario CDMX | Propósito | |-----|-----------|--------------|-----------| | SAT Cron | `0 6-10 * * *` | 6:00–10:00 AM | Daily sync, ~20% de tenants por hora | | Recovery Cron | `0 10 * * *` | 10:00 AM | Recuperar jobs `running` atorados | | Daily Retry | `0 9 * * *` y `0 16 * * *` | 9:00 AM y 4:00 PM | Reintentar daily fallidos | | 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 | ## 6. Flujo de sincronización ### `processDailySync` 1. Fecha final = ayer a medio día UTC (`getYesterdayEnd()`). 2. Ejecuta XMLs emitidos y recibidos de los últimos 7 días. 3. **Metadata histórica (desde inicio de año) solo si es domingo en CDMX.** 4. Errores 404 se registran pero **no abortan** el daily. 5. Errores transitorios (timeout) se reintentan según política. ### `processInitialSync` / custom range 1. Divide el rango en bloques de 3 o 6 meses para XMLs (según volumen estimado). 2. Descarga XMLs emitidos y recibidos por bloque. 3. Descarga metadata del rango completo. 4. Pausa de 5 segundos entre bloques. ### `processIncrementalSync` 1. Ventana de 8 horas: `ahora - 10h` a `ahora - 2h`. 2. Descarga XMLs + metadata de emitidos y recibidos. ## 7. Polling y límites Después de crear una solicitud (`query`) al SAT, el sistema verifica el estado periódicamente (`verify`). Constantes en `sat.service.ts`: ```ts const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutos entre verificaciones const MAX_POLL_ATTEMPTS = 9; // 9 intentos máximo por solicitud const DAILY_MAX_POLL_ATTEMPTS = 9; // igual para daily const YEARS_TO_SYNC = 6; ``` Esto da un máximo de **~45 minutos por solicitud** (9 × 5 min). Cada solicitud al SAT tiene su propio polling; los intentos no se comparten entre solicitudes. ## 8. Políticas de reintentos ```ts const RETRY_POLICIES = { daily: { maxRetries: 2, retryAtHours: [6, 12] }, custom: { maxRetries: 2, retryAtHours: [6, 12] }, initial: { maxRetries: 3, retryAtHours: [6, 12, 24] }, incremental: { maxRetries: 0, retryAtHours: [] }, }; const MAX_DAILY_RETRY_ATTEMPTS = 5; // original + 2 automáticos + 2 crons fijos ``` Los reintentos automáticos se programan desde `createdAt` del job. Los reintentos por cron fijo (9 AM / 4 PM) se manejan en `continuePendingDailyRequests`. ## 9. Manejo de errores ### Errores no fatales (se registran, no abortan en daily) - `404 Error no controlado` en daily se guarda en `errorMessage` como `completedWithWarnings`. - En syncs custom/initial los 404 se capturan por bloque pero el job puede continuar. ### Errores transitorios (reintentan) - Timeout del polling (`SatSyncTimeoutError`). - `SatTransientError` (ej. rechazo transitorio del SAT). - Metadata aún no lista (`SatMetadataPendingError`). ### Errores fatales (job falla) - FIEL inválida o vencida. - Errores que no son transitorios y no están en la lista de no fatales. ## 10. Errores comunes del SAT | Código/Mensaje | Significado | Acción | |----------------|-------------|--------| | `5000` / "Solicitud Aceptada" | OK, hay que esperar | Polling normal | | `5002` / "Solicitudes agotadas de por vida" | Cuota de solicitudes agotada | Omitir rango, esperar 24h | | `5004` / "No se encontró la información" | Sin CFDIs en el rango | Continuar | | `5005` / Duplicada | Solicitud duplicada | Reusar requestId existente | | `404` / "Error no controlado" | Bloqueo/cuota del SAT | Registrar y continuar en daily; reintentar en otros syncs | | "Fecha final invalida" | Fecha futura o mal formada | Usar `getYesterdayEnd()` | | "El certificado no es válido" | FIEL rechazada por el SAT | Revisar vigencia/contraseña de FIEL | ## 11. Monitoreo y comandos útiles ### Estado del API ```bash pm2 status horux-api pm2 logs horux-api --lines 50 --nostream ``` ### Jobs recientes ```bash export DATABASE_URL="postgresql://postgres:PASSWORD@localhost:5432/horux360" psql "$DATABASE_URL" -c " SELECT id, type, status, contribuyente_id, cfdis_found, cfdis_downloaded, cfdis_inserted, cfdis_updated, error_message, created_at, completed_at FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 20; " ``` ### Jobs fallidos o atorados ```bash psql "$DATABASE_URL" -c " SELECT id, type, status, tenant_id, contribuyente_id, error_message, created_at, started_at FROM sat_sync_jobs WHERE status IN ('failed', 'running') ORDER BY created_at DESC; " ``` ### Contribuyentes de un tenant ```bash # Reemplazar horux_ por la base del tenant psql "$DATABASE_URL" -c " SELECT c.entidad_id, c.rfc, e.nombre FROM contribuyentes c JOIN entidades_gestionadas e ON c.entidad_id = e.id; " ``` ## 12. Changelog reciente ### 2026-07-31 - Metadata histórica en daily solo se ejecuta los domingos. - Errores 404 en daily no abortan el proceso; se registran en `error_message`. - Polling reducido a **9 intentos máximos cada 5 minutos** por solicitud. - Daily retry fijo a las 9:00 AM y 4:00 PM CDMX. - Incremental Enterprise a las 11:00 AM, 3:00 PM y 7:00 PM CDMX. ## 13. Problemas conocidos 1. **Bloqueo `404 Error no controlado` del SAT**: Aparece cuando se hacen muchas consultas desde la misma IP. Mitigación temporal: reducir frecuencia de polling y metadata solo domingos. 2. **FIEL inválida**: Algunos tenants/contribuyentes tienen FIEL rechazada por el SAT. Requiere revisar/renovar FIEL. 3. **Jobs `initial` atorados en `running`**: Pueden quedar si el proceso se reinicia; el recovery cron y el watchdog los limpian. ## 14. Próximos pasos - [ ] Implementar proxies rotativos para evitar bloqueo por IP del SAT. - [ ] Monitorear tasa de éxito tras reducir polling y metadata solo domingos. - [ ] Revisar/renovar FIELs inválidas reportadas por el monitor.