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:
Horux Dev
2026-08-02 20:06:51 +00:00
parent dfc0183c12
commit 284c7620a9
14 changed files with 409 additions and 45 deletions

View File

@@ -38,10 +38,14 @@ const createSchema = z.object({
const updateSchema = createSchema.partial();
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, req.user!.tenantId);
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, effectiveTenantId(req));
// Batch lookup de nombres de supervisores
const supervisorIds = [...new Set(rows.map(r => r.supervisorUserId).filter(Boolean))] as string[];
@@ -65,7 +69,7 @@ export async function list(req: Request, res: Response, next: NextFunction) {
export async function getById(req: Request, res: Response, next: NextFunction) {
try {
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id), req.user!.tenantId);
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id), effectiveTenantId(req));
if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
return res.json(row);
} catch (err) { return next(err); }

View File

@@ -253,12 +253,19 @@ async function handlePaymentNotification(paymentId: string) {
// precio de renewal. Se detecta comparando el monto cobrado contra lo que
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
const esPrimerPago = subscription.status === 'pending';
const updateData: { status: string; currentPeriodEnd?: Date } = { status: 'authorized' };
const updateData: { status: string; currentPeriodStart?: Date; currentPeriodEnd?: Date } = { status: 'authorized' };
// Extender currentPeriodEnd para renovaciones recurrentes.
// El primer pago ya tiene currentPeriodEnd establecido al crear la suscripción;
// solo extendemos en pagos subsecuentes para reflejar el nuevo período cobrado.
if (!esPrimerPago && subscription.currentPeriodEnd) {
if (esPrimerPago) {
// El primer pago aprobado define el inicio del período activo.
// Algunos flujos (cambio de plan, creación manual) dejan currentPeriodEnd
// en null, así que lo establecemos aquí para evitar que la suscripción
// aparezca vencida aunque esté authorized.
const periodStart = payment.dateApproved ? new Date(payment.dateApproved) : new Date();
updateData.currentPeriodStart = periodStart;
updateData.currentPeriodEnd = computeNextPeriodEnd(periodStart, subscription.frequency);
console.log(`[WEBHOOK] Subscription ${subscription.id} primer pago aprobado: período ${updateData.currentPeriodStart.toISOString()}${updateData.currentPeriodEnd.toISOString()} (${subscription.frequency})`);
} else if (subscription.currentPeriodEnd) {
// Extender currentPeriodEnd para renovaciones recurrentes.
const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency);
updateData.currentPeriodEnd = nextPeriodEnd;
console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`);

View File

@@ -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:0010: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;

View File

@@ -0,0 +1,15 @@
-- Fix: la constraint unique de declaraciones normales solo consideraba
-- (año, mes, contribuyente_id). Esto impedía subir una declaración normal de
-- ISRTP si ya existía una normal de ISN para el mismo mes y contribuyente.
-- Ahora la unicidad se valida por (año, mes, contribuyente_id, impuestos),
-- permitiendo una declaración normal distinta por cada conjunto de impuestos.
DROP INDEX IF EXISTS uniq_declaracion_normal_mes_contrib;
CREATE UNIQUE INDEX IF NOT EXISTS uniq_declaracion_normal_mes_contrib_impuestos
ON declaraciones_provisionales(año, mes, contribuyente_id, impuestos)
WHERE tipo = 'normal';
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 57, '057_declaraciones_unique_por_impuestos')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -6,10 +6,10 @@ import { strictLimit } from '../middlewares/rate-limit.middleware.js';
const router: IRouter = Router();
// Rate limiting: 10 login attempts per 15 minutes per IP
// Rate limiting: 25 login attempts per 15 minutes per IP
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
max: 25,
message: { message: 'Demasiados intentos de login. Intenta de nuevo en 15 minutos.' },
standardHeaders: true,
legacyHeaders: false,

View File

@@ -323,7 +323,7 @@ export async function logout(token: string): Promise<void> {
// Password reset
// ============================================================================
const PASSWORD_RESET_EXPIRY_MS = 60 * 60 * 1000; // 1 hora
const PASSWORD_RESET_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 horas
/**
* Solicita recuperación de contraseña. No revela si el email existe (anti-enumeration).

View File

@@ -17,6 +17,23 @@ export interface FielData {
password: string;
}
/**
* Timeout explícito para el cliente HTTP del SAT (ms).
*
* IMPORTANTE: la librería @nodecfdi/sat-ws-descarga-masiva@2.0.0 tiene un bug
* en HttpsWebClient: si no se pasa un timeout explícito y ocurre un timeout
* de red, rechaza con un `Error` nativo en vez de `WebClientException`.
* Eso rompe el manejo de errores posterior y produce
* `webError.getResponse is not a function`.
*
* Al pasar un timeout explícito, `_timeout` queda definido y la librería
* envuelve el timeout como `WebClientException`, permitiendo reintentos sanos.
*
* El endpoint de verificación del SAT suele tardar >30s en responder; 5 minutos
* da margen sin dejar la conexión colgada indefinidamente.
*/
const SAT_WEB_CLIENT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutos
/**
* Crea el servicio de descarga masiva del SAT usando los datos de la FIEL
*/
@@ -29,8 +46,13 @@ export function createSatService(fielData: FielData): Service {
throw new Error('La FIEL no es válida o está vencida');
}
// Crear cliente HTTP
const webClient = new HttpsWebClient();
// Crear cliente HTTP con timeout explícito para evitar el bug de la librería
// cuando ocurre un timeout de red.
const webClient = new (HttpsWebClient as any)(
undefined,
undefined,
SAT_WEB_CLIENT_TIMEOUT_MS,
);
// Crear request builder con la FIEL
const requestBuilder = new FielRequestBuilder(fiel);
@@ -73,10 +95,13 @@ export async function querySat(
): Promise<QueryResult> {
try {
// El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca
// a medianoche, dos fechas dentro del mismo día calendario resultan iguales.
// Ajustamos fechaFin al día siguiente para evitar el error.
// a medianoche en zona horaria de México, dos fechas dentro del mismo día
// calendario mexicano resultan iguales. Ajustamos fechaFin al día siguiente
// en hora México para evitar el error.
let adjustedFechaFin = fechaFin;
if (formatDateForSat(fechaInicio) === formatDateForSat(fechaFin)) {
if (isSameMexicoDay(fechaInicio, fechaFin)) {
// Sumar 24h en ms es suficiente porque formatDateForSat solo usa la fecha
// calendaria de México, no la hora.
adjustedFechaFin = new Date(fechaFin.getTime() + 24 * 60 * 60 * 1000);
}
@@ -110,7 +135,30 @@ export async function querySat(
statusCode: result.getStatus().getCode().toString(),
};
} catch (error: any) {
console.error('[SAT Query Error]', error);
// Errores tipo "EmptyResult (5004)" o "Se han agotado las solicitudes de por vida"
// a veces vienen como excepción en vez de resultado aceptado. Los traducimos para
// que el llamador los trate como "sin datos / no hay nada más que hacer" en lugar
// de error fatal.
const raw = error?.message || String(error);
const emptyMatch = raw.match(/EmptyResult\s*\(?\s*(5004)\s*\)?/i) || raw.includes('5004');
if (emptyMatch) {
return {
success: false,
message: 'No se encontró la información',
statusCode: '5004',
};
}
const exhaustedMatch = raw.includes('Se han agotado las solicitudes de por vida');
if (exhaustedMatch) {
return {
success: false,
message: 'Se han agotado las solicitudes de por vida para este rango',
statusCode: 'exhausted',
};
}
console.error('[SAT Query Error]', error?.message, error?.stack || error);
return {
success: false,
message: error.message || 'Error al realizar consulta',
@@ -174,6 +222,7 @@ export async function verifySatRequest(
if (entryId === 'Finished') status = 'ready';
else if (entryId === 'InProgress') status = 'processing';
else if (entryId === 'Accepted') status = 'pending';
else if (entryId === 'Unknown' && result.getStatus().getCode().toString() === '404') status = 'failed';
else status = 'pending';
}
@@ -183,6 +232,38 @@ export async function verifySatRequest(
const statusMsg = result.getStatus().getMessage();
const reqValue = statusRequest.getValue();
const reqEntry = statusRequest.getEntryId();
// EmptyResult (5004) o Exhausted (5002, "solicitudes de por vida"): el SAT
// aceptó la solicitud pero no generó paquetes (rango sin info) o ya agotamos
// las solicitudes de ese rango. Tratarlos como "ready" con 0 paquetes para
// NO fallar la etapa ni quemar reintentos — es un resultado benigno.
// Se comparan value/entry/mensaje de forma defensiva porque getValue() puede
// venir como number o string según la versión de la librería.
const codeValueStr = codeRequestValue != null ? String(codeRequestValue) : '';
const codeEntryStr = codeRequestEntry != null ? String(codeRequestEntry) : '';
const codeMsgStr = codeRequestMessage != null ? String(codeRequestMessage) : '';
const isEmptyResult =
codeValueStr === '5004' ||
codeEntryStr === '5004' ||
/EmptyResult/i.test(codeEntryStr) ||
/\b5004\b/.test(codeMsgStr);
const isExhausted =
codeValueStr === '5002' ||
/Exhausted/i.test(codeEntryStr) ||
/solicitudes de por vida/i.test(codeMsgStr);
if (isEmptyResult || isExhausted) {
return {
success: true,
status: 'ready',
packageIds: [],
totalCfdis: 0,
message: isExhausted
? 'Se han agotado las solicitudes de por vida para este rango'
: 'No se encontró información para el rango solicitado',
statusCode,
};
}
let message = statusMsg;
if (status === 'rejected' || status === 'failed') {
const codeReqStr = codeRequestValue
@@ -200,7 +281,7 @@ export async function verifySatRequest(
statusCode,
};
} catch (error: any) {
console.error('[SAT Verify Error]', error.message || error);
console.error('[SAT Verify Error]', error?.message, error?.stack || error);
// Errores de la librería (ej. webError.getResponse is not a function)
// no son fallos del SAT — devolver 'pending' para reintentar polling
return {
@@ -250,8 +331,34 @@ export async function downloadSatPackage(
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss).
* El SAT requiere hora 00:00:00; cualquier otra hora causa
* "Fecha final invalida" / "Fecha inicial invalida".
*
* IMPORTANTE: las fechas deben interpretarse en la zona horaria de México
* (America/Mexico_City) porque el SAT opera en esa zona. El servidor corre
* en UTC, así que usamos Intl.DateTimeFormat para obtener los componentes
* locales a México.
*/
function formatDateForSat(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`;
const fmt = new Intl.DateTimeFormat('es-MX', {
timeZone: 'America/Mexico_City',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const parts = fmt.formatToParts(date);
const get = (type: string) => parts.find(p => p.type === type)?.value || '00';
return `${get('year')}-${get('month')}-${get('day')} 00:00:00`;
}
/**
* Devuelve true si dos fechas (interpretadas en zona horaria de México)
* caen en el mismo día calendario.
*/
function isSameMexicoDay(a: Date, b: Date): boolean {
const fmt = new Intl.DateTimeFormat('es-MX', {
timeZone: 'America/Mexico_City',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
return fmt.format(a) === fmt.format(b);
}

View File

@@ -15,7 +15,7 @@ export interface SweepResult {
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
initial: 24,
daily: 4,
daily: 8,
incremental: 2,
custom: 24,
};
@@ -38,8 +38,8 @@ const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
* (volver a correrlo no reabre los ya-marcados-failed).
*
* - `apply=false` (default): dry-run, no toca BD.
* - `pendingHours`: threshold pending (default 12h).
* - `runningHours`: fallback threshold running si no se usa por-tipo (default 4h).
* - `pendingHours`: threshold pending (default 24h).
* - `runningHours`: fallback threshold running si no se usa por-tipo (default 8h).
* - `runningHoursByType`: override por tipo de sync.
*/
export async function sweepStaleSatJobs(params: {
@@ -48,7 +48,7 @@ export async function sweepStaleSatJobs(params: {
runningHours?: number;
runningHoursByType?: Record<string, number>;
} = { apply: false }): Promise<SweepResult> {
const pendingHours = params.pendingHours ?? 12;
const pendingHours = params.pendingHours ?? 24;
const runningHoursByType = { ...DEFAULT_RUNNING_HOURS_BY_TYPE, ...(params.runningHoursByType || {}) };
const now = new Date();
const pendingCutoff = new Date(now.getTime() - pendingHours * 3600 * 1000);