From dfc0183c122503f1539cb33541013c793346088d Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Sun, 2 Aug 2026 19:22:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(sat):=20metadata=20hist=C3=B3rica=20solo?= =?UTF-8?q?=20domingos,=20404=20no=20fatal=20en=20daily,=20polling=209x5mi?= =?UTF-8?q?n=20y=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - processDailySync ejecuta metadata histórica únicamente los domingos en CDMX. - Errores 404 en daily se registran en error_message sin abortar el job. - Timeouts/transitorios reales siguen lanzándose para reintento. - Polling reducido a 9 intentos máximos cada 5 minutos por solicitud. - Actualiza docs/SAT-SYNC-IMPLEMENTATION.md con arquitectura, crons, manejo de errores y comandos. --- apps/api/src/services/sat/sat.service.ts | 664 ++++++++++++++++++++--- docs/SAT-SYNC-IMPLEMENTATION.md | 438 +++++++-------- 2 files changed, 782 insertions(+), 320 deletions(-) diff --git a/apps/api/src/services/sat/sat.service.ts b/apps/api/src/services/sat/sat.service.ts index 1de919d..d0cd968 100644 --- a/apps/api/src/services/sat/sat.service.ts +++ b/apps/api/src/services/sat/sat.service.ts @@ -17,10 +17,24 @@ import type { Pool } from 'pg'; import * as fs from 'fs'; import * as path from 'path'; -const POLL_INTERVAL_MS = 60000; // 60 segundos -const MAX_POLL_ATTEMPTS = 500; // ~8 horas máximo para syncs iniciales grandes +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 const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años +/** + * Fecha final segura para consultas al SAT. + * + * El SAT rechaza fechas futuras e incluso "hoy" en algunos horarios/condiciones, + * devolviendo "Fecha final invalida". Usamos el día anterior a medio día UTC, + * que al interpretarse en America/Mexico_City siempre cae en "ayer" y evita + * tanto fechas futuras como problemas de cambio de día por zona horaria. + */ +function getYesterdayEnd(): Date { + const now = new Date(); + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1, 12, 0, 0)); +} + /** * Política de retry por tipo de sync. * - `retryAtHours[i]` = horas DESDE startedAt para el retry i+1. @@ -49,6 +63,12 @@ const RETRY_POLICIES: Record<'daily' | 'custom' | 'initial' | 'incremental', { incremental: { maxRetries: 0, retryAtHours: [] }, }; +/** + * Límite total de intentos para jobs diarios. Incluye el intento original más + * retries automáticos (6h/12h) y los retries fijos de 9 AM / 4 PM CDMX. + */ +const MAX_DAILY_RETRY_ATTEMPTS = 5; + function getRetryPolicy(job: { type: SatSyncType; isCustomRange: boolean }) { if (job.type === 'initial' && job.isCustomRange) return RETRY_POLICIES.custom; return RETRY_POLICIES[job.type]; @@ -598,6 +618,7 @@ async function requestAndDownload( fechaFin: Date, tipoCfdi: CfdiSyncType, requestType: 'cfdi' | 'metadata', + isDaily = false, ): Promise<{ packageContents: string[]; totalCfdis: number }> { const label = `${tipoCfdi}/${requestType}`; const kindKey = makeRequestKindKey(fechaInicio, fechaFin, tipoCfdi, requestType); @@ -650,6 +671,10 @@ async function requestAndDownload( // Estados terminales inválidos → descartar y crear nuevo if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') { + if (isAgotadas(verifyResult.message)) { + console.log(`[SAT] Solicitud reusada agotada de por vida (${label}); se cancela y se omite, no se recrea.`); + return { packageContents: [], totalCfdis: 0 }; + } console.log(`[SAT] Request reusado en estado ${verifyResult.status}, creando nuevo`); requestId = null; verifyResult = undefined; @@ -670,10 +695,20 @@ async function requestAndDownload( const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi, requestType); if (!queryResult.success) { - if (queryResult.statusCode === '5004') { - console.log(`[SAT] No se encontraron CFDIs (${label})`); + if (queryResult.statusCode === '5004' || queryResult.statusCode === 'exhausted' || isAgotadas(queryResult.message)) { + console.log(`[SAT] Sin CFDIs, quota agotada o solicitudes agotadas (${label}): ${queryResult.message}`); return { packageContents: [], totalCfdis: 0 }; } + if (/error no controlado/i.test(queryResult.message || '')) { + 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. + console.warn(`[SAT] Rechazo 404 del SAT en daily (${label}): ${queryResult.message} — se registra y continúa`); + throw new Error(`SAT 404 en daily (${label}): ${queryResult.message}`); + } + console.warn(`[SAT] Rechazo transitorio del SAT (${label}): ${queryResult.message} — se reintentará`); + throw new SatTransientError(stageIdForTimeout(label), queryResult.message); + } throw new Error(`Error SAT (${label}): ${queryResult.message}`); } @@ -685,23 +720,28 @@ async function requestAndDownload( // Polling — si el reuse ya devolvió `ready`, salta el loop directamente. if (!verifyResult || verifyResult.status !== 'ready') { + const maxAttempts = isDaily ? DAILY_MAX_POLL_ATTEMPTS : MAX_POLL_ATTEMPTS; let attempts = 0; - while (attempts < MAX_POLL_ATTEMPTS) { + while (attempts < maxAttempts) { await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); attempts++; verifyResult = await verifySatRequest(ctx.service, requestId); - console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts})`); + console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts}/${maxAttempts})`); if (verifyResult.status === 'ready') break; if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') { + if (isAgotadas(verifyResult.message)) { + console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite este rango.`); + return { packageContents: [], totalCfdis: 0 }; + } throw new Error(`Solicitud fallida (${label}): ${verifyResult.message}`); } } } if (!verifyResult || verifyResult.status !== 'ready') { - throw new Error(`Timeout esperando respuesta del SAT (${label})`); + throw new SatSyncTimeoutError(stageIdForTimeout(label), `Timeout esperando respuesta del SAT (${label})`); } const packageContents: string[] = []; @@ -741,17 +781,20 @@ async function processDateRange( fechaInicio: Date, fechaFin: Date, tipoCfdi: CfdiSyncType, - skipJobUpdate = false -): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> { + skipJobUpdate = false, + throwOnError = false, + isDaily = false +): Promise<{ found: number; downloaded: number; inserted: number; updated: number; errors: { message: string }[] }> { let totalFound = 0; let totalDownloaded = 0; let totalInserted = 0; let totalUpdated = 0; + const errors: { message: string }[] = []; // Solo XMLs de vigentes (datos completos) try { const { packageContents, totalCfdis } = await requestAndDownload( - ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'cfdi' + ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'cfdi', isDaily ); totalFound += totalCfdis; @@ -766,6 +809,8 @@ async function processDateRange( } } catch (error: any) { console.error(`[SAT] Error en XMLs ${tipoCfdi}: ${error.message}`); + if (throwOnError) throw error; + errors.push({ message: error.message || `Error desconocido en XMLs ${tipoCfdi}` }); } if (!skipJobUpdate) { @@ -782,6 +827,7 @@ async function processDateRange( downloaded: totalDownloaded, inserted: totalInserted, updated: totalUpdated, + errors, }; } @@ -794,14 +840,17 @@ async function processMetadataRange( jobId: string, fechaInicio: Date, fechaFin: Date, - tipoCfdi: CfdiSyncType -): Promise<{ inserted: number; updated: number }> { + tipoCfdi: CfdiSyncType, + throwOnError = false, + isDaily = false +): Promise<{ inserted: number; updated: number; errors: { message: string }[] }> { let totalInserted = 0; let totalUpdated = 0; + const errors: { message: string }[] = []; try { const { packageContents } = await requestAndDownload( - ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'metadata' + ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'metadata', isDaily ); for (const content of packageContents) { @@ -814,9 +863,11 @@ async function processMetadataRange( } } catch (error: any) { console.error(`[SAT] Error en metadata ${tipoCfdi}: ${error.message}`); + if (throwOnError) throw error; + errors.push({ message: error.message || `Error desconocido en metadata ${tipoCfdi}` }); } - return { inserted: totalInserted, updated: totalUpdated }; + return { inserted: totalInserted, updated: totalUpdated, errors }; } /** @@ -904,11 +955,13 @@ async function processInitialSync( customDateFrom?: Date, customDateTo?: Date ): Promise { - const ahora = new Date(); - // Exactamente 6 años atrás desde hoy (mismo día del mes), no inicio de mes. + // El SAT rechaza fechas futuras; por defecto usamos ayer como fecha final. + // 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(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), ahora.getDate()); - const fechaFin = customDateTo || ahora; + const inicioHistorico = customDateFrom || new Date(fechaFin.getFullYear() - YEARS_TO_SYNC, fechaFin.getMonth(), fechaFin.getDate()); // Paso 1: Sondeo — determinar tamaño de bloque para XMLs const chunkMonths = await determineChunkMonths(ctx, jobId, inicioHistorico, fechaFin); @@ -1098,7 +1151,9 @@ async function processCustomRangeSync( const INCREMENTAL_WINDOW_HOURS = 8; async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise { - const ahora = new Date(); + // 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); let totalFound = 0; @@ -1109,25 +1164,17 @@ async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise< console.log(`[SAT] Incremental: ${desde.toISOString()} → ${ahora.toISOString()} (${INCREMENTAL_WINDOW_HOURS}h)`); for (const tipo of ['emitidos', 'recibidos'] as const) { - try { - const result = await processDateRange(ctx, jobId, desde, ahora, tipo); - totalFound += result.found; - totalDownloaded += result.downloaded; - totalInserted += result.inserted; - totalUpdated += result.updated; - } catch (error: any) { - console.error(`[SAT] Error incremental XMLs ${tipo}:`, error.message); - } + const result = await processDateRange(ctx, jobId, desde, ahora, tipo); + totalFound += result.found; + totalDownloaded += result.downloaded; + totalInserted += result.inserted; + totalUpdated += result.updated; } for (const tipo of ['emitidos', 'recibidos'] as const) { - try { - const { inserted, updated } = await processMetadataRange(ctx, jobId, desde, ahora, tipo); - totalInserted += inserted; - totalUpdated += updated; - } catch (error: any) { - console.error(`[SAT] Error incremental metadata ${tipo}:`, error.message); - } + const { inserted, updated } = await processMetadataRange(ctx, jobId, desde, ahora, tipo); + totalInserted += inserted; + totalUpdated += updated; } await updateJobProgress(jobId, { @@ -1138,51 +1185,310 @@ async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise< }); } -async function processDailySync(ctx: SyncContext, jobId: string): Promise { - const ahora = new Date(); +/** + * Error usado para señalar que una etapa del sync diario excedió el tiempo + * de espera al SAT. Lleva el identificador de la etapa para poder retomar + * desde el mismo punto en los retries programados. + */ +class SatSyncTimeoutError extends Error { + constructor( + public readonly stageId: string, + message: string + ) { + super(message); + this.name = 'SatSyncTimeoutError'; + } +} + +/** + * Rechazo transitorio del SAT (p. ej. "Error no controlado", típico de + * throttling cuando se lanzan muchas solicitudes en ráfaga). A diferencia de + * "solicitudes agotadas", SÍ vale la pena reintentarlo: se comporta como un + * timeout — se estaciona la etapa y los retries de 9 AM / 4 PM la retoman. + */ +class SatTransientError extends Error { + constructor( + public readonly stageId: string, + message: string + ) { + super(message); + this.name = 'SatTransientError'; + } +} + +/** + * El daily terminó sus etapas XML y parte de las de metadata, pero uno o más + * chunks de metadata aún no tienen paquetes listos. No es un fallo del SAT: + * el requestId ya quedó persistido y los retries de 9 AM / 4 PM lo retoman. + * Se trata como "pending" (no consume el job entero) en vez de abortar. + */ +class SatMetadataPendingError extends Error { + constructor( + public readonly stageId: string, + public readonly pendingStages: string[], + message: string, + ) { + super(message); + this.name = 'SatMetadataPendingError'; + } +} + +/** + * Detecta la respuesta del SAT "Se han agotado las solicitudes de por vida": + * se agotaron las solicitudes máximas para ese rango de fecha. No es + * transitorio — no vale la pena seguir intentando ni recrear la solicitud; + * hay que cancelarla y omitir ese rango. + */ +function isAgotadas(message?: string | null): boolean { + return !!message && message.toLowerCase().includes('agotad'); +} + +/** + * Genera un stageId a partir de la etiqueta de una solicitud SAT. + * Usado cuando requestAndDownload detecta timeout y no conoce el stage exacto. + */ +function stageIdForTimeout(label: string): string { + // label tiene forma "tipoCfdi/requestType" (ej. "emitidos/cfdi" o "recibidos/metadata") + const [tipo, requestType] = label.split('/'); + if (requestType === 'cfdi') return `xml-${tipo}-7d`; + return `metadata-${tipo}-chunk`; +} + +function isSundayInCDMX(now: Date = new Date()): boolean { + return new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Mexico_City', + weekday: 'short', + }).format(now) === 'Sun'; +} + +async function processDailySync( + ctx: SyncContext, + jobId: string, + resumeFromStage?: string +): Promise { + // Usamos ayer como fecha final para evitar el rechazo "Fecha final invalida" + // del SAT cuando se consulta con la fecha actual. + const ahora = getYesterdayEnd(); const inicioAño = new Date(ahora.getFullYear(), 0, 1); const hace7Dias = new Date(ahora.getTime() - 7 * 24 * 60 * 60 * 1000); + const ejecutarMetadataHistorica = isSundayInCDMX(); let totalFound = 0; let totalDownloaded = 0; let totalInserted = 0; let totalUpdated = 0; - // Paso 1: XMLs de los últimos 7 días (CFDIs nuevos) - console.log(`[SAT] Daily: XMLs desde ${hace7Dias.toISOString().slice(0, 10)} → ${ahora.toISOString().slice(0, 10)}`); + interface DailyStage { + id: string; + label: string; + isMetadata: boolean; + run: () => Promise; + } - for (const tipo of ['emitidos', 'recibidos'] as const) { - try { - const result = await processDateRange(ctx, jobId, hace7Dias, ahora, tipo); + const makeStage = ( + id: string, + label: string, + isMetadata: boolean, + runImpl: (stageId: string) => Promise, + ): DailyStage => ({ id, label, isMetadata, run: () => runImpl(id) }); + + const stages: DailyStage[] = [ + makeStage('xml-emitidos-7d', 'XMLs emitidos últimos 7 días', false, async (id) => { + const result = await processDateRange(ctx, jobId, hace7Dias, ahora, 'emitidos', true, false, true); totalFound += result.found; totalDownloaded += result.downloaded; totalInserted += result.inserted; totalUpdated += result.updated; - } catch (error: any) { - console.error(`[SAT] Error XMLs ${tipo} (7 días):`, error.message); + if (result.errors.length > 0) { + for (const e of result.errors) nonFatalErrors.push({ stage: id, message: e.message }); + } + }), + makeStage('xml-recibidos-7d', 'XMLs recibidos últimos 7 días', false, async (id) => { + const result = await processDateRange(ctx, jobId, hace7Dias, ahora, 'recibidos', true, false, true); + totalFound += result.found; + totalDownloaded += result.downloaded; + totalInserted += result.inserted; + totalUpdated += result.updated; + if (result.errors.length > 0) { + for (const e of result.errors) nonFatalErrors.push({ stage: id, message: e.message }); + } + }), + ]; + + // La metadata histórica (desde inicio de año) consume muchas solicitudes al SAT + // y no cambia día a día. Solo la ejecutamos los domingos para reducir carga. + if (ejecutarMetadataHistorica) { + const metaChunks = generateChunks(inicioAño, ahora, 3); + // Recorrer de más nuevo a más viejo: si un bloque antiguo da timeout, los + // recientes (más relevantes) ya quedaron procesados y no bloquean el avance. + for (let i = metaChunks.length - 1; i >= 0; i--) { + const { start, end } = metaChunks[i]; + const chunkLabel = `${start.toISOString().slice(0, 10)}_${end.toISOString().slice(0, 10)}`; + stages.push(makeStage( + `metadata-emitidos-${chunkLabel}`, + `Metadata emitidos ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`, + true, + async (id) => { + const { inserted, updated, errors } = await processMetadataRange(ctx, jobId, start, end, 'emitidos', false, true); + totalInserted += inserted; + totalUpdated += updated; + if (errors.length > 0) { + for (const e of errors) nonFatalErrors.push({ stage: id, message: e.message }); + } + }, + )); + stages.push(makeStage( + `metadata-recibidos-${chunkLabel}`, + `Metadata recibidos ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`, + true, + async (id) => { + const { inserted, updated, errors } = await processMetadataRange(ctx, jobId, start, end, 'recibidos', false, true); + totalInserted += inserted; + totalUpdated += updated; + if (errors.length > 0) { + for (const e of errors) nonFatalErrors.push({ stage: id, message: e.message }); + } + }, + )); } } - // Paso 2: Metadata del ciclo fiscal actual (enero → hoy) - // Captura cancelaciones y cambios de status del año completo - console.log(`[SAT] Daily: Metadata desde ${inicioAño.toISOString().slice(0, 10)} → ${ahora.toISOString().slice(0, 10)}`); + const totalStages = stages.length; + let activeStageIndex = 0; + if (resumeFromStage) { + const idx = stages.findIndex(s => s.id === resumeFromStage); + if (idx >= 0) { + activeStageIndex = idx; + console.log(`[SAT Daily] Retomando desde etapa ${resumeFromStage} (${idx + 1}/${totalStages})`); + } else { + console.log(`[SAT Daily] No se encontró etapa ${resumeFromStage}, iniciando desde el principio`); + activeStageIndex = 0; + } + } - for (const tipo of ['emitidos', 'recibidos'] as const) { + const pendingStages: string[] = []; + const nonFatalErrors: { stage: string; message: string }[] = []; + + for (let i = activeStageIndex; i < totalStages; i++) { + const stage = stages[i]; + console.log(`[SAT Daily] Etapa ${i + 1}/${totalStages}: ${stage.label}`); try { - const { inserted, updated } = await processMetadataRange(ctx, jobId, inicioAño, ahora, tipo); - totalInserted += inserted; - totalUpdated += updated; + await stage.run(); + const progressPercent = totalStages > 0 ? Math.round(((i + 1) / totalStages) * 100) : 0; + await updateJobProgress(jobId, { + cfdisFound: totalFound, + cfdisDownloaded: totalDownloaded, + cfdisInserted: totalInserted, + cfdisUpdated: totalUpdated, + progressPercent, + }); } catch (error: any) { - console.error(`[SAT] Error metadata ${tipo} (ciclo fiscal):`, error.message); + const retryableErr = error instanceof SatSyncTimeoutError || error instanceof SatTransientError + ? error + : (error.message?.includes('Timeout') ? new SatSyncTimeoutError(stage.id, error.message) : null); + + // Errores transitorios (timeout, metadata aún no lista) se estacionan para + // reintento; no se pierden requestIds ya creados. + if (retryableErr && stage.isMetadata) { + console.warn(`[SAT Daily] Etapa ${stage.id} sin paquetes listos; se estaciona y se continúa con las demás.`); + pendingStages.push(stage.id); + continue; + } + if (retryableErr) { + throw retryableErr; + } + + // Errores no transitorios (404 Error no controlado, etc.) no abortan el + // daily. Se registran para diagnóstico y se continúa con las demás etapas. + console.error(`[SAT Daily] Etapa ${stage.id} falló (no transitorio): ${error.message}`); + nonFatalErrors.push({ stage: stage.id, message: error.message }); + continue; } } + const errorMessage = nonFatalErrors.length > 0 + ? JSON.stringify({ completedWithWarnings: true, errors: nonFatalErrors }) + : undefined; + await updateJobProgress(jobId, { cfdisFound: totalFound, cfdisDownloaded: totalDownloaded, cfdisInserted: totalInserted, cfdisUpdated: totalUpdated, + progressPercent: 100, + errorMessage, }); + + if (pendingStages.length > 0) { + throw new SatMetadataPendingError( + pendingStages[0], + pendingStages, + `Metadata no lista en ${pendingStages.length} chunk(s): ${pendingStages.join(', ')}`, + ); + } +} + +/** + * Verifica y descarga un requestId existente, procesando los paquetes resultantes. + * Similar a processMetadataRange pero recibiendo el requestId explícito. + */ +async function requestAndDownloadWithId( + ctx: SyncContext, + jobId: string, + requestId: string, + tipoCfdi: 'emitidos' | 'recibidos', + requestType: 'metadata', + fechaInicio: Date, + fechaFin: Date, + isDaily = false, +): Promise<{ inserted: number; updated: number }> { + let totalInserted = 0; + let totalUpdated = 0; + const label = `${tipoCfdi}/${requestType} ${fechaInicio.toISOString().slice(0, 10)} → ${fechaFin.toISOString().slice(0, 10)}`; + + let verifyResult: Awaited> | undefined; + 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)); + attempts++; + + verifyResult = await verifySatRequest(ctx.service, requestId); + console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts}/${maxAttempts})`); + + if (verifyResult.status === 'ready') break; + if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') { + if (isAgotadas(verifyResult.message)) { + console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite.`); + return { inserted: 0, updated: 0 }; + } + throw new Error(`Solicitud fallida (${label}): ${verifyResult.message}`); + } + } + + if (!verifyResult || verifyResult.status !== 'ready') { + throw new SatSyncTimeoutError(stageIdForTimeout(label), `Timeout esperando respuesta del SAT (${label})`); + } + + 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); + if (!downloadResult.success) { + console.error(`[SAT] Error descargando paquete ${packageId}: ${downloadResult.message}`); + continue; + } + + const items = processMetadataPackage(downloadResult.packageContent, tipoCfdi); + console.log(`[SAT] Procesando ${items.length} registros de metadata ${tipoCfdi}`); + + const { inserted, updated } = await saveMetadata(await ctx.getPool(), items, jobId, ctx.contribuyenteId); + totalInserted += inserted; + totalUpdated += updated; + } + + return { inserted: totalInserted, updated: totalUpdated }; } /** @@ -1294,29 +1600,54 @@ export async function startSync( } catch (error: any) { console.error(`[SAT] Error en sincronización ${job.id}:`, error); - const isTimeout = error.message?.includes('Timeout'); + const isMetadataPending = error instanceof SatMetadataPendingError; + const isSatTimeout = error instanceof SatSyncTimeoutError; + const isTransient = error instanceof SatTransientError; + const isTimeout = isSatTimeout || isMetadataPending || isTransient || error.message?.includes('Timeout'); const currentRetries = job.retryCount || 0; const policy = getRetryPolicy(job); const nextRetryNumber = currentRetries + 1; - const nextRetry = isTimeout && nextRetryNumber <= policy.maxRetries - ? computeNextRetryAt(job.startedAt!, nextRetryNumber, policy) + // Para daily permitimos hasta MAX_DAILY_RETRY_ATTEMPTS intentos totales. + // Los primeros usan la política automática (6h/12h); los restantes los + // recogen los crons fijos de 9 AM / 4 PM CDMX. + const maxAttempts = job.type === 'daily' ? MAX_DAILY_RETRY_ATTEMPTS : policy.maxRetries; + const hasAttemptsLeft = isTimeout && nextRetryNumber <= maxAttempts; + // B: anclar la política a createdAt (inmutable) en vez de startedAt, + // porque startedAt se resetea en cada retry para que el watchdog mida + // el intento actual y no mate retries legítimos en vuelo. + const nextRetry = hasAttemptsLeft + ? (computeNextRetryAt(job.createdAt, nextRetryNumber, policy) ?? null) : null; - if (nextRetry) { + // Para timeouts/rechazos transitorios del daily, persistimos la etapa para retomar en retries programados. + const progressErrorMessage = isMetadataPending + ? JSON.stringify({ stage: error.stageId, pendingStages: error.pendingStages, message: error.message }) + : isSatTimeout || isTransient + ? JSON.stringify({ stage: error.stageId, message: error.message }) + : undefined; + + if (hasAttemptsLeft) { + const retryLabel = nextRetry + ? nextRetry.toLocaleString('es-MX') + : 'próxima ventana 9 AM / 4 PM CDMX'; await updateJobProgress(job.id, { status: 'pending', - errorMessage: `Timeout (intento ${nextRetryNumber}/${policy.maxRetries}). Reintento programado para ${nextRetry.toLocaleString('es-MX')}.`, + errorMessage: progressErrorMessage ?? `Timeout (intento ${nextRetryNumber}/${maxAttempts}). Reintento programado para ${retryLabel}.`, retryCount: nextRetryNumber, - nextRetryAt: nextRetry, + nextRetryAt: nextRetry ?? null as any, }); - console.log(`[SAT] Job ${job.id} programado para reintento ${nextRetryNumber}/${policy.maxRetries} a las ${nextRetry.toLocaleString('es-MX')}`); + console.log(`[SAT] Job ${job.id} programado para reintento ${nextRetryNumber}/${maxAttempts} (${retryLabel})`); } else { // Sin reintentos restantes, error no-timeout, o policy con maxRetries=0 (incremental) - const finalMsg = isTimeout - ? policy.maxRetries === 0 - ? 'Timeout en sync incremental — sin reintentos por política. Próximo cron incremental cubrirá el gap.' - : 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.' - : error.message; + const finalMsg = isMetadataPending + ? progressErrorMessage + : isSatTimeout + ? progressErrorMessage + : isTimeout + ? policy.maxRetries === 0 + ? 'Timeout en sync incremental — sin reintentos por política. Próximo cron incremental cubrirá el gap.' + : 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.' + : error.message; await updateJobProgress(job.id, { status: 'failed', errorMessage: finalMsg, @@ -1410,7 +1741,23 @@ export async function retryTimedOutJobs(): Promise { getPool: () => tenantDb.getPool(job.tenantId, job.tenant.databaseName), }; - await updateJobProgress(job.id, { status: 'running', errorMessage: null as any }); + // 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() }); + + // Para jobs daily, intentamos retomar desde la última etapa completada. + let resumeFromStage: string | undefined; + if (job.type === 'daily' && job.errorMessage) { + try { + const parsed = JSON.parse(job.errorMessage); + if (typeof parsed.stage === 'string') { + resumeFromStage = parsed.stage; + } + } catch { + // errorMessage no es JSON, ignorar + } + } // Re-ejecutar según tipo original try { @@ -1419,7 +1766,7 @@ export async function retryTimedOutJobs(): Promise { } else if (job.type === 'incremental') { await processIncrementalSync(ctx, job.id); } else { - await processDailySync(ctx, job.id); + await processDailySync(ctx, job.id, resumeFromStage); } await updateJobProgress(job.id, { @@ -1432,26 +1779,46 @@ export async function retryTimedOutJobs(): Promise { } catch (retryError: any) { console.error(`[SAT Retry] Job ${job.id} falló de nuevo:`, retryError.message); - const isTimeout = retryError.message?.includes('Timeout'); + const isMetadataPending = retryError instanceof SatMetadataPendingError; + const isSatTimeout = retryError instanceof SatSyncTimeoutError; + const isTransient = retryError instanceof SatTransientError; + const isTimeout = isSatTimeout || isMetadataPending || isTransient || retryError.message?.includes('Timeout'); const policy = getRetryPolicy(job); const nextRetryNumber = job.retryCount + 1; - const nextRetry = isTimeout && nextRetryNumber <= policy.maxRetries - ? computeNextRetryAt(job.startedAt!, nextRetryNumber, policy) + // Para daily permitimos hasta MAX_DAILY_RETRY_ATTEMPTS intentos totales. + const maxAttempts = job.type === 'daily' ? MAX_DAILY_RETRY_ATTEMPTS : policy.maxRetries; + const hasAttemptsLeft = isTimeout && nextRetryNumber <= maxAttempts; + // B: política anclada a createdAt (startedAt se resetea por intento). + const nextRetry = hasAttemptsLeft + ? (computeNextRetryAt(job.createdAt, nextRetryNumber, policy) ?? null) : null; - if (nextRetry) { + const progressErrorMessage = isMetadataPending + ? JSON.stringify({ stage: retryError.stageId, pendingStages: retryError.pendingStages, message: retryError.message }) + : isSatTimeout || isTransient + ? JSON.stringify({ stage: retryError.stageId, message: retryError.message }) + : undefined; + + if (hasAttemptsLeft) { + const retryLabel = nextRetry + ? nextRetry.toLocaleString('es-MX') + : 'próxima ventana 9 AM / 4 PM CDMX'; await updateJobProgress(job.id, { status: 'pending', - errorMessage: `Timeout (intento ${nextRetryNumber}/${policy.maxRetries}). Reintento programado para ${nextRetry.toLocaleString('es-MX')}.`, + errorMessage: progressErrorMessage ?? `Timeout (intento ${nextRetryNumber}/${maxAttempts}). Reintento programado para ${retryLabel}.`, retryCount: nextRetryNumber, - nextRetryAt: nextRetry, + nextRetryAt: nextRetry ?? null as any, }); } else { await updateJobProgress(job.id, { status: 'failed', - errorMessage: isTimeout - ? 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.' - : retryError.message, + errorMessage: isMetadataPending + ? progressErrorMessage + : isSatTimeout + ? progressErrorMessage + : isTimeout + ? 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.' + : retryError.message, completedAt: new Date(), }); } @@ -1467,6 +1834,153 @@ export async function retryTimedOutJobs(): Promise { } } +/** + * Retoma jobs diarios que quedaron pending por timeout de polling, sin depender + * de nextRetryAt. Usado por los cron fijos de 9:00 AM y 4:00 PM CDMX. + * Hasta MAX_DAILY_RETRY_ATTEMPTS intentos en total. + */ +export async function continuePendingDailyRequests(): Promise { + const pendingJobs = await prisma.satSyncJob.findMany({ + where: { + status: 'pending', + type: 'daily', + retryCount: { lt: MAX_DAILY_RETRY_ATTEMPTS }, + }, + include: { tenant: { select: { id: true, databaseName: true, rfc: true } } }, + }); + + if (pendingJobs.length === 0) { + console.log('[SAT Daily Retry] No hay jobs diarios pendientes'); + return; + } + + console.log(`[SAT Daily Retry] ${pendingJobs.length} job(s) diarios pendientes`); + + for (const job of pendingJobs) { + try { + const activeSync = await prisma.satSyncJob.findFirst({ + where: { + tenantId: job.tenantId, + contribuyenteId: job.contribuyenteId ?? null, + status: 'running', + }, + }); + + if (activeSync) { + console.log(`[SAT Daily Retry] (${job.tenant.rfc}, contrib=${job.contribuyenteId || 'tenant-wide'}) tiene sync activo, posponiendo`); + continue; + } + + console.log(`[SAT Daily Retry] Reintentando job ${job.id} (${job.tenant.rfc}), intento ${(job.retryCount || 0) + 1}/${MAX_DAILY_RETRY_ATTEMPTS}`); + + let decryptedFiel = null; + if (job.contribuyenteId) { + const pool = await tenantDb.getPool(job.tenantId, job.tenant.databaseName); + decryptedFiel = await getDecryptedFielContribuyente(pool, job.contribuyenteId); + } + if (!decryptedFiel) { + decryptedFiel = await getDecryptedFiel(job.tenantId); + } + if (!decryptedFiel) { + await updateJobProgress(job.id, { + status: 'failed', + errorMessage: 'FIEL no disponible para reintento', + completedAt: new Date(), + }); + continue; + } + + const service = createSatService({ + cerContent: decryptedFiel.cerContent, + keyContent: decryptedFiel.keyContent, + password: decryptedFiel.password, + }); + + const ctx: SyncContext = { + fielData: { + cerContent: decryptedFiel.cerContent, + keyContent: decryptedFiel.keyContent, + password: decryptedFiel.password, + }, + service, + rfc: decryptedFiel.rfc, + tenantId: job.tenantId, + databaseName: job.tenant.databaseName, + contribuyenteId: job.contribuyenteId ?? null, + getPool: () => tenantDb.getPool(job.tenantId, job.tenant.databaseName), + }; + + let resumeFromStage: string | undefined; + if (job.errorMessage) { + try { + const parsed = JSON.parse(job.errorMessage); + if (typeof parsed.stage === 'string') { + resumeFromStage = parsed.stage; + } + } catch { + // no es JSON, ignorar + } + } + + // B: resetear startedAt al inicio de este intento (ver retryTimedOutJobs). + await updateJobProgress(job.id, { status: 'running', errorMessage: null as any, startedAt: new Date() }); + + try { + await processDailySync(ctx, job.id, resumeFromStage); + await updateJobProgress(job.id, { + status: 'completed', + completedAt: new Date(), + progressPercent: 100, + errorMessage: null as any, + }); + console.log(`[SAT Daily Retry] Job ${job.id} completado`); + } catch (retryError: any) { + console.error(`[SAT Daily Retry] Job ${job.id} falló:`, retryError.message); + + const isMetadataPending = retryError instanceof SatMetadataPendingError; + const isSatTimeout = retryError instanceof SatSyncTimeoutError; + const isTransient = retryError instanceof SatTransientError; + const isTimeout = isSatTimeout || isMetadataPending || isTransient || retryError.message?.includes('Timeout'); + const nextRetryCount = (job.retryCount || 0) + 1; + const progressErrorMessage = isMetadataPending + ? JSON.stringify({ stage: retryError.stageId, pendingStages: retryError.pendingStages, message: retryError.message }) + : isSatTimeout || isTransient + ? JSON.stringify({ stage: retryError.stageId, message: retryError.message }) + : undefined; + + if (isTimeout && nextRetryCount < MAX_DAILY_RETRY_ATTEMPTS) { + await updateJobProgress(job.id, { + status: 'pending', + errorMessage: progressErrorMessage, + retryCount: nextRetryCount, + nextRetryAt: null as any, + }); + console.log(`[SAT Daily Retry] Job ${job.id} quedó pending para siguiente ventana (intento ${nextRetryCount}/${MAX_DAILY_RETRY_ATTEMPTS})`); + } else { + await updateJobProgress(job.id, { + status: 'failed', + errorMessage: isMetadataPending + ? progressErrorMessage + : isSatTimeout + ? progressErrorMessage + : isTimeout + ? 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.' + : retryError.message, + completedAt: new Date(), + }); + } + } + } catch (error: any) { + console.error(`[SAT Daily Retry] Error procesando job ${job.id}:`, error.message); + await updateJobProgress(job.id, { + status: 'failed', + errorMessage: error.message, + completedAt: new Date(), + }); + } + } +} + /** * Obtiene el estado actual de sincronización de un tenant */ diff --git a/docs/SAT-SYNC-IMPLEMENTATION.md b/docs/SAT-SYNC-IMPLEMENTATION.md index 1aa9db8..5aa9056 100644 --- a/docs/SAT-SYNC-IMPLEMENTATION.md +++ b/docs/SAT-SYNC-IMPLEMENTATION.md @@ -1,298 +1,246 @@ -# Implementación de Sincronización SAT +# Sincronización SAT — Implementación y Operación -## Resumen +Documentación viva del sistema de sincronización de CFDIs con el SAT para Horux Despachos / Horux 360. -Sistema de sincronización automática de CFDIs con el SAT (Servicio de Administración Tributaria de México) para Horux360. +## 1. Resumen -## Componentes Implementados +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). -### 1. Backend (API) +Los datos se almacenan en la base de datos del tenant correspondiente. -#### Servicios +## 2. Arquitectura -| Archivo | Descripción | -|---------|-------------| -| `src/services/fiel.service.ts` | Gestión de credenciales FIEL (e.firma) | -| `src/services/sat/sat-client.service.ts` | Cliente para el servicio web del SAT | -| `src/services/sat/sat.service.ts` | Lógica principal de sincronización | -| `src/services/sat/sat-crypto.service.ts` | Encriptación AES-256-GCM para credenciales | -| `src/services/sat/sat-parser.service.ts` | Parser de XMLs de CFDI | +### Backend -#### Controladores +| 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 | -| Archivo | Descripción | -|---------|-------------| -| `src/controllers/fiel.controller.ts` | Endpoints para gestión de FIEL | -| `src/controllers/sat.controller.ts` | Endpoints para sincronización SAT | +### Frontend -#### Job Programado +| 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 | -| Archivo | Descripción | -|---------|-------------| -| `src/jobs/sat-sync.job.ts` | Cron job para sincronización diaria (3:00 AM) | +## 3. Modelo de datos -### 2. Frontend (Web) - -#### Componentes - -| Archivo | Descripción | -|---------|-------------| -| `components/sat/FielUploadModal.tsx` | Modal para subir certificado y llave FIEL | -| `components/sat/SyncStatus.tsx` | Estado de sincronización con selector de fechas | -| `components/sat/SyncHistory.tsx` | Historial de sincronizaciones | - -#### Página - -| Archivo | Descripción | -|---------|-------------| -| `app/(dashboard)/configuracion/sat/page.tsx` | Página de configuración SAT | - -### 3. Base de Datos - -#### Tabla Principal (schema public) +### Tabla global `public.sat_sync_jobs` ```sql --- sat_sync_jobs: Almacena los trabajos de sincronización CREATE TABLE sat_sync_jobs ( - id UUID PRIMARY KEY, - tenant_id UUID NOT NULL, - type VARCHAR(20) NOT NULL, -- 'initial' | 'daily' - status VARCHAR(20) NOT NULL, -- 'pending' | 'running' | 'completed' | 'failed' - date_from TIMESTAMP NOT NULL, - date_to TIMESTAMP NOT NULL, - cfdi_type VARCHAR(20), - sat_request_id VARCHAR(100), + 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 DEFAULT 0, - cfdis_downloaded INTEGER DEFAULT 0, - cfdis_inserted INTEGER DEFAULT 0, - cfdis_updated INTEGER DEFAULT 0, - progress_percent INTEGER DEFAULT 0, + 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, - completed_at TIMESTAMP, - created_at TIMESTAMP DEFAULT NOW(), - retry_count INTEGER DEFAULT 0 -); - --- fiel_credentials: Almacena las credenciales FIEL encriptadas -CREATE TABLE fiel_credentials ( - id UUID PRIMARY KEY, - tenant_id UUID UNIQUE NOT NULL, - rfc VARCHAR(13) NOT NULL, - cer_data BYTEA NOT NULL, - key_data BYTEA NOT NULL, - key_password_encrypted BYTEA NOT NULL, - encryption_iv BYTEA NOT NULL, - encryption_tag BYTEA NOT NULL, - serial_number VARCHAR(100), - valid_from TIMESTAMP NOT NULL, - valid_until TIMESTAMP NOT NULL, - is_active BOOLEAN DEFAULT true, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() + 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 '{}' ); ``` -#### Columnas agregadas a tabla cfdis (por tenant) - -```sql -ALTER TABLE tenant_xxx.cfdis ADD COLUMN xml_original TEXT; -ALTER TABLE tenant_xxx.cfdis ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; -ALTER TABLE tenant_xxx.cfdis ADD COLUMN last_sat_sync TIMESTAMP; -ALTER TABLE tenant_xxx.cfdis ADD COLUMN sat_sync_job_id UUID; -ALTER TABLE tenant_xxx.cfdis ADD COLUMN source VARCHAR(20) DEFAULT 'manual'; -``` - -## Dependencias - -```json -{ - "@nodecfdi/sat-ws-descarga-masiva": "^2.0.0", - "@nodecfdi/credentials": "^2.0.0", - "@nodecfdi/cfdi-core": "^1.0.1" -} -``` - -## Flujo de Sincronización - -``` -1. Usuario configura FIEL (certificado .cer + llave .key + contraseña) - ↓ -2. Sistema valida y encripta credenciales (AES-256-GCM) - ↓ -3. Usuario inicia sincronización (manual o automática 3:00 AM) - ↓ -4. Sistema desencripta FIEL y crea cliente SAT - ↓ -5. Por cada mes en el rango: - a. Solicitar CFDIs emitidos al SAT - b. Esperar respuesta (polling cada 30s) - c. Descargar paquetes ZIP - d. Extraer y parsear XMLs - e. Guardar en BD del tenant - f. Repetir para CFDIs recibidos - ↓ -6. Marcar job como completado -``` - -## API Endpoints - ### FIEL -| Método | Ruta | Descripción | -|--------|------|-------------| -| GET | `/api/fiel/status` | Estado de la FIEL configurada | -| POST | `/api/fiel/upload` | Subir nueva FIEL | -| DELETE | `/api/fiel` | Eliminar FIEL | +- **Legacy:** `public.fiel_credentials` (una por tenant). +- **Por contribuyente:** `fiel_contribuyente` dentro de la base del tenant. -### Sincronización SAT +El sistema intenta primero la FIEL del contribuyente; si no existe, cae a la FIEL del tenant. -| Método | Ruta | Descripción | -|--------|------|-------------| -| POST | `/api/sat/sync` | Iniciar sincronización | -| GET | `/api/sat/sync/status` | Estado actual | -| GET | `/api/sat/sync/history` | Historial de syncs | -| GET | `/api/sat/sync/:id` | Detalle de un job | -| POST | `/api/sat/sync/:id/retry` | Reintentar job fallido | +## 4. Tipos de sincronización -### Parámetros 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 | -```typescript -interface StartSyncRequest { - type?: 'initial' | 'daily'; // default: 'daily' - dateFrom?: string; // ISO date, ej: "2025-01-01T00:00:00" - dateTo?: string; // ISO date, ej: "2025-12-31T23:59:59" -} +## 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; ``` -## Configuración +Esto da un máximo de **~45 minutos por solicitud** (9 × 5 min). -### Variables de entorno +Cada solicitud al SAT tiene su propio polling; los intentos no se comparten entre solicitudes. -```env -# Clave para encriptar credenciales FIEL (32 bytes hex) -FIEL_ENCRYPTION_KEY=tu_clave_de_32_bytes_en_hexadecimal +## 8. Políticas de reintentos -# Zona horaria para el cron -TZ=America/Mexico_City +```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 ``` -### Límites del SAT +Los reintentos automáticos se programan desde `createdAt` del job. Los reintentos por cron fijo (9 AM / 4 PM) se manejan en `continuePendingDailyRequests`. -- **Antigüedad máxima**: 6 años -- **Solicitudes por día**: Limitadas (se reinicia cada 24h) -- **Tamaño de paquete**: Variable +## 9. Manejo de errores -## Errores Comunes del SAT +### Errores no fatales (se registran, no abortan en daily) -| Código | Mensaje | Solución | -|--------|---------|----------| -| 5000 | Solicitud Aceptada | OK - esperar verificación | -| 5002 | Límite de solicitudes agotado | Esperar 24 horas | -| 5004 | No se encontraron CFDIs | Normal si no hay facturas en el rango | -| 5005 | Solicitud duplicada | Ya existe una solicitud pendiente | -| - | Información mayor a 6 años | Ajustar rango de fechas | -| - | No se permite descarga de cancelados | Facturas canceladas no disponibles | +- `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. -## Seguridad +### Errores transitorios (reintentan) -1. **Encriptación de credenciales**: AES-256-GCM con IV único -2. **Almacenamiento seguro**: Certificado, llave y contraseña encriptados -3. **Autenticación**: JWT con tenantId embebido -4. **Aislamiento**: Cada tenant tiene su propio schema en PostgreSQL +- Timeout del polling (`SatSyncTimeoutError`). +- `SatTransientError` (ej. rechazo transitorio del SAT). +- Metadata aún no lista (`SatMetadataPendingError`). -## Servicios Systemd +### 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 -# API Backend -systemctl status horux-api - -# Web Frontend -systemctl status horux-web +pm2 status horux-api +pm2 logs horux-api --lines 50 --nostream ``` -## Comandos Útiles +### Jobs recientes ```bash -# Ver logs de sincronización SAT -journalctl -u horux-api -f | grep "\[SAT\]" - -# Estado de jobs -psql -U postgres -d horux360 -c "SELECT * FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 5;" - -# CFDIs sincronizados por tenant -psql -U postgres -d horux360 -c "SELECT COUNT(*) FROM tenant_xxx.cfdis WHERE source = 'sat';" +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; +" ``` -## Changelog - -### 2026-01-25 - -- Implementación inicial de sincronización SAT -- Integración con librería @nodecfdi/sat-ws-descarga-masiva -- Soporte para fechas personalizadas en sincronización -- Corrección de cast UUID en queries SQL -- Agregadas columnas faltantes a tabla cfdis -- UI para selección de periodo personalizado -- Cambio de servicio web a modo producción (next start) - -## Estado Actual (2026-01-25) - -### Completado - -- [x] Servicio de encriptación de credenciales FIEL -- [x] Integración con @nodecfdi/sat-ws-descarga-masiva -- [x] Parser de XMLs de CFDI -- [x] UI para subir FIEL -- [x] UI para ver estado de sincronización -- [x] UI para seleccionar periodo personalizado -- [x] Cron job para sincronización diaria (3:00 AM) -- [x] Soporte para fechas personalizadas -- [x] Corrección de cast UUID en queries -- [x] Columnas adicionales en tabla cfdis de todos los tenants - -### Pendiente por probar - -El SAT bloqueó las solicitudes por exceso de pruebas. **Esperar 24 horas** y luego: - -1. Ir a **Configuración > SAT** -2. Clic en **"Periodo personalizado"** -3. Seleccionar: **2025-01-01** a **2025-12-31** -4. Clic en **"Sincronizar periodo"** - -### Tenant de prueba - -- **RFC**: HTS240708LJA -- **Schema**: `tenant_cas2408138w2` -- **Nota**: Los CFDIs "recibidos" de este tenant están cancelados (SAT no permite descargarlos) - -### Comandos para verificar después de 24h +### Jobs fallidos o atorados ```bash -# Ver estado del sync -PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \ - "SELECT status, cfdis_found, cfdis_downloaded, cfdis_inserted FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 1;" - -# Ver logs en tiempo real -journalctl -u horux-api -f | grep "\[SAT\]" - -# Contar CFDIs sincronizados -PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \ - "SELECT COUNT(*) as total FROM tenant_cas2408138w2.cfdis WHERE source = 'sat';" +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; +" ``` -### Problemas conocidos +### Contribuyentes de un tenant -1. **"Se han agotado las solicitudes de por vida"**: Límite de SAT alcanzado, esperar 24h -2. **"No se permite la descarga de xml que se encuentren cancelados"**: Normal para facturas canceladas -3. **"Información mayor a 6 años"**: SAT solo permite descargar últimos 6 años +```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; +" +``` -## Próximos Pasos +## 12. Changelog reciente -- [ ] Probar sincronización completa después de 24h -- [ ] Verificar que los CFDIs se guarden correctamente -- [ ] Implementar reintentos automáticos para errores temporales -- [ ] Notificaciones por email al completar sincronización -- [ ] Dashboard con estadísticas de CFDIs por periodo -- [ ] Soporte para filtros adicionales (RFC emisor/receptor, tipo de comprobante) +### 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.