feat(sat-sync): excluir tenant DEMO2501019X2 de sincronizaciones SAT

- Agrega SAT_SYNC_EXCLUDED_TENANTS en ecosystem.config.js con DEMO2501019X2.
- Filtra tenants excluidos en getTenantsWithFiel() para crons automáticos.
- Rechaza startSync() manual para tenants excluidos.
- Incluye script check-daily-sync.js para monitorear cobertura diaria.
This commit is contained in:
Horux Dev
2026-08-26 16:45:33 +00:00
parent a5636427be
commit 97f3991cb0
4 changed files with 377 additions and 103 deletions

View File

@@ -0,0 +1,143 @@
const { Client } = require('./node_modules/pg');
const HORUX_CONN = {
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb',
database: 'horux360',
};
const todayStart = new Date('2026-08-26T06:00:00.000Z'); // 00:00 CDMX approx
const todayEnd = new Date('2026-08-27T06:00:00.000Z');
async function query(client, sql, params = []) {
const res = await client.query(sql, params);
return res.rows;
}
async function main() {
const client = new Client(HORUX_CONN);
await client.connect();
// Tenants activos y su FIEL legacy (tenant-level, Horux 360 antiguo)
// Se excluyen cuentas demo/ficticias configuradas en SAT_SYNC_EXCLUDED_TENANTS.
const excluded = new Set(
(process.env.SAT_SYNC_EXCLUDED_TENANTS || '')
.split(',')
.map(s => s.trim().toUpperCase())
.filter(Boolean)
);
const tenants = await query(client, `
SELECT t.id, t.rfc, t.nombre, t.database_name,
EXISTS (
SELECT 1 FROM fiel_credentials fc
WHERE fc.tenant_id = t.id AND fc.is_active = true AND fc.valid_until > NOW()
) AS has_legacy_fiel
FROM tenants t
WHERE t.active = true
ORDER BY t.rfc
`);
const visibleTenants = tenants.filter(t => !excluded.has(t.id) && !excluded.has(t.rfc.toUpperCase()));
// Daily jobs de hoy
const dailyJobs = await query(client, `
SELECT tenant_id, contribuyente_id, cfdis_found, status, created_at
FROM sat_sync_jobs
WHERE type = 'daily'
AND created_at >= $1
AND created_at < $2
`, [todayStart, todayEnd]);
const dailyByTenantContrib = new Map();
for (const j of dailyJobs) {
const key = j.contribuyente_id ? `${j.tenant_id}:${j.contribuyente_id}` : `${j.tenant_id}:`;
dailyByTenantContrib.set(key, j);
}
const hadDaily = [];
const missing = [];
const noFiel = [];
for (const t of visibleTenants) {
if (!t.database_name) {
// Legacy tenant-wide (sin contribuyentes)
if (!t.has_legacy_fiel) {
noFiel.push({ tenantRfc: t.rfc, tenantName: t.nombre });
continue;
}
const key = `${t.id}:`;
const job = dailyByTenantContrib.get(key);
if (job) {
hadDaily.push({ tenantRfc: t.rfc, tenantName: t.nombre, contribuyenteId: null, contribuyenteRfc: null, contribuyenteName: null, cfdisFound: job.cfdis_found, status: job.status });
} else {
missing.push({ tenantRfc: t.rfc, tenantName: t.nombre, contribuyenteId: null, contribuyenteRfc: null, contribuyenteName: null, reason: 'tenant legacy con FIEL pero sin daily' });
}
continue;
}
// Conectar a la BD del tenant
const tenantClient = new Client({ ...HORUX_CONN, database: t.database_name });
try {
await tenantClient.connect();
// Todos los contribuyentes con info de FIEL (per-contribuyente o legacy)
const contribuyentes = await query(tenantClient, `
SELECT c.entidad_id, c.rfc, eg.nombre AS razon_social,
EXISTS (
SELECT 1 FROM fiel_contribuyente f
WHERE f.contribuyente_id = c.entidad_id
AND f.is_active = true AND f.valid_until > NOW()
) AS has_fiel
FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
ORDER BY eg.nombre
`);
for (const c of contribuyentes) {
const eligible = c.has_fiel || t.has_legacy_fiel;
const key = `${t.id}:${c.entidad_id}`;
const job = dailyByTenantContrib.get(key);
if (job) {
hadDaily.push({ tenantRfc: t.rfc, tenantName: t.nombre, contribuyenteId: c.entidad_id, contribuyenteRfc: c.rfc, contribuyenteName: c.razon_social, cfdisFound: job.cfdis_found, status: job.status });
} else if (eligible) {
missing.push({ tenantRfc: t.rfc, tenantName: t.nombre, contribuyenteId: c.entidad_id, contribuyenteRfc: c.rfc, contribuyenteName: c.razon_social, reason: 'sin daily' });
} else {
noFiel.push({ tenantRfc: t.rfc, tenantName: t.nombre, contribuyenteId: c.entidad_id, contribuyenteRfc: c.rfc, contribuyenteName: c.razon_social });
}
}
} catch (err) {
missing.push({ tenantRfc: t.rfc, tenantName: t.nombre, contribuyenteId: null, contribuyenteRfc: null, contribuyenteName: null, reason: `error consultando BD: ${err.message}` });
} finally {
try { await tenantClient.end(); } catch {}
}
}
await client.end();
console.log('=== CONTRIBUYENTES/TENANTS CON DAILY HOY ===');
console.log(`Total con daily: ${hadDaily.length}`);
for (const h of hadDaily) {
const name = h.contribuyenteName ? `${h.contribuyenteName} (${h.contribuyenteRfc})` : h.tenantName;
console.log(`- ${h.tenantRfc}: ${name} | CFDIs: ${h.cfdisFound ?? 'N/A'} | status: ${h.status}`);
}
console.log('\n=== CONTRIBUYENTES/TENANTS SIN DAILY HOY (con FIEL) ===');
console.log(`Total sin daily: ${missing.length}`);
for (const m of missing) {
const name = m.contribuyenteName ? `${m.contribuyenteName} (${m.contribuyenteRfc})` : m.tenantName;
console.log(`- ${m.tenantRfc}: ${name} [${m.reason}]`);
}
console.log('\n=== CONTRIBUYENTES/TENANTS SIN FIEL CONFIGURADA ===');
console.log(`Total sin FIEL: ${noFiel.length}`);
for (const n of noFiel) {
const name = n.contribuyenteName ? `${n.contribuyenteName} (${n.contribuyenteRfc})` : n.tenantName;
console.log(`- ${n.tenantRfc}: ${name}`);
}
}
main().catch(err => { console.error(err); process.exit(1); });

View File

@@ -11,18 +11,40 @@ import { consultarConstancia, purgeConstanciasAntiguas } from '../services/const
import { tenantDb } from '../config/database.js';
import type { Pool } from 'pg';
const SYNC_CRON_SCHEDULE = '0 6-10 * * *'; // 6:00–10:00 AM CDMX — ~20% de tenants por hora (5 grupos); el SAT cierra el servicio en la noche
const RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10:00 AM todos los días
const RETRY_9AM_CRON_SCHEDULE = '0 9 * * *'; // 9:00 AM todos los días
const SYNC_CRON_SCHEDULE = '0 3-7 * * *'; // 3:00–7:00 AM CDMX — ~20% de tenants por hora (5 grupos); ventana de menor carga
const RECOVERY_CRON_SCHEDULE = '0 14 * * *'; // 2:00 PM todos los días
const RETRY_4PM_CRON_SCHEDULE = '0 16 * * *'; // 4:00 PM todos los días
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas (legacy, se mantiene por compatibilidad)
const CONCURRENT_CONTRIBUYENTES = Number(process.env.SAT_CONCURRENT_CONTRIBUYENTES || '10'); // Máximo de contribuyentes en paralelo
const CONCURRENT_CONTRIBUYENTES = Number(process.env.SAT_CONCURRENT_CONTRIBUYENTES || '5'); // Máximo de contribuyentes en paralelo
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual)
const INCREMENTAL_CRON_SCHEDULE = '0 11,15,19 * * *'; // 11:00, 15:00 y 19:00; fuera de ese rango el daily (6-10 AM) cubre
const INCREMENTAL_CRON_SCHEDULE = '0 12,16 * * *'; // 12:00 PM y 4:00 PM CDMX
const BATCH_JITTER_MS = Number(process.env.SAT_BATCH_JITTER_MS || '60000'); // Jitter máximo entre inicios de sync en un batch (default 60s)
const SUBSCRIPTION_LIFECYCLE_CRON = '30 2 * * *'; // 2:30 AM diario — aplica pending changes + expira trials
const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos pre-vencimiento (7d/3d/1d/0d)
// Tenants excluidos de sincronizaciones SAT (cuentas demo/ficticias).
// Ejemplo: SAT_SYNC_EXCLUDED_TENANTS=DEMO2501019X2,OTRO_RFC
const EXCLUDED_TENANTS = new Set(
(process.env.SAT_SYNC_EXCLUDED_TENANTS || '')
.split(',')
.map(s => s.trim().toUpperCase())
.filter(Boolean)
);
function isTenantExcluded(tenantId: string, tenantRfc?: string): boolean {
return EXCLUDED_TENANTS.has(tenantId) || (tenantRfc ? EXCLUDED_TENANTS.has(tenantRfc.toUpperCase()) : false);
}
/**
* Pausa aleatoria para desalinear el inicio de syncs dentro de un batch.
* El jitter se aplica antes de cada syncUnit para evitar ráfagas.
*/
function jitterSleep(): Promise<void> {
const ms = Math.floor(Math.random() * BATCH_JITTER_MS);
return new Promise(resolve => setTimeout(resolve, ms));
}
let isRunning = false;
let isIncrementalRunning = false;
let isRecoveryRunning = false;
@@ -65,12 +87,16 @@ async function hasAnyFielConfigured(tenantId: string, databaseName?: string | nu
async function getTenantsWithFiel(): Promise<string[]> {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, databaseName: true },
select: { id: true, rfc: true, databaseName: true },
});
const tenantsWithFiel: string[] = [];
for (const tenant of tenants) {
if (isTenantExcluded(tenant.id, tenant.rfc)) {
console.log(`[SAT Cron] Tenant ${tenant.rfc || tenant.id} excluido de sincronizaciones SAT`);
continue;
}
const hasFiel = await hasAnyFielConfigured(tenant.id, tenant.databaseName);
if (hasFiel) {
tenantsWithFiel.push(tenant.id);
@@ -288,8 +314,8 @@ async function syncTenant(tenantId: string): Promise<void> {
/**
* Ejecuta el job de sincronización para todos los tenants
*/
const DAILY_GROUPS = 5; // ventanas 6,7,8,9,10 AM
const DAILY_WINDOW_START = 6; // primera ventana CDMX
const DAILY_GROUPS = 5; // ventanas 3,4,5,6,7 AM
const DAILY_WINDOW_START = 3; // primera ventana CDMX
/** Hash estable del tenantId → grupo 0..DAILY_GROUPS-1 (reparte ~20% por ventana) */
function tenantGroup(tenantId: string): number {
@@ -328,7 +354,7 @@ async function runSyncJob(): Promise<void> {
}
const hour = cdmxHour();
const groupIndex = hour - DAILY_WINDOW_START; // 6→0 … 10→4
const groupIndex = hour - DAILY_WINDOW_START; // 3→0 … 7→4
if (groupIndex < 0 || groupIndex >= DAILY_GROUPS) {
console.log(`[SAT Cron] Hora CDMX ${hour} fuera de ventana 6-10 AM, omitiendo`);
return;
@@ -353,7 +379,10 @@ async function runSyncJob(): Promise<void> {
// Procesar en lotes de contribuyentes para aprovechar los proxies
for (let i = 0; i < units.length; i += CONCURRENT_CONTRIBUYENTES) {
const batch = units.slice(i, i + CONCURRENT_CONTRIBUYENTES);
await Promise.all(batch.map(unit => syncUnit(unit, '[SAT Cron]')));
await Promise.all(batch.map(async (unit) => {
await jitterSleep(); // desalinear inicios y reducir ráfagas
return syncUnit(unit, '[SAT Cron]');
}));
// Pequeña pausa entre lotes
if (i + CONCURRENT_CONTRIBUYENTES < units.length) {
@@ -499,7 +528,10 @@ async function runIncrementalSyncJob(): Promise<void> {
for (let i = 0; i < units.length; i += CONCURRENT_CONTRIBUYENTES) {
const batch = units.slice(i, i + CONCURRENT_CONTRIBUYENTES);
await Promise.all(batch.map(unit => syncUnit(unit, '[SAT Cron Inc]')));
await Promise.all(batch.map(async (unit) => {
await jitterSleep(); // desalinear inicios y reducir ráfagas
return syncUnit(unit, '[SAT Cron Inc]');
}));
if (i + CONCURRENT_CONTRIBUYENTES < units.length) {
await new Promise(resolve => setTimeout(resolve, 5000));
@@ -742,7 +774,6 @@ async function runDailyRetryJob(): Promise<void> {
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
let retryTask: ReturnType<typeof cron.schedule> | null = null;
let recoveryTask: ReturnType<typeof cron.schedule> | null = null;
let retry9amTask: ReturnType<typeof cron.schedule> | null = null;
let retry4pmTask: ReturnType<typeof cron.schedule> | null = null;
let opinionTask: ReturnType<typeof cron.schedule> | null = null;
let csfTask: ReturnType<typeof cron.schedule> | null = null;
@@ -798,17 +829,7 @@ export function startSatSyncJob(): void {
});
// Retomar jobs diarios que quedaron pending por timeout de polling.
// 9:00 AM y 4:00 PM CDMX, complemento a los retries automáticos de 6h/12h.
retry9amTask = cron.schedule(RETRY_9AM_CRON_SCHEDULE, async () => {
try {
await runDailyRetryJob();
} catch (error: any) {
console.error('[SAT Daily Retry 9AM] Error:', error.message);
}
}, {
timezone: 'America/Mexico_City',
});
// 4:00 PM CDMX, complemento a los retries automáticos de 6h/12h.
retry4pmTask = cron.schedule(RETRY_4PM_CRON_SCHEDULE, async () => {
try {
await runDailyRetryJob();
@@ -925,7 +946,7 @@ export function startSatSyncJob(): void {
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[SAT Cron] Retry programado cada hora`);
console.log(`[SAT Recovery Cron] Programado para: ${RECOVERY_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[SAT Daily Retry] Programado para: ${RETRY_9AM_CRON_SCHEDULE} y ${RETRY_4PM_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[SAT Daily Retry] Programado para: ${RETRY_4PM_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[CSF Cron] Programado para: ${CSF_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`);
@@ -949,10 +970,6 @@ export function stopSatSyncJob(): void {
recoveryTask.stop();
recoveryTask = null;
}
if (retry9amTask) {
retry9amTask.stop();
retry9amTask = null;
}
if (retry4pmTask) {
retry4pmTask.stop();
retry4pmTask = null;

View File

@@ -7,10 +7,13 @@ import {
querySat,
verifySatRequest,
downloadSatPackage,
startOfMexicoDay,
SatNetworkError,
type FielData,
type ProxyInfo,
} from './sat-client.service.js';
import { processPackage, processMetadataPackage, extractXmlsFromZip, type CfdiParsed, type CfdiMetadata } from './sat-parser.service.js';
import { proxyManager } from './proxy.service.js';
import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../../utils/saldo.js';
import type { SatSyncJob, CfdiSyncType, SatSyncType } from '@horux/shared';
import type { Service } from '@nodecfdi/sat-ws-descarga-masiva';
@@ -18,10 +21,54 @@ import type { Pool } from 'pg';
import * as fs from 'fs';
import * as path from 'path';
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutos entre verificaciones
const MAX_POLL_ATTEMPTS = 9; // 9 intentos máximo por solicitud (~45 min total)
const DAILY_MAX_POLL_ATTEMPTS = 9; // igual para daily: 9 intentos × 5 min
/**
* Intervalos de polling con backoff corto (en minutos).
* Patrón: 5, 5, 5, 10, 10, 15, 15, 20, 20 minutos.
* Reduce la presión inicial sobre el SAT y sigue dando hasta ~105 min de espera.
*/
const POLL_INTERVALS_MIN = [5, 5, 5, 10, 10, 15, 15, 20, 20];
const POLL_INTERVALS_MS = POLL_INTERVALS_MIN.map(m => m * 60 * 1000);
const MAX_POLL_ATTEMPTS = POLL_INTERVALS_MS.length; // 9 intentos máximo por solicitud
const DAILY_MAX_POLL_ATTEMPTS = MAX_POLL_ATTEMPTS; // igual para daily
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
const SAT_MAX_RANGE_MONTHS = 71; // 5 años y 11 meses: margen seguro bajo el límite de 6 años del SAT
// Tenants excluidos de sincronizaciones SAT (cuentas demo/ficticias).
// Ejemplo: SAT_SYNC_EXCLUDED_TENANTS=DEMO2501019X2,OTRO_RFC
const EXCLUDED_TENANTS = new Set(
(process.env.SAT_SYNC_EXCLUDED_TENANTS || '')
.split(',')
.map(s => s.trim().toUpperCase())
.filter(Boolean)
);
async function isTenantExcludedFromSync(tenantId: string): Promise<boolean> {
if (EXCLUDED_TENANTS.has(tenantId)) return true;
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { rfc: true } });
return tenant?.rfc ? EXCLUDED_TENANTS.has(tenant.rfc.toUpperCase()) : false;
}
/**
* Recorta una fecha de inicio personalizada para que el rango [dateFrom, fechaFin]
* no exceda ~5 años 11 meses. El SAT rechaza solicitudes "mayor a 6 años de
* antigüedad"; este margen evita el error 301 en los chunks más antiguos.
*/
function clampDateFrom(fechaFin: Date, customDateFrom?: Date): Date {
const limite = new Date(
fechaFin.getFullYear(),
fechaFin.getMonth() - SAT_MAX_RANGE_MONTHS,
fechaFin.getDate(),
fechaFin.getHours(),
fechaFin.getMinutes(),
fechaFin.getSeconds(),
fechaFin.getMilliseconds(),
);
if (!customDateFrom || customDateFrom < limite) {
console.log(`[SAT] Rango recortado a ${limite.toISOString()} para no exceder 5 años 11 meses`);
return limite;
}
return customDateFrom;
}
/**
* Fecha final segura para consultas al SAT.
@@ -100,6 +147,40 @@ interface SyncContext {
getPool: () => Promise<Pool>;
}
/**
* Ejecuta una operación SAT reintentando con otro proxy si falla por error de red
* (timeout, ECONNRESET, 502, etc.). Actualiza el Service y proxyInfo del contexto
* para que las siguientes operaciones del mismo job usen el proxy que funcionó.
*/
async function withSatRetry<T>(
ctx: SyncContext,
operation: (service: Service) => Promise<T>,
maxProxyRetries = 2,
): Promise<T> {
let lastError: any;
for (let attempt = 0; attempt <= maxProxyRetries; attempt++) {
try {
return await operation(ctx.service);
} catch (error: any) {
lastError = error;
if (!(error instanceof SatNetworkError)) {
throw error;
}
const current = ctx.proxyInfo;
const fallback = proxyManager.getDifferentProxy(current);
if (!fallback) {
console.warn(`[SAT] No hay proxy alternativo disponible para failover`);
throw error;
}
console.warn(`[SAT] Proxy failover intento ${attempt + 1}: ${current?.host}:${current?.port} → ${fallback.host}:${fallback.port}`);
const { service, proxyInfo } = createSatService(ctx.fielData, fallback);
ctx.service = service;
ctx.proxyInfo = proxyInfo;
}
}
throw lastError;
}
/**
* Registra un error de bloqueo/devolución del SAT junto con el proxy usado.
* Permite reportes diarios de errores por IP.
@@ -660,48 +741,50 @@ async function requestAndDownload(
});
let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
// NOTA: se desactivó la reutilización de requestIds de jobs previos porque el SAT
// limita las descargas por solicitud. Reusar un requestId de un job anterior puede
// agotar el límite y devolver "Máximo de descargas permitidas", dejando el recovery
// sin poder descargar. Cada job nuevo crea sus propias solicitudes.
//
// Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente
// SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo).
// if (!existingMap[kindKey]) {
// const previousJob = await prisma.satSyncJob.findFirst({
// where: {
// tenantId: jobRow?.tenantId,
// contribuyenteId: jobRow?.contribuyenteId ?? null,
// id: { not: jobId },
// dateFrom: jobRow?.dateFrom,
// dateTo: jobRow?.dateTo,
// },
// orderBy: { createdAt: 'desc' },
// select: { satRequestIds: true },
// });
// if (previousJob?.satRequestIds) {
// const prevMap = previousJob.satRequestIds as Record<string, string>;
// if (prevMap[kindKey]) {
// console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
// // Copiar al job actual para futuros usos
// await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
// existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
// }
// }
// }
// Reutilización segura de requestIds entre jobs recientes:
// - Solo jobs del mismo tenant/contribuyente, tipo daily/incremental, estado completed.
// - Solo si el requestId fue creado en las últimas 48h (evita expiración >72h del SAT).
// - Solo si el kindKey (rango+tipo+requestType) coincide exactamente.
// Esto ahorra quota del SAT y evita crear solicitudes duplicadas para el mismo rango.
const REQUEST_ID_REUSE_MAX_AGE_HOURS = 48;
if (!existingMap[kindKey]) {
const reuseCutoff = new Date(Date.now() - REQUEST_ID_REUSE_MAX_AGE_HOURS * 60 * 60 * 1000);
const previousJob = await prisma.satSyncJob.findFirst({
where: {
tenantId: jobRow?.tenantId,
contribuyenteId: jobRow?.contribuyenteId ?? null,
id: { not: jobId },
type: { in: ['daily', 'incremental'] },
status: 'completed',
createdAt: { gte: reuseCutoff },
},
orderBy: { createdAt: 'desc' },
select: { satRequestIds: true },
});
if (previousJob?.satRequestIds) {
const prevMap = previousJob.satRequestIds as Record<string, string>;
if (prevMap[kindKey]) {
console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
}
}
}
let requestId: string | null = existingMap[kindKey] || null;
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
if (requestId) {
console.log(`[SAT] Reusando requestId previo (${label}): ${requestId}`);
const currentRequestId = requestId; // alias para estrechamiento de tipo
console.log(`[SAT] Reusando requestId previo (${label}): ${currentRequestId}`);
try {
verifyResult = await verifySatRequest(ctx.service, requestId);
verifyResult = await withSatRetry(ctx, service => verifySatRequest(service, currentRequestId));
console.log(`[SAT] Estado del request reusado (${label}): ${verifyResult.status}`);
// Estados terminales inválidos → descartar y crear nuevo
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') {
if (isAgotadas(verifyResult.message)) {
if (isAgotadas(verifyResult.message) || verifyResult.codeRequest === '5002') {
await recordSatProxyError(jobId, ctx, verifyResult.codeRequest || '5002', stageIdForTimeout(label), `${label}: solicitud reusada agotada de por vida`);
console.log(`[SAT] Solicitud reusada agotada de por vida (${label}); se cancela y se omite, no se recrea.`);
return { packageContents: [], totalCfdis: 0 };
}
@@ -722,10 +805,12 @@ async function requestAndDownload(
if (!requestId) {
console.log(`[SAT] Solicitando ${label} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`);
const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi, requestType);
const queryResult = await withSatRetry(ctx, service => querySat(service, fechaInicio, fechaFin, tipoCfdi, requestType));
if (!queryResult.success) {
if (queryResult.statusCode === '5004' || queryResult.statusCode === 'exhausted' || isAgotadas(queryResult.message)) {
if (queryResult.statusCode === '5004' || queryResult.statusCode === '5002' || isAgotadas(queryResult.message)) {
const satCode = queryResult.statusCode === '5002' || isAgotadas(queryResult.message) ? '5002' : '5004';
await recordSatProxyError(jobId, ctx, satCode, stageIdForTimeout(label), `${label}: ${queryResult.message}`);
console.log(`[SAT] Sin CFDIs, quota agotada o solicitudes agotadas (${label}): ${queryResult.message}`);
return { packageContents: [], totalCfdis: 0 };
}
@@ -755,15 +840,17 @@ async function requestAndDownload(
const maxAttempts = isDaily ? DAILY_MAX_POLL_ATTEMPTS : MAX_POLL_ATTEMPTS;
let attempts = 0;
while (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
const delayMs = POLL_INTERVALS_MS[attempts];
await new Promise(resolve => setTimeout(resolve, delayMs));
attempts++;
verifyResult = await verifySatRequest(ctx.service, requestId);
console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts}/${maxAttempts})`);
verifyResult = await withSatRetry(ctx, service => verifySatRequest(service, requestId));
console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts}/${maxAttempts}, espera ${delayMs / 60000}min)`);
if (verifyResult.status === 'ready') break;
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') {
if (isAgotadas(verifyResult.message)) {
if (isAgotadas(verifyResult.message) || verifyResult.codeRequest === '5002') {
await recordSatProxyError(jobId, ctx, verifyResult.codeRequest || '5002', stageIdForTimeout(label), `${label}: solicitudes agotadas de por vida`);
console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite este rango.`);
return { packageContents: [], totalCfdis: 0 };
}
@@ -777,12 +864,18 @@ async function requestAndDownload(
throw new SatSyncTimeoutError(stageIdForTimeout(label), `Timeout esperando respuesta del SAT (${label})`);
}
// Registrar EmptyResult (5004) / Exhausted (5002) cuando el verify termina en
// ready con 0 paquetes. Esto permite distinguir "no hay CFDIs" de quota agotada.
if (verifyResult.totalCfdis === 0 && verifyResult.packageIds.length === 0 && verifyResult.codeRequest) {
await recordSatProxyError(jobId, ctx, verifyResult.codeRequest, stageIdForTimeout(label), `${label}: ${verifyResult.message}`);
}
const packageContents: string[] = [];
for (let i = 0; i < verifyResult.packageIds.length; i++) {
const packageId = verifyResult.packageIds[i];
console.log(`[SAT] Descargando paquete ${label} ${i + 1}/${verifyResult.packageIds.length}: ${packageId}`);
const downloadResult = await downloadSatPackage(ctx.service, packageId);
const downloadResult = await withSatRetry(ctx, service => downloadSatPackage(service, packageId));
if (!downloadResult.success) {
console.error(`[SAT] Error descargando paquete ${packageId}: ${downloadResult.message}`);
continue;
@@ -992,9 +1085,9 @@ async function processInitialSync(
// Si el usuario pasó un rango explícito lo respetamos (aunque podría fallar
// si pone "hoy").
const fechaFin = customDateTo || getYesterdayEnd();
// Exactamente 6 años atrás desde la fecha final (mismo día del mes), no inicio de mes.
// El SAT rechaza "mayor a 6 años" si usamos el día 1 del mes hace 6 años.
const inicioHistorico = customDateFrom || new Date(fechaFin.getFullYear() - YEARS_TO_SYNC, fechaFin.getMonth(), fechaFin.getDate());
// Inicio histórico: 5 años y 11 meses atrás. El SAT rechaza rangos "mayor a 6 años",
// así que dejamos 1 mes de margen de seguridad respecto al límite legal.
const inicioHistorico = clampDateFrom(fechaFin, customDateFrom);
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
const chunkMonths = await determineChunkMonths(ctx, jobId, inicioHistorico, fechaFin);
@@ -1025,6 +1118,7 @@ async function processInitialSync(
}
// Paso 2: Descargar XMLs de vigentes (bloques de 3/6 meses)
const xmlErrors: { stage: string; bloque: number; start: string; end: string; message: string }[] = [];
for (let i = 0; i < xmlChunks.length; i++) {
const { start, end } = xmlChunks[i];
console.log(`[SAT] XML bloque ${i + 1}/${xmlChunks.length}: ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`);
@@ -1037,6 +1131,7 @@ async function processInitialSync(
totalUpdated += emitidos.updated;
} catch (error: any) {
console.error(`[SAT] Error emitidos XML bloque ${i + 1}:`, error.message);
xmlErrors.push({ stage: 'xml-emitidos', bloque: i + 1, start: start.toISOString(), end: end.toISOString(), message: error.message });
}
await reportProgress();
@@ -1048,6 +1143,7 @@ async function processInitialSync(
totalUpdated += recibidos.updated;
} catch (error: any) {
console.error(`[SAT] Error recibidos XML bloque ${i + 1}:`, error.message);
xmlErrors.push({ stage: 'xml-recibidos', bloque: i + 1, start: start.toISOString(), end: end.toISOString(), message: error.message });
}
await reportProgress();
@@ -1055,6 +1151,7 @@ async function processInitialSync(
}
// Paso 3: Descargar metadata (bloques anuales)
const metadataErrors: { stage: string; bloque: number; start: string; end: string; message: string }[] = [];
for (let i = 0; i < metaChunks.length; i++) {
const { start, end } = metaChunks[i];
console.log(`[SAT] Metadata bloque ${i + 1}/${metaChunks.length}: ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`);
@@ -1065,6 +1162,7 @@ async function processInitialSync(
totalUpdated += updated;
} catch (error: any) {
console.error(`[SAT] Error metadata emitidos bloque ${i + 1}:`, error.message);
metadataErrors.push({ stage: 'metadata-emitidos', bloque: i + 1, start: start.toISOString(), end: end.toISOString(), message: error.message });
}
await reportProgress();
@@ -1074,6 +1172,7 @@ async function processInitialSync(
totalUpdated += updated;
} catch (error: any) {
console.error(`[SAT] Error metadata recibidos bloque ${i + 1}:`, error.message);
metadataErrors.push({ stage: 'metadata-recibidos', bloque: i + 1, start: start.toISOString(), end: end.toISOString(), message: error.message });
}
await reportProgress();
@@ -1086,7 +1185,16 @@ async function processInitialSync(
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
progressPercent: 100,
errorMessage: xmlErrors.length > 0 || metadataErrors.length > 0
? JSON.stringify({ xmlErrors, metadataErrors }, null, 2)
: undefined,
});
// Si fallo alguna etapa de XMLs, marcamos el job como failed para que sea
// facil identificarlo y repetir la sincronizacion mas tarde.
if (xmlErrors.length > 0) {
throw new Error(`Fallo en etapa de XMLs: ${xmlErrors.map(e => `${e.stage} bloque ${e.bloque}`).join(', ')}`);
}
}
/**
@@ -1100,9 +1208,10 @@ async function processInitialSync(
async function processCustomRangeSync(
ctx: SyncContext,
jobId: string,
dateFrom: Date,
dateFromInput: Date,
dateTo: Date
): Promise<void> {
const dateFrom = clampDateFrom(dateTo, dateFromInput);
const diffMs = dateTo.getTime() - dateFrom.getTime();
const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30);
const MAX_MONTHS_DIRECT = 6;
@@ -1175,41 +1284,37 @@ async function processCustomRangeSync(
}
/**
* Sincronización incremental: ventana fija de las últimas 8 horas.
* Diseñada para correr 3 veces al día (11:00, 15:00, 19:00) en clientes Enterprise.
* La ventana de 8h cubre el gap máximo (03:00 → 11:00) entre el daily y el primer
* incremental; los disparos siguientes solapan, pero la unicidad del UUID deduplica.
* Fuera de ese rango, el daily de 03:00 se encarga.
* Sincronización incremental: rango ayer → hoy (hora CDMX).
* Corre 3 veces al día (11:00, 15:00 y 19:00) en clientes Enterprise.
* El SAT solo acepta rangos de días calendario distintos, así que la ventana
* mínima válida es ayer 00:00 → hoy 00:00. Cada ejecución recupera las
* facturas de hoy que el SAT ya haya procesado desde la última corrida.
*
* Solo descargamos XMLs (cfdi); la metadata histórica la cubre el daily
* (domingos) y el recovery cron. Incluir metadata en incremental hace que
* el job exceda el umbral del watchdog porque los paquetes de metadata de
* 1 día tardan demasiado en generarse.
* Los disparos solapan, pero la unicidad del UUID deduplica.
*/
const INCREMENTAL_WINDOW_HOURS = 8;
async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise<void> {
// Retrocedemos 2h respecto a ahora para evitar que el SAT vea una fecha final
// futura / demasiado reciente (rechazo "Fecha final invalida").
const ahora = new Date(Date.now() - 2 * 60 * 60 * 1000);
const desde = new Date(ahora.getTime() - INCREMENTAL_WINDOW_HOURS * 60 * 60 * 1000);
const fechaFin = startOfMexicoDay(0); // hoy 00:00 CDMX
const desde = startOfMexicoDay(-1); // ayer 00:00 CDMX
let totalFound = 0;
let totalDownloaded = 0;
let totalInserted = 0;
let totalUpdated = 0;
console.log(`[SAT] Incremental: ${desde.toISOString()} → ${ahora.toISOString()} (${INCREMENTAL_WINDOW_HOURS}h)`);
console.log(`[SAT] Incremental: ${desde.toISOString()} → ${fechaFin.toISOString()} (ayer → hoy CDMX)`);
for (const tipo of ['emitidos', 'recibidos'] as const) {
const result = await processDateRange(ctx, jobId, desde, ahora, tipo);
const result = await processDateRange(ctx, jobId, desde, fechaFin, tipo);
totalFound += result.found;
totalDownloaded += result.downloaded;
totalInserted += result.inserted;
totalUpdated += result.updated;
}
for (const tipo of ['emitidos', 'recibidos'] as const) {
const { inserted, updated } = await processMetadataRange(ctx, jobId, desde, ahora, tipo);
totalInserted += inserted;
totalUpdated += updated;
}
await updateJobProgress(jobId, {
cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded,
@@ -1483,15 +1588,17 @@ async function requestAndDownloadWithId(
let attempts = 0;
const maxAttempts = isDaily ? DAILY_MAX_POLL_ATTEMPTS : MAX_POLL_ATTEMPTS;
while (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
const delayMs = POLL_INTERVALS_MS[attempts];
await new Promise(resolve => setTimeout(resolve, delayMs));
attempts++;
verifyResult = await verifySatRequest(ctx.service, requestId);
console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts}/${maxAttempts})`);
verifyResult = await withSatRetry(ctx, service => verifySatRequest(service, requestId));
console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts}/${maxAttempts}, espera ${delayMs / 60000}min)`);
if (verifyResult.status === 'ready') break;
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') {
if (isAgotadas(verifyResult.message)) {
if (isAgotadas(verifyResult.message) || verifyResult.codeRequest === '5002') {
await recordSatProxyError(jobId, ctx, verifyResult.codeRequest || '5002', stageIdForTimeout(label), `${label}: solicitudes agotadas de por vida`);
console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite.`);
return { inserted: 0, updated: 0 };
}
@@ -1507,7 +1614,7 @@ async function requestAndDownloadWithId(
const packageId = verifyResult.packageIds[i];
console.log(`[SAT] Descargando paquete ${label} ${i + 1}/${verifyResult.packageIds.length}: ${packageId}`);
const downloadResult = await downloadSatPackage(ctx.service, packageId);
const downloadResult = await withSatRetry(ctx, service => downloadSatPackage(service, packageId));
if (!downloadResult.success) {
console.error(`[SAT] Error descargando paquete ${packageId}: ${downloadResult.message}`);
continue;
@@ -1534,6 +1641,10 @@ export async function startSync(
dateTo?: Date,
contribuyenteId?: string
): Promise<string> {
if (await isTenantExcludedFromSync(tenantId)) {
throw new Error('Este tenant está excluido de sincronizaciones SAT');
}
// Try per-contribuyente FIEL first (despachos), then legacy (Horux360)
let decryptedFiel = null;
if (contribuyenteId) {
@@ -1585,16 +1696,18 @@ export async function startSync(
const now = new Date();
// `isCustomRange` solo aplica a 'initial' con fechas explícitas del UI.
// Bootstrap puro = initial sin fechas → usa default de 6 años atrás.
// Bootstrap puro = initial sin fechas → usa el límite seguro de 5 años 11 meses.
const isCustomRange = type === 'initial' && (!!dateFrom || !!dateTo);
const effectiveDateTo = dateTo || now;
const effectiveDateFrom = clampDateFrom(effectiveDateTo, dateFrom);
const job = await prisma.satSyncJob.create({
data: {
tenantId,
contribuyenteId: contribuyenteId || null,
type,
status: 'running',
dateFrom: dateFrom || new Date(now.getFullYear() - YEARS_TO_SYNC, 0, 1),
dateTo: dateTo || now,
dateFrom: effectiveDateFrom,
dateTo: effectiveDateTo,
startedAt: now,
isCustomRange,
proxyUsed: proxyInfo ? `${proxyInfo.host}:${proxyInfo.port}` : null,
@@ -1616,11 +1729,11 @@ export async function startSync(
(async () => {
try {
if (type === 'initial') {
await processInitialSync(ctx, job.id, dateFrom, dateTo);
await processInitialSync(ctx, job.id, effectiveDateFrom, dateTo);
} else if (type === 'incremental') {
await processIncrementalSync(ctx, job.id);
} else if (dateFrom && dateTo) {
await processCustomRangeSync(ctx, job.id, dateFrom, dateTo);
await processCustomRangeSync(ctx, job.id, effectiveDateFrom, dateTo);
} else {
await processDailySync(ctx, job.id);
}

View File

@@ -15,6 +15,7 @@ module.exports = {
env: {
NODE_ENV: 'production',
PORT: 4000,
SAT_SYNC_EXCLUDED_TENANTS: 'DEMO2501019X2',
},
},
{