feat: mejoras SAT, auth, web y migración
SAT: - Cron diario 6-10 AM con grupos de tenants (~20% por hora). - Retries fijos de daily sync a 9 AM y 4 PM CDMX. - Filtrado de contribuyentes con FIEL vigente en cron. - Timeout de 5 min en cliente HTTP del SAT. - Manejo defensivo de 404, EmptyResult (5004) y solicitudes agotadas. - Fechas formateadas en zona horaria America/Mexico_City. - Patch a @nodecfdi/sat-ws-descarga-masiva para evitar getResponse crash. - Sweep de jobs stale con thresholds ajustados. Auth/Web: - Primer pago de suscripción define periodo activo en webhook. - Rate limit de login: 25 intentos / 15 min. - Recuperación de contraseña: 24h de validez. - Soporte viewingTenantId en contribuyentes. - Timeout y estado de carga al crear organización en Facturapi. - Filtros por cliente y cartera en Mis Asignados. - Orden alfabético en selector de contribuyente. DB: - Migración 057: unique index de declaraciones incluye impuestos.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import cron from 'node-cron';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { startSync, getSyncStatus, retryTimedOutJobs } from '../services/sat/sat.service.js';
|
||||
import { startSync, getSyncStatus, retryTimedOutJobs, continuePendingDailyRequests } from '../services/sat/sat.service.js';
|
||||
import { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js';
|
||||
import { hasFielConfigured } from '../services/fiel.service.js';
|
||||
import { consultarOpinion, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js';
|
||||
@@ -11,18 +11,21 @@ import { consultarConstancia, purgeConstanciasAntiguas } from '../services/const
|
||||
import { tenantDb } from '../config/database.js';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
|
||||
const SYNC_CRON_SCHEDULE = '0 6-10 * * *'; // 6:00–10:00 AM CDMX — ~20% de tenants por hora (5 grupos); el SAT cierra el servicio en la noche
|
||||
const RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10:00 AM todos los días
|
||||
const RETRY_9AM_CRON_SCHEDULE = '0 9 * * *'; // 9:00 AM todos los días
|
||||
const RETRY_4PM_CRON_SCHEDULE = '0 16 * * *'; // 4:00 PM todos los días
|
||||
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
|
||||
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
|
||||
const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual)
|
||||
const INCREMENTAL_CRON_SCHEDULE = '0 11,15,19 * * *'; // 11:00, 15:00 y 19:00; fuera de ese rango el daily (03:00) cubre
|
||||
const INCREMENTAL_CRON_SCHEDULE = '0 11,15,19 * * *'; // 11:00, 15:00 y 19:00; fuera de ese rango el daily (6-10 AM) cubre
|
||||
const SUBSCRIPTION_LIFECYCLE_CRON = '30 2 * * *'; // 2:30 AM diario — aplica pending changes + expira trials
|
||||
const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos pre-vencimiento (7d/3d/1d/0d)
|
||||
|
||||
let isRunning = false;
|
||||
let isIncrementalRunning = false;
|
||||
let isRecoveryRunning = false;
|
||||
let isDailyRetryRunning = false;
|
||||
|
||||
/**
|
||||
* Verifica si un tenant tiene FIEL a nivel tenant (legacy Horux 360)
|
||||
@@ -46,7 +49,7 @@ async function hasAnyFielConfigured(tenantId: string, databaseName?: string | nu
|
||||
try {
|
||||
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT 1 FROM fiel_contribuyente WHERE is_active = true LIMIT 1`
|
||||
`SELECT 1 FROM fiel_contribuyente WHERE is_active = true AND valid_until > NOW() LIMIT 1`
|
||||
);
|
||||
return rows.length > 0;
|
||||
} catch (err: any) {
|
||||
@@ -94,6 +97,41 @@ async function needsInitialSync(tenantId: string, contribuyenteId?: string): Pro
|
||||
return !completedSync;
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve los entidad_id de contribuyentes con FIEL vigente.
|
||||
* Si el tenant tiene FIEL legacy vigente a nivel tenant, devuelve todos
|
||||
* (startSync hace fallback por RFC). `total` permite distinguir "tenant sin
|
||||
* contribuyentes" (path legacy) de "ninguno con FIEL vigente" (se omite).
|
||||
*/
|
||||
async function getContribuyentesParaSync(
|
||||
tenantId: string,
|
||||
databaseName: string,
|
||||
logPrefix: string
|
||||
): Promise<{ ids: string[]; total: number }> {
|
||||
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||
const { rows: allRows } = await pool.query('SELECT entidad_id FROM contribuyentes');
|
||||
const allIds: string[] = allRows.map((r: any) => r.entidad_id);
|
||||
if (allIds.length === 0) return { ids: [], total: 0 };
|
||||
|
||||
const hasLegacyFiel = await hasFielConfigured(tenantId);
|
||||
if (hasLegacyFiel) return { ids: allIds, total: allIds.length };
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT c.entidad_id FROM contribuyentes c
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM fiel_contribuyente f
|
||||
WHERE f.contribuyente_id = c.entidad_id
|
||||
AND f.is_active = true AND f.valid_until > NOW()
|
||||
)`
|
||||
);
|
||||
const ids: string[] = rows.map((r: any) => r.entidad_id);
|
||||
const skipped = allIds.length - ids.length;
|
||||
if (skipped > 0) {
|
||||
console.log(`${logPrefix} Tenant ${tenantId}: ${skipped} contribuyente(s) sin FIEL vigente, omitidos`);
|
||||
}
|
||||
return { ids, total: allIds.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta sincronización para un tenant y sus contribuyentes
|
||||
*/
|
||||
@@ -107,9 +145,12 @@ async function syncTenant(tenantId: string): Promise<void> {
|
||||
|
||||
let contribuyenteIds: string[] = [];
|
||||
if (tenant?.databaseName) {
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
const { rows } = await pool.query('SELECT entidad_id FROM contribuyentes');
|
||||
contribuyenteIds = rows.map((r: any) => r.entidad_id);
|
||||
const { ids, total } = await getContribuyentesParaSync(tenantId, tenant.databaseName, '[SAT Cron]');
|
||||
if (total > 0 && ids.length === 0) {
|
||||
console.log(`[SAT Cron] Tenant ${tenantId}: ningún contribuyente con FIEL vigente, se omite`);
|
||||
return;
|
||||
}
|
||||
contribuyenteIds = ids;
|
||||
}
|
||||
|
||||
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy Horux 360)
|
||||
@@ -153,6 +194,27 @@ async function syncTenant(tenantId: string): Promise<void> {
|
||||
/**
|
||||
* Ejecuta el job de sincronización para todos los tenants
|
||||
*/
|
||||
const DAILY_GROUPS = 5; // ventanas 6,7,8,9,10 AM
|
||||
const DAILY_WINDOW_START = 6; // primera ventana CDMX
|
||||
|
||||
/** Hash estable del tenantId → grupo 0..DAILY_GROUPS-1 (reparte ~20% por ventana) */
|
||||
function tenantGroup(tenantId: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < tenantId.length; i++) h = (h * 31 + tenantId.charCodeAt(i)) >>> 0;
|
||||
return h % DAILY_GROUPS;
|
||||
}
|
||||
|
||||
/** Hora actual en zona America/Mexico_City (0-23) */
|
||||
function cdmxHour(): number {
|
||||
return Number(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/Mexico_City',
|
||||
hour: 'numeric',
|
||||
hour12: false,
|
||||
}).format(new Date())
|
||||
);
|
||||
}
|
||||
|
||||
async function runSyncJob(): Promise<void> {
|
||||
if (isRunning) {
|
||||
console.log('[SAT Cron] Job ya en ejecución, omitiendo');
|
||||
@@ -171,13 +233,27 @@ async function runSyncJob(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const hour = cdmxHour();
|
||||
const groupIndex = hour - DAILY_WINDOW_START; // 6→0 … 10→4
|
||||
if (groupIndex < 0 || groupIndex >= DAILY_GROUPS) {
|
||||
console.log(`[SAT Cron] Hora CDMX ${hour} fuera de ventana 6-10 AM, omitiendo`);
|
||||
return;
|
||||
}
|
||||
const groupTenants = tenantIds.filter(id => tenantGroup(id) === groupIndex);
|
||||
console.log(`[SAT Cron] Ventana ${hour}:00 CDMX — grupo ${groupIndex + 1}/${DAILY_GROUPS}: ${groupTenants.length}/${tenantIds.length} tenants`);
|
||||
|
||||
if (groupTenants.length === 0) {
|
||||
console.log('[SAT Cron] No hay tenants en este grupo');
|
||||
return;
|
||||
}
|
||||
|
||||
// Procesar en lotes para no saturar
|
||||
for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) {
|
||||
const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS);
|
||||
for (let i = 0; i < groupTenants.length; i += CONCURRENT_SYNCS) {
|
||||
const batch = groupTenants.slice(i, i + CONCURRENT_SYNCS);
|
||||
await Promise.all(batch.map(syncTenant));
|
||||
|
||||
// Pequeña pausa entre lotes
|
||||
if (i + CONCURRENT_SYNCS < tenantIds.length) {
|
||||
if (i + CONCURRENT_SYNCS < groupTenants.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
@@ -232,9 +308,12 @@ async function incrementalSyncTenant(tenantId: string): Promise<void> {
|
||||
|
||||
let contribuyenteIds: string[] = [];
|
||||
if (tenant?.databaseName) {
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
const { rows } = await pool.query('SELECT entidad_id FROM contribuyentes');
|
||||
contribuyenteIds = rows.map((r: any) => r.entidad_id);
|
||||
const { ids, total } = await getContribuyentesParaSync(tenantId, tenant.databaseName, '[SAT Cron Inc]');
|
||||
if (total > 0 && ids.length === 0) {
|
||||
console.log(`[SAT Cron Inc] Tenant ${tenantId}: ningún contribuyente con FIEL vigente, se omite`);
|
||||
return;
|
||||
}
|
||||
contribuyenteIds = ids;
|
||||
}
|
||||
|
||||
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy)
|
||||
@@ -529,9 +608,30 @@ export async function runRecoverySyncJob(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function runDailyRetryJob(): Promise<void> {
|
||||
if (isDailyRetryRunning) {
|
||||
console.log('[SAT Daily Retry] Ya en ejecución, omitiendo');
|
||||
return;
|
||||
}
|
||||
|
||||
isDailyRetryRunning = true;
|
||||
console.log('[SAT Daily Retry] Iniciando retry programado de daily syncs');
|
||||
|
||||
try {
|
||||
await continuePendingDailyRequests();
|
||||
console.log('[SAT Daily Retry] Retry programado completado');
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Daily Retry] Error:', error.message);
|
||||
} finally {
|
||||
isDailyRetryRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let retryTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let recoveryTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let retry9amTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let retry4pmTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let opinionTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let csfTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let incrementalTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
@@ -585,6 +685,28 @@ export function startSatSyncJob(): void {
|
||||
timezone: 'America/Mexico_City',
|
||||
});
|
||||
|
||||
// Retomar jobs diarios que quedaron pending por timeout de polling.
|
||||
// 9:00 AM y 4:00 PM CDMX, complemento a los retries automáticos de 6h/12h.
|
||||
retry9amTask = cron.schedule(RETRY_9AM_CRON_SCHEDULE, async () => {
|
||||
try {
|
||||
await runDailyRetryJob();
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Daily Retry 9AM] Error:', error.message);
|
||||
}
|
||||
}, {
|
||||
timezone: 'America/Mexico_City',
|
||||
});
|
||||
|
||||
retry4pmTask = cron.schedule(RETRY_4PM_CRON_SCHEDULE, async () => {
|
||||
try {
|
||||
await runDailyRetryJob();
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Daily Retry 4PM] Error:', error.message);
|
||||
}
|
||||
}, {
|
||||
timezone: 'America/Mexico_City',
|
||||
});
|
||||
|
||||
// Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale
|
||||
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
|
||||
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS)
|
||||
@@ -691,6 +813,7 @@ export function startSatSyncJob(): void {
|
||||
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
console.log(`[SAT Cron] Retry programado cada hora`);
|
||||
console.log(`[SAT Recovery Cron] Programado para: ${RECOVERY_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
console.log(`[SAT Daily Retry] Programado para: ${RETRY_9AM_CRON_SCHEDULE} y ${RETRY_4PM_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
console.log(`[CSF Cron] Programado para: ${CSF_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
@@ -714,6 +837,14 @@ export function stopSatSyncJob(): void {
|
||||
recoveryTask.stop();
|
||||
recoveryTask = null;
|
||||
}
|
||||
if (retry9amTask) {
|
||||
retry9amTask.stop();
|
||||
retry9amTask = null;
|
||||
}
|
||||
if (retry4pmTask) {
|
||||
retry4pmTask.stop();
|
||||
retry4pmTask = null;
|
||||
}
|
||||
if (opinionTask) {
|
||||
opinionTask.stop();
|
||||
opinionTask = null;
|
||||
|
||||
Reference in New Issue
Block a user