7.2 KiB
Sincronización Incremental SAT para Enterprise
Resumen
Los clientes con plan Enterprise reciben sincronización con el SAT 3 veces al día (11:00, 15:00 y 19:00, zona horaria America/Mexico_City) adicional al daily de las 03:00 que aplica a todos los planes. Cada corrida incremental descarga XMLs y metadata de una ventana fija de las últimas 8 horas.
Motivación
El daily del cron nocturno cubre el ciclo fiscal completo pero deja al cliente Enterprise con una latencia de ~24h para ver CFDIs nuevos. El incremental de 3 corridas intradiarias reduce esa latencia a ~4h en horas de oficina, que es cuando se emite la mayoría del tráfico. Fuera de ese rango (19:00 → 03:00 del día siguiente) el daily se encarga.
Decisiones
| Aspecto | Decisión | Razón |
|---|---|---|
| Cron | 0 11,15,19 * * * |
3 disparos en horas con tráfico fiscal real |
| Zona horaria | America/Mexico_City |
Consistencia con el resto de crons del proyecto |
| Ventana por corrida | 8 horas hacia atrás | Cubre el gap máximo (03:00 → 11:00) sin dejar huecos |
| Elegibilidad | tenant.active && tenant.plan === 'enterprise' && hasFielConfigured(tenantId) |
tenant.plan es la fuente de verdad usada por feature-gate y plan-limits |
| Requisito previo | Tenant debe tener initial completado |
El incremental no debe actuar como primera descarga |
| Deduplicación | UNIQUE(uuid) en tabla cfdis |
El solape entre ventanas consecutivas no genera duplicados |
| Nuevo valor de enum | SatSyncType.incremental |
Diferenciable en historial, simplifica métricas/debugging |
Cobertura horaria
03:00 ── daily (XMLs últimos 7 días + metadata año fiscal completo)
│
11:00 ── incremental Enterprise ventana 03:00–11:00
15:00 ── incremental Enterprise ventana 07:00–15:00 (solape con anterior)
19:00 ── incremental Enterprise ventana 11:00–19:00 (solape con anterior)
│
19:00 → 03:00 del día siguiente: cubierto por el daily al arrancar
El solape entre disparos consecutivos es deliberado: la ventana de 8h es mayor que el gap entre corridas (4h). Esto cubre CFDIs que llegan a los servidores del SAT con retraso respecto a su fecha de emisión, sin duplicar datos en la BD.
Arquitectura
┌────────────────────────────┐
│ cron "0 11,15,19 * * *" │
│ (node-cron, America/MX) │
└──────────┬─────────────────┘
▼
┌────────────────────────────────────────┐
│ runIncrementalSyncJob() │
│ - Guard: isIncrementalRunning │
│ - getEnterpriseTenantsWithFiel() │
│ - Batch de CONCURRENT_SYNCS (3) tenants│
└──────────┬─────────────────────────────┘
▼
┌────────────────────────────────────────┐
│ incrementalSyncTenant(tenantId) │
│ - Omite si hay sync activo │
│ - Omite si no hay initial completado │
│ - startSync(tenantId, 'incremental') │
└──────────┬─────────────────────────────┘
▼
┌────────────────────────────────────────┐
│ processIncrementalSync(ctx, jobId) │
│ - ventana [now - 8h, now] │
│ - processDateRange × (emitidos, │
│ recibidos) │
│ - processMetadataRange × (emitidos, │
│ recibidos) │
└────────────────────────────────────────┘
Archivos modificados
| Archivo | Cambio |
|---|---|
apps/api/prisma/schema.prisma |
enum SatSyncType ahora incluye incremental |
packages/shared/src/types/sat.ts |
SatSyncType = 'initial' | 'daily' | 'incremental' |
apps/api/src/services/sat/sat.service.ts |
Nueva función processIncrementalSync. Branches en startSync y retryTimedOutJobs |
apps/api/src/jobs/sat-sync.job.ts |
Constante INCREMENTAL_CRON_SCHEDULE, funciones getEnterpriseTenantsWithFiel, incrementalSyncTenant, runIncrementalSyncJob. Export runIncrementalSyncJobManually. Registro/desregistro en startSatSyncJob/stopSatSyncJob |
Guardrails
- Un incremental a la vez por proceso: flag
isIncrementalRunningevita reentradas si una corrida toma más de lo esperado. - Un sync a la vez por tenant:
incrementalSyncTenantrevisagetSyncStatus(tenantId)y omite si ya hay un job corriendo (initial, daily, custom u otro incremental). - Primera sync debe ser
initial: si el tenant no tiene unSatSyncJobcontype: 'initial'ystatus: 'completed', el incremental se omite con un log y no crea job. La backfill debe correrse explícitamente (manual desde UI o el daily la detecta). - Concurrencia limitada:
CONCURRENT_SYNCS = 3— mismo límite que el daily para no saturar el SAT con solicitudes simultáneas desde la misma IP. - Reintentos: si el incremental falla por timeout del SAT, el cron horario de retries (
retryTimedOutJobs) lo reintenta hasta 3 veces con 6h de espera, igual que los otros tipos. - Deduplicación: constraint
UNIQUE(uuid)en tablacfdis(BD tenant) maneja solapes entre ventanas sin lógica adicional.
Deploy
Migración requerida (BD central)
Agregar el valor incremental al enum Postgres:
cd apps/api
pnpm prisma migrate dev --name add_incremental_sat_sync_type # dev
pnpm prisma migrate deploy # prod
Sin esta migración, al insertar SatSyncJob con type: 'incremental' el driver fallará con error de enum value.
Rebuild de shared
El tipo SatSyncType cambió en packages/shared:
pnpm build
Restart API
pm2 restart horux-api
Al arrancar, el log confirma el registro del cron:
[SAT Cron Inc] Incremental Enterprise programado para: 0 11,15,19 * * * (America/Mexico_City)
Testing manual
// Desde un script o endpoint de admin
import { runIncrementalSyncJobManually } from './jobs/sat-sync.job';
await runIncrementalSyncJobManually();
O en BD, verificar que se creó un SatSyncJob con type = 'incremental' para el tenant Enterprise esperado.
Futuro / pendientes
- Ventana dinámica: podría calcularse desde el último
incrementalcompletado del tenant en vez de fijarse en 8h. Reduciría solicitudes al SAT en solape pero agrega complejidad. No se hizo porque la dedup por UUID ya hace el solape gratuito en BD. - Frecuencia configurable por tenant: si algún Enterprise pide cadencia distinta, el schedule actual es global para todo el plan. Se podría mover a una columna
syncFrequencyentenants. - Alerta en dashboard Admin Global: mostrar cuándo fue el último
incrementalexitoso por tenant Enterprise, para detectar silencios.