Files
HoruxDespachosNuevo/apps/api/src/services/sat/sat.service.ts
2026-08-03 14:27:53 +00:00

2131 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { prisma, tenantDb } from '../../config/database.js';
import { getDecryptedFiel } from '../fiel.service.js';
import { getDecryptedFielContribuyente } from '../contribuyente-fiel.service.js';
import { markForInvalidation } from '../metricas.service.js';
import {
createSatService,
querySat,
verifySatRequest,
downloadSatPackage,
type FielData,
} from './sat-client.service.js';
import { processPackage, processMetadataPackage, extractXmlsFromZip, type CfdiParsed, type CfdiMetadata } from './sat-parser.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';
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
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.
* Los tiempos son ABSOLUTOS desde el inicio, no acumulativos desde el
* intento anterior — así el retry 1 cae exactamente a startedAt+6h sin
* importar cuánto tardó el intento original en hacer timeout.
* - `maxRetries` = nº máximo de reintentos (después → status='failed').
*
* Justificación de las políticas:
* - daily/custom: el contador puede esperar 12h. 2 retries cubre fallas
* transitorias del SAT sin atascar pendientes infinitamente.
* - initial bootstrap: la primera sync de un tenant puede ser de 6 años
* de datos — vale la pena más paciencia (24h del start es el último
* intento). Si después de 24h sigue fallando, hay un problema estructural.
* - incremental: corre cada 4h por el cron. Si una falla, la siguiente
* ejecución cubrirá el gap (la ventana de 8h se solapa). Reintentar
* duplicaría carga sin beneficio.
*/
const RETRY_POLICIES: Record<'daily' | 'custom' | 'initial' | 'incremental', {
maxRetries: number;
retryAtHours: number[];
}> = {
daily: { maxRetries: 2, retryAtHours: [6, 12] },
custom: { maxRetries: 2, retryAtHours: [6, 12] },
initial: { maxRetries: 3, retryAtHours: [6, 12, 24] },
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];
}
/**
* Calcula `nextRetryAt` para el retry número `nextRetryNumber` (1-based).
* Devuelve null si ya no hay retries disponibles según la política.
*/
function computeNextRetryAt(
startedAt: Date,
nextRetryNumber: number,
policy: { retryAtHours: number[] },
): Date | null {
const idx = nextRetryNumber - 1;
if (idx < 0 || idx >= policy.retryAtHours.length) return null;
return new Date(startedAt.getTime() + policy.retryAtHours[idx] * 60 * 60 * 1000);
}
interface SyncContext {
fielData: FielData;
service: Service;
rfc: string;
tenantId: string;
databaseName: string;
contribuyenteId: string | null;
getPool: () => Promise<Pool>;
}
/**
* Actualiza el progreso de un job
*/
async function updateJobProgress(
jobId: string,
updates: Partial<{
status: 'pending' | 'running' | 'completed' | 'failed';
satRequestId: string;
satPackageIds: string[];
cfdisFound: number;
cfdisDownloaded: number;
cfdisInserted: number;
cfdisUpdated: number;
progressPercent: number;
errorMessage: string;
startedAt: Date;
completedAt: Date;
retryCount: number;
nextRetryAt: Date;
}>
): Promise<void> {
await prisma.satSyncJob.update({
where: { id: jobId },
data: updates,
});
}
/**
* Obtiene o crea un RFC en la tabla rfcs del tenant
*/
async function getOrCreateRfc(pool: Pool, rfc: string, razonSocial: string | null, regimenFiscal?: string | null): Promise<number> {
const { rows } = await pool.query(
`INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET
razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social),
regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END
RETURNING id`,
[rfc, razonSocial || null, regimenFiscal || null]
);
return rows[0].id;
}
/**
* Calcula la fecha efectiva de un CFDI para métricas.
* Si tiene InformacionGlobal, usa el año/mes declarado.
* Para bimestral (periodicidad 05), convierte el código 13-18 a mes 2-12.
*/
function calcFechaEfectiva(cfdi: CfdiParsed): Date | null {
if (!cfdi.añoGlobal || !cfdi.mesesGlobal) {
return null;
}
const anio = parseInt(cfdi.añoGlobal, 10);
if (isNaN(anio)) return null;
const mesesStr = cfdi.mesesGlobal;
const mesesParts = mesesStr.split(',').map((s: string) => s.trim());
const ultimoMesStr = mesesParts[mesesParts.length - 1];
let mes = parseInt(ultimoMesStr, 10);
if (isNaN(mes)) return null;
// Bimestral: códigos 13-18 → meses 2,4,6,8,10,12
if (cfdi.periodicidad === '05') {
if (mes >= 13 && mes <= 18) {
mes = (mes - 12) * 2;
}
}
if (mes < 1 || mes > 12) return null;
return new Date(anio, mes - 1, 1);
}
/**
* Guarda los XMLs extraídos del ZIP en disco para respaldo
*/
function saveXmlsToDisk(
zipBase64: string,
tenantRfc: string,
tipoCfdi: CfdiSyncType,
packageId: string
): string {
const baseDir = path.join(process.cwd(), 'data', 'xmls', tenantRfc.toLowerCase(), tipoCfdi);
fs.mkdirSync(baseDir, { recursive: true });
const xmlFiles = extractXmlsFromZip(zipBase64);
const packageDir = path.join(baseDir, packageId);
fs.mkdirSync(packageDir, { recursive: true });
for (const { filename, content } of xmlFiles) {
fs.writeFileSync(path.join(packageDir, filename), content, 'utf-8');
}
console.log(`[SAT] ${xmlFiles.length} XMLs guardados en ${packageDir}`);
return packageDir;
}
/**
* Guarda los CFDIs en la base de datos del tenant
*/
async function saveCfdis(
pool: Pool,
cfdis: CfdiParsed[],
jobId: string,
contribuyenteId: string | null = null
): Promise<{ inserted: number; updated: number }> {
let inserted = 0;
let updated = 0;
for (const cfdi of cfdis) {
try {
const tc = cfdi.tipoCambio || 1;
const m = (v: number) => v * tc; // compute MXN
const fechaEmision = cfdi.fechaEmision;
const year = String(fechaEmision.getFullYear());
const month = String(fechaEmision.getMonth() + 1).padStart(2, '0');
// Upsert RFCs y obtener IDs
const rfcEmisorId = await getOrCreateRfc(pool, cfdi.rfcEmisor, cfdi.nombreEmisor, cfdi.regimenFiscalEmisor);
const rfcReceptorId = await getOrCreateRfc(pool, cfdi.rfcReceptor, cfdi.nombreReceptor, cfdi.regimenFiscalReceptor);
// Normaliza UUID a lowercase (RFC 4122 canonical) para evitar duplicados
// entre el XML parser y el CSV metadata parser del SAT.
const uuidNorm = cfdi.uuid ? cfdi.uuid.toLowerCase() : cfdi.uuid;
// All values for the full column set
const vals = [
year, month, cfdi.type, uuidNorm, cfdi.serie, cfdi.folio,
cfdi.status, fechaEmision,
rfcEmisorId, cfdi.rfcEmisor, cfdi.nombreEmisor,
rfcReceptorId, cfdi.rfcReceptor, cfdi.nombreReceptor,
cfdi.subtotal, m(cfdi.subtotal),
cfdi.descuento, m(cfdi.descuento),
cfdi.total, m(cfdi.total),
cfdi.saldoInsoluto,
cfdi.moneda, tc, cfdi.tipoComprobante,
cfdi.metodoPago, cfdi.formaPago, cfdi.usoCfdi,
cfdi.pac, cfdi.fechaCertSat,
cfdi.uuidRelacionado,
cfdi.isrRetencion, m(cfdi.isrRetencion),
cfdi.ivaTraslado, m(cfdi.ivaTraslado),
cfdi.ivaRetencion, m(cfdi.ivaRetencion),
cfdi.iepsTraslado, m(cfdi.iepsTraslado),
cfdi.iepsRetencion, m(cfdi.iepsRetencion),
cfdi.impuestosLocalesTrasladado, m(cfdi.impuestosLocalesTrasladado),
cfdi.impuestosLocalesRetenidos, m(cfdi.impuestosLocalesRetenidos),
cfdi.montoPago, m(cfdi.montoPago),
cfdi.fechaPagoP, cfdi.numParcialidad,
cfdi.isrRetencionPago, m(cfdi.isrRetencionPago),
cfdi.ivaTrasladoPago, m(cfdi.ivaTrasladoPago),
cfdi.ivaRetencionPago, m(cfdi.ivaRetencionPago),
cfdi.iepsTrasladoPago, m(cfdi.iepsTrasladoPago),
cfdi.iepsRetencionPago, m(cfdi.iepsRetencionPago),
cfdi.fechaPago, cfdi.fechaInicialPago, cfdi.fechaFinalPago,
cfdi.numDiasPagados, cfdi.numSeguroSocial, cfdi.puesto,
cfdi.salarioBaseCotApor, m(cfdi.salarioBaseCotApor),
cfdi.salarioDiarioIntegrado, m(cfdi.salarioDiarioIntegrado),
cfdi.totalPercepciones, m(cfdi.totalPercepciones),
cfdi.totalDeducciones, m(cfdi.totalDeducciones),
cfdi.impRetenidosNomina, m(cfdi.impRetenidosNomina),
cfdi.otrasDeduccionesNomina, m(cfdi.otrasDeduccionesNomina),
cfdi.subsidioCausado, m(cfdi.subsidioCausado),
cfdi.regimenFiscalEmisor, cfdi.regimenFiscalReceptor,
cfdi.codigoPostalReceptor,
cfdi.periodicidad,
cfdi.mesesGlobal,
cfdi.añoGlobal,
calcFechaEfectiva(cfdi),
cfdi.xmlOriginal,
cfdi.cfdiTipoRelacion, cfdi.cfdisRelacionados,
jobId,
];
const { rows: existing } = await pool.query(
`SELECT id FROM cfdis WHERE LOWER(uuid) = $1`,
[uuidNorm]
);
if (existing.length > 0) {
// $1=uuid(WHERE), $2-$85=all vals (includes rfc_emisor_id, rfc_receptor_id,
// cfdi_tipo_relacion, cfdis_relacionados)
await pool.query(
`UPDATE cfdis SET
year=$2, month=$3, type=$4, uuid=$5, serie=$6, folio=$7,
status=$8, fecha_emision=$9,
rfc_emisor_id=$10, rfc_emisor=$11, nombre_emisor=$12,
rfc_receptor_id=$13, rfc_receptor=$14, nombre_receptor=$15,
subtotal=$16, subtotal_mxn=$17, descuento=$18, descuento_mxn=$19,
total=$20, total_mxn=$21, saldo_insoluto=$22,
moneda=$23, tipo_cambio=$24, tipo_comprobante=$25,
metodo_pago=$26, forma_pago=$27, uso_cfdi=$28,
pac=$29, fecha_cert_sat=$30, uuid_relacionado=$31,
isr_retencion=$32, isr_retencion_mxn=$33,
iva_traslado=$34, iva_traslado_mxn=$35,
iva_retencion=$36, iva_retencion_mxn=$37,
ieps_traslado=$38, ieps_traslado_mxn=$39,
ieps_retencion=$40, ieps_retencion_mxn=$41,
impuestos_locales_trasladado=$42, impuestos_locales_trasladado_mxn=$43,
impuestos_locales_retenidos=$44, impuestos_locales_retenidos_mxn=$45,
monto_pago=$46, monto_pago_mxn=$47,
fecha_pago_p=$48, num_parcialidad=$49,
isr_retencion_pago=$50, isr_retencion_pago_mxn=$51,
iva_traslado_pago=$52, iva_traslado_pago_mxn=$53,
iva_retencion_pago=$54, iva_retencion_pago_mxn=$55,
ieps_traslado_pago=$56, ieps_traslado_pago_mxn=$57,
ieps_retencion_pago=$58, ieps_retencion_pago_mxn=$59,
fecha_pago=$60, fecha_inicial_pago=$61, fecha_final_pago=$62,
num_dias_pagados=$63, num_seguro_social=$64, puesto=$65,
salario_base_cot_apor=$66, salario_base_cot_apor_mxn=$67,
salario_diario_integrado=$68, salario_diario_integrado_mxn=$69,
total_percepciones=$70, total_percepciones_mxn=$71,
total_deducciones=$72, total_deducciones_mxn=$73,
imp_retenidos_nomina=$74, imp_retenidos_nomina_mxn=$75,
otras_deducciones_nomina=$76, otras_deducciones_nomina_mxn=$77,
subsidio_causado=$78, subsidio_causado_mxn=$79,
regimen_fiscal_emisor=$80, regimen_fiscal_receptor=$81,
codigo_postal_receptor=$82,
periodicidad=$83, meses_global=$84, año_global=$85, fecha_efectiva=$86,
xml_original=$87,
cfdi_tipo_relacion=$88, cfdis_relacionados=$89,
last_sat_sync=NOW(), sat_sync_job_id=$90::uuid,
actualizado_en=NOW()
WHERE LOWER(uuid) = LOWER($1)`,
[cfdi.uuid, ...vals]
);
// Re-insert conceptos for updated CFDI
await pool.query(`DELETE FROM cfdi_conceptos WHERE cfdi_id = $1`, [existing[0].id]);
await saveConceptosWithRetry(pool, existing[0].id, cfdi);
updated++;
} else {
// $1-$83 = data fields (year..cfdis_relacionados), $84 = jobId, $85 = contribuyente_id
const dataPlaceholders = vals.slice(0, -1).map((_, i) => `$${i + 1}`).join(',');
await pool.query(
`INSERT INTO cfdis (
year, month, type, uuid, serie, folio, status, fecha_emision,
rfc_emisor_id, rfc_emisor, nombre_emisor,
rfc_receptor_id, rfc_receptor, nombre_receptor,
subtotal, subtotal_mxn, descuento, descuento_mxn,
total, total_mxn, saldo_insoluto,
moneda, tipo_cambio, tipo_comprobante,
metodo_pago, forma_pago, uso_cfdi,
pac, fecha_cert_sat, uuid_relacionado,
isr_retencion, isr_retencion_mxn,
iva_traslado, iva_traslado_mxn,
iva_retencion, iva_retencion_mxn,
ieps_traslado, ieps_traslado_mxn,
ieps_retencion, ieps_retencion_mxn,
impuestos_locales_trasladado, impuestos_locales_trasladado_mxn,
impuestos_locales_retenidos, impuestos_locales_retenidos_mxn,
monto_pago, monto_pago_mxn,
fecha_pago_p, num_parcialidad,
isr_retencion_pago, isr_retencion_pago_mxn,
iva_traslado_pago, iva_traslado_pago_mxn,
iva_retencion_pago, iva_retencion_pago_mxn,
ieps_traslado_pago, ieps_traslado_pago_mxn,
ieps_retencion_pago, ieps_retencion_pago_mxn,
fecha_pago, fecha_inicial_pago, fecha_final_pago,
num_dias_pagados, num_seguro_social, puesto,
salario_base_cot_apor, salario_base_cot_apor_mxn,
salario_diario_integrado, salario_diario_integrado_mxn,
total_percepciones, total_percepciones_mxn,
total_deducciones, total_deducciones_mxn,
imp_retenidos_nomina, imp_retenidos_nomina_mxn,
otras_deducciones_nomina, otras_deducciones_nomina_mxn,
subsidio_causado, subsidio_causado_mxn,
regimen_fiscal_emisor, regimen_fiscal_receptor,
codigo_postal_receptor,
periodicidad, meses_global, año_global, fecha_efectiva,
xml_original,
cfdi_tipo_relacion, cfdis_relacionados,
source, sat_sync_job_id, last_sat_sync, contribuyente_id
) VALUES (
${dataPlaceholders},
'sat', $${vals.length}::uuid, NOW(), $${vals.length + 1}
)`,
[...vals, contribuyenteId]
);
// Get the inserted cfdi id and save conceptos
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE LOWER(uuid) = LOWER($1)`, [cfdi.uuid]);
if (newRow) await saveConceptosWithRetry(pool, newRow.id, cfdi);
inserted++;
}
// Marcar el mes para recompute de métricas pre-calculadas. Para tipo P
// el mes contable es el de fecha_pago_p (coherente con los cálculos
// fiscales). Solo tiene sentido en tenants con contribuyentes.
if (contribuyenteId) {
const fechaContableRaw = cfdi.tipoComprobante === 'P' && cfdi.fechaPagoP
? cfdi.fechaPagoP
: cfdi.fechaEmision;
const fechaContable = fechaContableRaw instanceof Date ? fechaContableRaw : new Date(fechaContableRaw);
const anio = fechaContable.getFullYear();
const mes = fechaContable.getMonth() + 1;
await markForInvalidation(pool, contribuyenteId, anio, mes, 'SAT_SYNC_CFDI').catch(
err => console.warn('[SAT] markForInvalidation falló:', err?.message || err),
);
}
} catch (error) {
console.error(`[SAT] Error guardando CFDI ${cfdi.uuid}:`, error);
}
}
// Recompute saldo_pendiente_mxn para todos los CFDIs afectados por este
// batch: los I PPD recién insertados, más los I PPD referenciados por los
// P y E no-07 que entraron. Un solo UPDATE agregado al final del loop es
// más eficiente que uno por CFDI (la subquery del SALDO es costosa).
const afectados = new Set<string>();
for (const cfdi of cfdis) {
for (const u of uuidsAfectadosPorCfdi(cfdi)) afectados.add(u);
}
if (afectados.size > 0) {
try {
await recomputarSaldoPendiente(pool, Array.from(afectados));
} catch (err: any) {
console.warn(`[SAT] recomputarSaldoPendiente falló: ${err?.message || err}`);
}
}
return { inserted, updated };
}
/**
* Guarda los conceptos de un CFDI en cfdi_conceptos
*/
async function saveConceptos(pool: Pool, cfdiId: number, cfdi: CfdiParsed): Promise<void> {
if (!cfdi.conceptos || cfdi.conceptos.length === 0) return;
const tc = cfdi.tipoCambio || 1;
const m = (v: number) => v * tc;
for (const c of cfdi.conceptos) {
await pool.query(`
INSERT INTO cfdi_conceptos (
cfdi_id,
clave_prod_serv, no_identificacion, descripcion, cantidad,
clave_unidad, unidad,
valor_unitario, valor_unitario_mxn, importe, importe_mxn,
descuento, descuento_mxn,
isr_retencion, isr_retencion_mxn,
iva_traslado, iva_traslado_mxn,
iva_retencion, iva_retencion_mxn,
ieps_traslado, ieps_traslado_mxn,
ieps_retencion, ieps_retencion_mxn
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,
$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
$21,$22,$23
)
`, [
cfdiId,
c.claveProdServ, c.noIdentificacion, c.descripcion, c.cantidad,
c.claveUnidad, c.unidad,
c.valorUnitario, m(c.valorUnitario), c.importe, m(c.importe),
c.descuento, m(c.descuento),
c.isrRetencion, m(c.isrRetencion),
c.ivaTraslado, m(c.ivaTraslado),
c.ivaRetencion, m(c.ivaRetencion),
c.iepsTraslado, m(c.iepsTraslado),
c.iepsRetencion, m(c.iepsRetencion),
]);
}
}
/** Reintenta saveConceptos con backoff exponencial para tolerar errores transitorios. */
async function saveConceptosWithRetry(pool: Pool, cfdiId: number, cfdi: CfdiParsed, maxRetries = 3): Promise<void> {
let lastError: any;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await saveConceptos(pool, cfdiId, cfdi);
return;
} catch (err: any) {
lastError = err;
if (attempt < maxRetries) {
const delay = 500 * attempt;
console.warn(`[SAT] saveConceptos falló (intento ${attempt}/${maxRetries}) para CFDI ${cfdi.uuid}, reintentando en ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
}
console.error(`[SAT] saveConceptos falló definitivamente después de ${maxRetries} intentos para CFDI ${cfdi.uuid}:`, lastError?.message || lastError);
throw lastError;
}
/**
* Guarda/actualiza CFDIs desde metadata del SAT.
* - Si el CFDI no existe: inserta con datos básicos de metadata (sin XML).
* - Si el CFDI ya existe y la metadata dice Cancelado: actualiza status + fecha_cancelacion.
* - Si el CFDI ya existe y sigue Vigente: no toca nada (los datos del XML son más completos).
*/
async function saveMetadata(
pool: Pool,
items: CfdiMetadata[],
jobId: string,
contribuyenteId: string | null = null
): Promise<{ inserted: number; updated: number }> {
let inserted = 0;
let updated = 0;
for (const m of items) {
try {
// Normaliza UUID a lowercase para evitar duplicados case-sensitive con
// el XML parser (que también normaliza). CSV del SAT lo devuelve UPPERCASE.
const uuidNorm = m.uuid ? m.uuid.toLowerCase() : m.uuid;
const { rows: existing } = await pool.query(
`SELECT id, status FROM cfdis WHERE LOWER(uuid) = $1`,
[uuidNorm]
);
if (existing.length > 0) {
// Solo actualizar si cambió a Cancelado
if (m.status === 'Cancelado' && existing[0].status !== 'Cancelado') {
await pool.query(
`UPDATE cfdis SET status = 'Cancelado', fecha_cancelacion = $2, actualizado_en = NOW()
WHERE id = $1`,
[existing[0].id, m.fechaCancelacion]
);
updated++;
}
} else {
// Insertar CFDI con datos básicos de metadata (sin XML)
const year = String(m.fechaEmision.getFullYear());
const month = String(m.fechaEmision.getMonth() + 1).padStart(2, '0');
// Upsert RFCs
const rfcEmisorId = await getOrCreateRfc(pool, m.rfcEmisor, m.nombreEmisor);
const rfcReceptorId = await getOrCreateRfc(pool, m.rfcReceptor, m.nombreReceptor);
await pool.query(
`INSERT INTO cfdis (
year, month, type, uuid, status, fecha_emision, fecha_cancelacion,
rfc_emisor_id, rfc_emisor, nombre_emisor,
rfc_receptor_id, rfc_receptor, nombre_receptor,
total, total_mxn, moneda, tipo_comprobante,
pac, fecha_cert_sat,
source, sat_sync_job_id, last_sat_sync, contribuyente_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9, $10,
$11, $12, $13,
$14, $14, 'MXN', $15,
$16, $17,
'sat-metadata', $18::uuid, NOW(), $19
)`,
[
year, month, m.type, uuidNorm, m.status, m.fechaEmision, m.fechaCancelacion,
rfcEmisorId, m.rfcEmisor, m.nombreEmisor,
rfcReceptorId, m.rfcReceptor, m.nombreReceptor,
m.monto, m.tipoComprobante,
m.rfcPac, m.fechaCertSat,
jobId, contribuyenteId,
]
);
inserted++;
}
// Invalidar metricas: tanto insert como status→Cancelado afectan el mes.
if (contribuyenteId) {
const anio = m.fechaEmision.getFullYear();
const mes = m.fechaEmision.getMonth() + 1;
await markForInvalidation(pool, contribuyenteId, anio, mes, 'SAT_METADATA').catch(
err => console.warn('[SAT] markForInvalidation falló:', err?.message || err),
);
}
} catch (error: any) {
console.error(`[SAT] Error guardando metadata CFDI ${m.uuid}:`, error.message);
}
}
return { inserted, updated };
}
/**
* Construye el identificador único de un request dentro de un job.
* Hay 2-N requests por job (daily=4, initial=N×4) — necesitamos distinguirlos
* para reusar el correcto en retries.
*/
function makeRequestKindKey(
fechaInicio: Date,
fechaFin: Date,
tipoCfdi: CfdiSyncType,
requestType: 'cfdi' | 'metadata',
): string {
return `${requestType}-${tipoCfdi}-${fechaInicio.toISOString().slice(0, 10)}-${fechaFin.toISOString().slice(0, 10)}`;
}
/**
* Persiste un (kindKey, requestId) en el mapa `sat_request_ids` con merge
* atómico SQL. Evita race conditions vs read-modify-write desde JS.
* Sigue actualizando `satRequestId` (singular) para backward compat.
*/
async function persistSatRequestId(jobId: string, kindKey: string, requestId: string): Promise<void> {
await prisma.$executeRawUnsafe(
`UPDATE sat_sync_jobs
SET sat_request_ids = COALESCE(sat_request_ids, '{}'::jsonb) || $1::jsonb,
sat_request_id = $2
WHERE id = $3`,
JSON.stringify({ [kindKey]: requestId }),
requestId,
jobId,
);
}
/**
* Solicita, espera y descarga paquetes del SAT para un rango+tipo+requestType.
* Retorna los contenidos base64 de los paquetes descargados.
*
* Reuso de requestIds en retries:
* - Antes de crear una nueva solicitud al SAT, busca si el job ya tiene un
* requestId guardado para esta misma kindKey (mismo rango+tipo+requestType).
* - Si existe: hace `verifySatRequest` directo. Si está listo → descarga.
* Si está pending/processing → entra al polling con ese mismo id.
* Si está failed/rejected o el verify lanza excepción → fallback a crear nuevo.
* - Esto evita quemar cuota del SAT en cada reintento (límite ~5 solicitudes
* activas por RFC).
*/
async function requestAndDownload(
ctx: SyncContext,
jobId: string,
fechaInicio: Date,
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);
// Intentar reusar requestId previo del mismo job/kindKey (caso retry)
const jobRow = await prisma.satSyncJob.findUnique({
where: { id: jobId },
select: { satRequestIds: true, tenantId: true, contribuyenteId: true, dateFrom: true, dateTo: true },
});
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] };
// }
// }
// }
let requestId: string | null = existingMap[kindKey] || null;
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
if (requestId) {
console.log(`[SAT] Reusando requestId previo (${label}): ${requestId}`);
try {
verifyResult = await verifySatRequest(ctx.service, requestId);
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)) {
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;
}
} catch (err: any) {
// El SAT a veces devuelve errores raros para requestIds expirados (>72h).
// Defensivo: descartar y crear nuevo.
console.warn(`[SAT] Verify del request reusado falló (${err.message}), creando nuevo`);
requestId = null;
verifyResult = undefined;
}
}
// Si no hay requestId válido reusable, crear nuevo
if (!requestId) {
console.log(`[SAT] Solicitando ${label} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`);
const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi, requestType);
if (!queryResult.success) {
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}`);
}
requestId = queryResult.requestId!;
console.log(`[SAT] Nueva solicitud creada (${label}): ${requestId}`);
await persistSatRequestId(jobId, kindKey, requestId);
}
// 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 < 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 este rango.`);
return { packageContents: [], totalCfdis: 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})`);
}
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);
if (!downloadResult.success) {
console.error(`[SAT] Error descargando paquete ${packageId}: ${downloadResult.message}`);
continue;
}
// Guardar en disco como respaldo
if (requestType === 'cfdi') {
try {
saveXmlsToDisk(downloadResult.packageContent, ctx.rfc, tipoCfdi, packageId);
} catch (err: any) {
console.error(`[SAT] Error guardando XMLs en disco: ${err.message}`);
}
}
packageContents.push(downloadResult.packageContent);
}
return { packageContents, totalCfdis: verifyResult.totalCfdis };
}
/**
* Procesa una solicitud de descarga para un rango de fechas.
* 1) Descarga XMLs de CFDIs vigentes → INSERT/UPDATE completo
* 2) Descarga metadata de todos (vigentes+cancelados) → INSERT básico o UPDATE status
*/
async function processDateRange(
ctx: SyncContext,
jobId: string,
fechaInicio: Date,
fechaFin: Date,
tipoCfdi: CfdiSyncType,
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', isDaily
);
totalFound += totalCfdis;
for (const content of packageContents) {
const cfdis = processPackage(content, tipoCfdi);
totalDownloaded += cfdis.length;
console.log(`[SAT] Procesando ${cfdis.length} CFDIs XML del paquete`);
const { inserted, updated } = await saveCfdis(await ctx.getPool(), cfdis, jobId, ctx.contribuyenteId);
totalInserted += inserted;
totalUpdated += updated;
}
} 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) {
await updateJobProgress(jobId, {
cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
});
}
return {
found: totalFound,
downloaded: totalDownloaded,
inserted: totalInserted,
updated: totalUpdated,
errors,
};
}
/**
* Descarga y procesa metadata de un rango de fechas para un tipo de CFDI.
* Metadata incluye vigentes + cancelados.
*/
async function processMetadataRange(
ctx: SyncContext,
jobId: string,
fechaInicio: Date,
fechaFin: Date,
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', isDaily
);
for (const content of packageContents) {
const items = processMetadataPackage(content, 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;
}
} 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, errors };
}
/**
* Determina el tamaño de bloque óptimo consultando metadata en chunks de 1 año.
* <= 15,000 CFDIs → bloques de 6 meses
* > 15,000 CFDIs → bloques de 3 meses
*
* El SAT no genera paquetes de metadata para rangos muy grandes (p. ej. 6 años),
* así que el sondeo se divide en bloques anuales.
*/
async function determineChunkMonths(
ctx: SyncContext,
jobId: string,
fechaInicio: Date,
fechaFin: Date,
): Promise<number> {
// Si el job previo del mismo tenant/contribuyente ya tenía chunks,
// inferimos que el volumen es alto y usamos 6 meses directamente
// para evitar el sondeo lento del SAT.
const previousJob = await prisma.satSyncJob.findFirst({
where: {
tenantId: ctx.tenantId,
contribuyenteId: ctx.contribuyenteId ?? null,
id: { not: jobId },
status: 'completed',
cfdisFound: { gt: 0 },
},
orderBy: { createdAt: 'desc' },
select: { satRequestIds: true, cfdisFound: true },
});
if (previousJob?.satRequestIds && Object.keys(previousJob.satRequestIds as Record<string, string>).length > 0) {
const chunkMonths = (previousJob.cfdisFound || 0) > 15_000 ? 3 : 6;
console.log(`[SAT] Reutilizando estrategia de job previo (${previousJob.cfdisFound} CFDIs) → bloques de ${chunkMonths} meses`);
return chunkMonths;
}
const THRESHOLD = 15_000;
const probeChunks = generateChunks(fechaInicio, fechaFin, 12);
let totalCfdis = 0;
for (const tipo of ['emitidos', 'recibidos'] as const) {
for (const { start, end } of probeChunks) {
try {
const { totalCfdis: count } = await requestAndDownload(
ctx, jobId, start, end, tipo, 'metadata'
);
totalCfdis += count;
console.log(`[SAT] Sondeo metadata ${tipo} ${start.toISOString().slice(0, 10)}${end.toISOString().slice(0, 10)}: ${count} CFDIs`);
} catch (error: any) {
console.log(`[SAT] No se pudo sondear metadata ${tipo} ${start.toISOString().slice(0, 10)}${end.toISOString().slice(0, 10)}: ${error.message}`);
}
}
}
const chunkMonths = totalCfdis > THRESHOLD ? 3 : 6;
console.log(`[SAT] Total estimado: ${totalCfdis} CFDIs → bloques de ${chunkMonths} meses`);
return chunkMonths;
}
/**
* Genera bloques de fechas segmentados por N meses.
*/
function generateChunks(fechaInicio: Date, fechaFin: Date, chunkMonths: number): { start: Date; end: Date }[] {
const chunks: { start: Date; end: Date }[] = [];
let current = new Date(fechaInicio);
while (current < fechaFin) {
const chunkEnd = new Date(current.getFullYear(), current.getMonth() + chunkMonths, 0, 23, 59, 59);
const end = chunkEnd > fechaFin ? fechaFin : chunkEnd;
chunks.push({ start: new Date(current), end });
current = new Date(current.getFullYear(), current.getMonth() + chunkMonths, 1);
}
return chunks;
}
/**
* Ejecuta sincronización inicial o por rango personalizado.
* - XMLs: bloques de 3 o 6 meses (según volumen)
* - Metadata: bloques anuales (siempre)
*/
async function processInitialSync(
ctx: SyncContext,
jobId: string,
customDateFrom?: Date,
customDateTo?: Date
): Promise<void> {
// 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(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);
const xmlChunks = generateChunks(inicioHistorico, fechaFin, chunkMonths);
const metaChunks = generateChunks(inicioHistorico, fechaFin, 12); // bloques de 1 año
console.log(`[SAT] Sincronización: ${xmlChunks.length} bloques XML (${chunkMonths}m) + ${metaChunks.length} bloques metadata (36m)`);
let totalFound = 0;
let totalDownloaded = 0;
let totalInserted = 0;
let totalUpdated = 0;
const totalSteps = xmlChunks.length * 2 + metaChunks.length * 2; // emitidos + recibidos por cada chunk
let completedSteps = 0;
// Helper para actualizar progreso acumulado
async function reportProgress() {
completedSteps++;
const progressPercent = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
await updateJobProgress(jobId, {
cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
progressPercent,
});
}
// Paso 2: Descargar XMLs de vigentes (bloques de 3/6 meses)
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)}`);
try {
const emitidos = await processDateRange(ctx, jobId, start, end, 'emitidos', true);
totalFound += emitidos.found;
totalDownloaded += emitidos.downloaded;
totalInserted += emitidos.inserted;
totalUpdated += emitidos.updated;
} catch (error: any) {
console.error(`[SAT] Error emitidos XML bloque ${i + 1}:`, error.message);
}
await reportProgress();
try {
const recibidos = await processDateRange(ctx, jobId, start, end, 'recibidos', true);
totalFound += recibidos.found;
totalDownloaded += recibidos.downloaded;
totalInserted += recibidos.inserted;
totalUpdated += recibidos.updated;
} catch (error: any) {
console.error(`[SAT] Error recibidos XML bloque ${i + 1}:`, error.message);
}
await reportProgress();
await new Promise(resolve => setTimeout(resolve, 5000));
}
// Paso 3: Descargar metadata (bloques anuales)
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)}`);
try {
const { inserted, updated } = await processMetadataRange(ctx, jobId, start, end, 'emitidos');
totalInserted += inserted;
totalUpdated += updated;
} catch (error: any) {
console.error(`[SAT] Error metadata emitidos bloque ${i + 1}:`, error.message);
}
await reportProgress();
try {
const { inserted, updated } = await processMetadataRange(ctx, jobId, start, end, 'recibidos');
totalInserted += inserted;
totalUpdated += updated;
} catch (error: any) {
console.error(`[SAT] Error metadata recibidos bloque ${i + 1}:`, error.message);
}
await reportProgress();
await new Promise(resolve => setTimeout(resolve, 5000));
}
await updateJobProgress(jobId, {
cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
progressPercent: 100,
});
}
/**
* Ejecuta sincronización diaria (mes actual)
*/
/**
* Procesa un rango personalizado de fechas.
* Si el rango supera 6 meses, divide en bloques de 6 meses.
* Si no, usa el rango directamente.
*/
async function processCustomRangeSync(
ctx: SyncContext,
jobId: string,
dateFrom: Date,
dateTo: Date
): Promise<void> {
const diffMs = dateTo.getTime() - dateFrom.getTime();
const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30);
const MAX_MONTHS_DIRECT = 6;
let totalFound = 0;
let totalDownloaded = 0;
let totalInserted = 0;
let totalUpdated = 0;
if (diffMonths <= MAX_MONTHS_DIRECT) {
// Rango <= 6 meses: solicitud directa sin dividir
console.log(`[SAT] Rango personalizado (${diffMonths.toFixed(1)} meses) — solicitud directa`);
for (const tipo of ['emitidos', 'recibidos'] as const) {
try {
const result = await processDateRange(ctx, jobId, dateFrom, dateTo, tipo);
totalFound += result.found;
totalDownloaded += result.downloaded;
totalInserted += result.inserted;
totalUpdated += result.updated;
} catch (error: any) {
console.error(`[SAT] Error ${tipo} rango personalizado:`, error.message);
}
}
} else {
// Rango > 6 meses: dividir en bloques de 6 meses
const chunks = generateChunks(dateFrom, dateTo, 6);
console.log(`[SAT] Rango personalizado (${diffMonths.toFixed(1)} meses) — ${chunks.length} bloques de 6 meses`);
for (let i = 0; i < chunks.length; i++) {
const { start, end } = chunks[i];
console.log(`[SAT] Bloque ${i + 1}/${chunks.length}: ${start.toISOString().slice(0, 10)}${end.toISOString().slice(0, 10)}`);
for (const tipo of ['emitidos', 'recibidos'] as const) {
try {
const result = await processDateRange(ctx, jobId, start, end, tipo);
totalFound += result.found;
totalDownloaded += result.downloaded;
totalInserted += result.inserted;
totalUpdated += result.updated;
} catch (error: any) {
console.error(`[SAT] Error ${tipo} bloque ${i + 1}:`, error.message);
}
}
// Pausa entre bloques para no saturar el SAT
if (i < chunks.length - 1) {
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
}
// Metadata: siempre el rango completo (incluye cancelados)
for (const tipo of ['emitidos', 'recibidos'] as const) {
try {
const { inserted, updated } = await processMetadataRange(ctx, jobId, dateFrom, dateTo, tipo);
totalInserted += inserted;
totalUpdated += updated;
} catch (error: any) {
console.error(`[SAT] Error metadata ${tipo} rango personalizado:`, error.message);
}
}
await updateJobProgress(jobId, {
cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
});
}
/**
* 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.
*/
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);
let totalFound = 0;
let totalDownloaded = 0;
let totalInserted = 0;
let totalUpdated = 0;
console.log(`[SAT] Incremental: ${desde.toISOString()}${ahora.toISOString()} (${INCREMENTAL_WINDOW_HOURS}h)`);
for (const tipo of ['emitidos', 'recibidos'] as const) {
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) {
const { inserted, updated } = await processMetadataRange(ctx, jobId, desde, ahora, tipo);
totalInserted += inserted;
totalUpdated += updated;
}
await updateJobProgress(jobId, {
cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
});
}
/**
* 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<void> {
// 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;
interface DailyStage {
id: string;
label: string;
isMetadata: boolean;
run: () => Promise<void>;
}
const makeStage = (
id: string,
label: string,
isMetadata: boolean,
runImpl: (stageId: string) => Promise<void>,
): 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;
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 });
}
},
));
}
}
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;
}
}
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 {
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) {
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<ReturnType<typeof verifySatRequest>> | 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 };
}
/**
* Inicia la sincronización con el SAT
*/
export async function startSync(
tenantId: string,
type: SatSyncType = 'daily',
dateFrom?: Date,
dateTo?: Date,
contribuyenteId?: string
): Promise<string> {
// Try per-contribuyente FIEL first (despachos), then legacy (Horux360)
let decryptedFiel = null;
if (contribuyenteId) {
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { databaseName: true } });
if (tenant) {
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
decryptedFiel = await getDecryptedFielContribuyente(pool, contribuyenteId);
}
}
if (!decryptedFiel) {
decryptedFiel = await getDecryptedFiel(tenantId);
}
if (!decryptedFiel) {
throw new Error('No hay FIEL configurada o está vencida');
}
const fielData: FielData = {
cerContent: decryptedFiel.cerContent,
keyContent: decryptedFiel.keyContent,
password: decryptedFiel.password,
};
const service = createSatService(fielData);
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true },
});
if (!tenant) {
throw new Error('Tenant no encontrado');
}
// Lock a nivel (tenantId, contribuyenteId). Contribuyentes distintos dentro
// del mismo tenant (despacho) pueden sincronizarse en paralelo — cada uno
// usa su propio FIEL y su propio conjunto de CFDIs. El null de Horux 360
// (tenant-wide) solo se bloquea contra sí mismo.
const activeSync = await prisma.satSyncJob.findFirst({
where: {
tenantId,
contribuyenteId: contribuyenteId ?? null,
status: { in: ['pending', 'running'] },
},
});
if (activeSync) {
throw new Error('Ya hay una sincronización en curso');
}
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.
const isCustomRange = type === 'initial' && (!!dateFrom || !!dateTo);
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,
startedAt: now,
isCustomRange,
},
});
const ctx: SyncContext = {
fielData,
service,
rfc: decryptedFiel.rfc,
tenantId,
databaseName: tenant.databaseName,
contribuyenteId: contribuyenteId || null,
getPool: () => tenantDb.getPool(tenantId, tenant.databaseName),
};
// Ejecutar sincronización en background
(async () => {
try {
if (type === 'initial') {
await processInitialSync(ctx, job.id, dateFrom, dateTo);
} else if (type === 'incremental') {
await processIncrementalSync(ctx, job.id);
} else if (dateFrom && dateTo) {
await processCustomRangeSync(ctx, job.id, dateFrom, dateTo);
} else {
await processDailySync(ctx, job.id);
}
await updateJobProgress(job.id, {
status: 'completed',
completedAt: new Date(),
progressPercent: 100,
});
console.log(`[SAT] Sincronización ${job.id} completada`);
} catch (error: any) {
console.error(`[SAT] Error en sincronización ${job.id}:`, error);
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;
// 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;
// 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: progressErrorMessage ?? `Timeout (intento ${nextRetryNumber}/${maxAttempts}). Reintento programado para ${retryLabel}.`,
retryCount: nextRetryNumber,
nextRetryAt: nextRetry ?? null as any,
});
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 = 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,
completedAt: new Date(),
});
}
}
})();
return job.id;
}
/**
* Reintenta jobs de SAT que tienen nextRetryAt pasado.
* Llamado por el cron cada hora.
*/
export async function retryTimedOutJobs(): Promise<void> {
// No filtramos por retryCount aquí porque el max es per-policy (varía por
// type + isCustomRange). El catch del retry ya valida y marca failed si
// se excede. Los jobs con maxRetries=0 nunca llegan a status='pending',
// van directo a 'failed', así que no aparecen acá.
const pendingJobs = await prisma.satSyncJob.findMany({
where: {
status: 'pending',
nextRetryAt: { lte: new Date() },
},
include: { tenant: { select: { id: true, databaseName: true, rfc: true } } },
});
if (pendingJobs.length === 0) return;
console.log(`[SAT Retry] ${pendingJobs.length} job(s) pendientes de reintento`);
for (const job of pendingJobs) {
try {
// Verificar que no haya otro sync activo para el MISMO (tenant, contribuyente).
// Contribuyentes distintos pueden correr en paralelo.
const activeSync = await prisma.satSyncJob.findFirst({
where: {
tenantId: job.tenantId,
contribuyenteId: job.contribuyenteId ?? null,
status: 'running',
},
});
if (activeSync) {
console.log(`[SAT Retry] (${job.tenant.rfc}, contrib=${job.contribuyenteId || 'tenant-wide'}) tiene sync activo, posponiendo`);
await updateJobProgress(job.id, {
nextRetryAt: new Date(Date.now() + 60 * 60 * 1000), // +1 hora
});
continue;
}
console.log(`[SAT Retry] Reintentando job ${job.id} (${job.tenant.rfc}), intento ${job.retryCount}/${getRetryPolicy(job).maxRetries}`);
// Try per-contribuyente FIEL first, then legacy
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),
};
// 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 {
if (job.type === 'initial') {
await processInitialSync(ctx, job.id, job.dateFrom, job.dateTo);
} else if (job.type === 'incremental') {
await processIncrementalSync(ctx, job.id);
} else {
await processDailySync(ctx, job.id, resumeFromStage);
}
await updateJobProgress(job.id, {
status: 'completed',
completedAt: new Date(),
progressPercent: 100,
errorMessage: null as any,
});
console.log(`[SAT Retry] Job ${job.id} completado en reintento ${job.retryCount}`);
} catch (retryError: any) {
console.error(`[SAT Retry] Job ${job.id} falló de nuevo:`, 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 policy = getRetryPolicy(job);
const nextRetryNumber = job.retryCount + 1;
// 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;
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: progressErrorMessage ?? `Timeout (intento ${nextRetryNumber}/${maxAttempts}). Reintento programado para ${retryLabel}.`,
retryCount: nextRetryNumber,
nextRetryAt: nextRetry ?? null as any,
});
} 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 Retry] Error procesando job ${job.id}:`, error.message);
await updateJobProgress(job.id, {
status: 'failed',
errorMessage: error.message,
completedAt: new Date(),
});
}
}
}
/**
* 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<void> {
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
*/
export async function getSyncStatus(tenantId: string, contribuyenteId?: string): Promise<{
hasActiveSync: boolean;
currentJob?: SatSyncJob;
lastCompletedJob?: SatSyncJob;
lastCompletedInitialJob?: SatSyncJob;
totalCfdisSynced: number;
}> {
const contribuyenteFilter = contribuyenteId ? { contribuyenteId } : {};
const activeJob = await prisma.satSyncJob.findFirst({
where: {
tenantId,
...contribuyenteFilter,
status: { in: ['pending', 'running'] },
},
orderBy: { createdAt: 'desc' },
});
const lastCompleted = await prisma.satSyncJob.findFirst({
where: {
tenantId,
...contribuyenteFilter,
status: 'completed',
},
orderBy: { completedAt: 'desc' },
});
const lastCompletedInitial = await prisma.satSyncJob.findFirst({
where: {
tenantId,
...contribuyenteFilter,
type: 'initial',
status: 'completed',
},
orderBy: { completedAt: 'desc' },
});
const totals = await prisma.satSyncJob.aggregate({
where: {
tenantId,
...contribuyenteFilter,
status: 'completed',
},
_sum: {
cfdisInserted: true,
},
});
const mapJob = (job: any): SatSyncJob => ({
id: job.id,
tenantId: job.tenantId,
type: job.type,
status: job.status,
dateFrom: job.dateFrom.toISOString(),
dateTo: job.dateTo.toISOString(),
cfdiType: job.cfdiType ?? undefined,
satRequestId: job.satRequestId ?? undefined,
satPackageIds: job.satPackageIds,
cfdisFound: job.cfdisFound,
cfdisDownloaded: job.cfdisDownloaded,
cfdisInserted: job.cfdisInserted,
cfdisUpdated: job.cfdisUpdated,
progressPercent: job.progressPercent,
errorMessage: job.errorMessage ?? undefined,
startedAt: job.startedAt?.toISOString(),
completedAt: job.completedAt?.toISOString(),
createdAt: job.createdAt.toISOString(),
retryCount: job.retryCount,
});
return {
hasActiveSync: !!activeJob,
currentJob: activeJob ? mapJob(activeJob) : undefined,
lastCompletedJob: lastCompleted ? mapJob(lastCompleted) : undefined,
lastCompletedInitialJob: lastCompletedInitial ? mapJob(lastCompletedInitial) : undefined,
totalCfdisSynced: totals._sum.cfdisInserted || 0,
};
}
/**
* Obtiene el historial de sincronizaciones
*/
export async function getSyncHistory(
tenantId: string,
page: number = 1,
limit: number = 10,
contribuyenteId?: string
): Promise<{ jobs: SatSyncJob[]; total: number }> {
const contribuyenteFilter = contribuyenteId ? { contribuyenteId } : {};
const [jobs, total] = await Promise.all([
prisma.satSyncJob.findMany({
where: { tenantId, ...contribuyenteFilter },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.satSyncJob.count({ where: { tenantId, ...contribuyenteFilter } }),
]);
return {
jobs: jobs.map(job => ({
id: job.id,
tenantId: job.tenantId,
type: job.type,
status: job.status,
dateFrom: job.dateFrom.toISOString(),
dateTo: job.dateTo.toISOString(),
cfdiType: job.cfdiType ?? undefined,
satRequestId: job.satRequestId ?? undefined,
satPackageIds: job.satPackageIds,
cfdisFound: job.cfdisFound,
cfdisDownloaded: job.cfdisDownloaded,
cfdisInserted: job.cfdisInserted,
cfdisUpdated: job.cfdisUpdated,
progressPercent: job.progressPercent,
errorMessage: job.errorMessage ?? undefined,
startedAt: job.startedAt?.toISOString(),
completedAt: job.completedAt?.toISOString(),
createdAt: job.createdAt.toISOString(),
retryCount: job.retryCount,
})),
total,
};
}
/**
* Reintenta un job fallido
*/
export async function retryJob(jobId: string): Promise<string> {
const job = await prisma.satSyncJob.findUnique({
where: { id: jobId },
});
if (!job) {
throw new Error('Job no encontrado');
}
if (job.status !== 'failed') {
throw new Error('Solo se pueden reintentar jobs fallidos');
}
return startSync(job.tenantId, job.type, job.dateFrom, job.dateTo, job.contribuyenteId ?? undefined);
}