# 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 `isIncrementalRunning` evita reentradas si una corrida toma más de lo esperado.
- **Un sync a la vez por tenant:** `incrementalSyncTenant` revisa `getSyncStatus(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 un `SatSyncJob` con `type: 'initial'` y `status: '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 tabla `cfdis` (BD tenant) maneja solapes entre ventanas sin lógica adicional.

## Deploy

### Migración requerida (BD central)

Agregar el valor `incremental` al enum Postgres:

```bash
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`:

```bash
pnpm build
```

### Restart API

```bash
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

```typescript
// 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 `incremental` completado 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 `syncFrequency` en `tenants`.
- **Alerta en dashboard Admin Global:** mostrar cuándo fue el último `incremental` exitoso por tenant Enterprise, para detectar silencios.
