From 284c7620a9588c00c7f61b66b8b599762ca51e19 Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Sun, 2 Aug 2026 20:06:51 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20mejoras=20SAT,=20auth,=20web=20y=20migr?= =?UTF-8?q?aci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../controllers/contribuyente.controller.ts | 8 +- .../api/src/controllers/webhook.controller.ts | 17 +- apps/api/src/jobs/sat-sync.job.ts | 157 ++++++++++++++++-- ...057_declaraciones_unique_por_impuestos.sql | 15 ++ apps/api/src/routes/auth.routes.ts | 4 +- apps/api/src/services/auth.service.ts | 2 +- .../src/services/sat/sat-client.service.ts | 125 +++++++++++++- .../services/sat/sweep-stale-jobs.service.ts | 8 +- .../(dashboard)/configuracion/csd/page.tsx | 23 ++- .../despachos/mis-asignados/page.tsx | 55 +++++- .../web/components/contribuyente-selector.tsx | 7 +- package.json | 5 + ...decfdi__sat-ws-descarga-masiva@2.0.0.patch | 19 +++ pnpm-lock.yaml | 9 +- 14 files changed, 409 insertions(+), 45 deletions(-) create mode 100644 apps/api/src/migrations/tenant/057_declaraciones_unique_por_impuestos.sql create mode 100644 patches/@nodecfdi__sat-ws-descarga-masiva@2.0.0.patch diff --git a/apps/api/src/controllers/contribuyente.controller.ts b/apps/api/src/controllers/contribuyente.controller.ts index 75b9cb0..e8ae91a 100644 --- a/apps/api/src/controllers/contribuyente.controller.ts +++ b/apps/api/src/controllers/contribuyente.controller.ts @@ -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); } diff --git a/apps/api/src/controllers/webhook.controller.ts b/apps/api/src/controllers/webhook.controller.ts index 72328d7..a62abe3 100644 --- a/apps/api/src/controllers/webhook.controller.ts +++ b/apps/api/src/controllers/webhook.controller.ts @@ -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})`); diff --git a/apps/api/src/jobs/sat-sync.job.ts b/apps/api/src/jobs/sat-sync.job.ts index 080a7cf..1449ea7 100644 --- a/apps/api/src/jobs/sat-sync.job.ts +++ b/apps/api/src/jobs/sat-sync.job.ts @@ -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 { 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 { /** * 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 { if (isRunning) { console.log('[SAT Cron] Job ya en ejecución, omitiendo'); @@ -171,13 +233,27 @@ async function runSyncJob(): Promise { 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 { 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 { } } +async function runDailyRetryJob(): Promise { + 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 | null = null; let retryTask: ReturnType | null = null; let recoveryTask: ReturnType | null = null; +let retry9amTask: ReturnType | null = null; +let retry4pmTask: ReturnType | null = null; let opinionTask: ReturnType | null = null; let csfTask: ReturnType | null = null; let incrementalTask: ReturnType | 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; diff --git a/apps/api/src/migrations/tenant/057_declaraciones_unique_por_impuestos.sql b/apps/api/src/migrations/tenant/057_declaraciones_unique_por_impuestos.sql new file mode 100644 index 0000000..af0feb2 --- /dev/null +++ b/apps/api/src/migrations/tenant/057_declaraciones_unique_por_impuestos.sql @@ -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; diff --git a/apps/api/src/routes/auth.routes.ts b/apps/api/src/routes/auth.routes.ts index 6aac029..89426aa 100644 --- a/apps/api/src/routes/auth.routes.ts +++ b/apps/api/src/routes/auth.routes.ts @@ -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, diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts index dab22f2..6fa41c4 100644 --- a/apps/api/src/services/auth.service.ts +++ b/apps/api/src/services/auth.service.ts @@ -323,7 +323,7 @@ export async function logout(token: string): Promise { // 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). diff --git a/apps/api/src/services/sat/sat-client.service.ts b/apps/api/src/services/sat/sat-client.service.ts index c4ad2cd..a58a614 100644 --- a/apps/api/src/services/sat/sat-client.service.ts +++ b/apps/api/src/services/sat/sat-client.service.ts @@ -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 { 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); } diff --git a/apps/api/src/services/sat/sweep-stale-jobs.service.ts b/apps/api/src/services/sat/sweep-stale-jobs.service.ts index 33e692a..9ff2c56 100644 --- a/apps/api/src/services/sat/sweep-stale-jobs.service.ts +++ b/apps/api/src/services/sat/sweep-stale-jobs.service.ts @@ -15,7 +15,7 @@ export interface SweepResult { const DEFAULT_RUNNING_HOURS_BY_TYPE: Record = { initial: 24, - daily: 4, + daily: 8, incremental: 2, custom: 24, }; @@ -38,8 +38,8 @@ const DEFAULT_RUNNING_HOURS_BY_TYPE: Record = { * (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; } = { apply: false }): Promise { - 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); diff --git a/apps/web/app/(dashboard)/configuracion/csd/page.tsx b/apps/web/app/(dashboard)/configuracion/csd/page.tsx index 65b9aa3..327b22f 100644 --- a/apps/web/app/(dashboard)/configuracion/csd/page.tsx +++ b/apps/web/app/(dashboard)/configuracion/csd/page.tsx @@ -161,6 +161,7 @@ export default function CsdConfigPage() { const queryClient = useQueryClient(); const [uploading, setUploading] = useState(false); + const [creatingOrg, setCreatingOrg] = useState(false); const [cerFile, setCerFile] = useState(''); const [keyFile, setKeyFile] = useState(''); const [password, setPassword] = useState(''); @@ -178,16 +179,28 @@ export default function CsdConfigPage() { }; const handleCreateOrg = async () => { + if (creatingOrg) return; + setCreatingOrg(true); + setMessage(null); try { + const cfg = { timeout: 60000 }; if (selectedContribuyenteId) { - await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/org`); + await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/org`, undefined, cfg); } else { - await apiClient.post('/facturacion/org'); + await apiClient.post('/facturacion/org', undefined, cfg); } queryClient.invalidateQueries({ queryKey: ['facturapi-org-contrib'] }); setMessage({ type: 'success', text: 'Organización creada en Facturapi' }); } catch (err: any) { - setMessage({ type: 'error', text: err.response?.data?.message || 'Error al crear organización' }); + const isTimeout = err?.code === 'ECONNABORTED'; + setMessage({ + type: 'error', + text: isTimeout + ? 'La creación está tardando más de lo esperado. Refresca la página en unos segundos; si no aparece, intenta de nuevo.' + : (err.response?.data?.message || 'Error al crear organización'), + }); + } finally { + setCreatingOrg(false); } }; @@ -268,7 +281,9 @@ export default function CsdConfigPage() {

No hay organización configurada para este tenant.

- + ) : (
diff --git a/apps/web/app/(dashboard)/despachos/mis-asignados/page.tsx b/apps/web/app/(dashboard)/despachos/mis-asignados/page.tsx index 253f2e1..435e5fe 100644 --- a/apps/web/app/(dashboard)/despachos/mis-asignados/page.tsx +++ b/apps/web/app/(dashboard)/despachos/mis-asignados/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState } from 'react'; import Link from 'next/link'; import { useQuery } from '@tanstack/react-query'; import { Card, CardContent } from '@horux/shared-ui'; @@ -10,7 +11,7 @@ import { apiClient } from '@/lib/api/client'; import { useAuthStore } from '@/stores/auth-store'; import { useContribuyenteStore } from '@/stores/contribuyente-store'; import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store'; -import { Building2, Clock, AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; +import { Building2, Clock, AlertTriangle, CheckCircle2, Loader2, Search, FolderOpen, ChevronDown } from 'lucide-react'; interface Asignado { contribuyenteId: string; @@ -29,6 +30,9 @@ const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar', 'cont const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']); export default function MisAsignadosPage() { + const [filtroCliente, setFiltroCliente] = useState(''); + const [filtroCartera, setFiltroCartera] = useState(''); + const role = useAuthStore(s => s.user?.role); const platformRoles = useAuthStore(s => s.user?.platformRoles); const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false; @@ -64,6 +68,19 @@ export default function MisAsignadosPage() { const items = data ?? []; + const carterasUnicas = Array.from(new Set(items.map(it => it.carteraNombre || 'Sin cartera'))).sort((a, b) => + a.localeCompare(b, 'es', { sensitivity: 'base' }) + ); + + const itemsFiltrados = items.filter((it) => { + const coincideCliente = [it.nombre, it.rfc].some(v => + v.toLowerCase().includes(filtroCliente.trim().toLowerCase()) + ); + const coincideCartera = + filtroCartera === '' || (filtroCartera === '__sin_cartera__' ? !it.carteraNombre : it.carteraNombre === filtroCartera); + return coincideCliente && coincideCartera; + }); + return ( <>
@@ -84,6 +101,39 @@ export default function MisAsignadosPage() { ) : ( +
+
+ + setFiltroCliente(e.target.value)} + placeholder="Buscar por cliente o RFC..." + className="w-full rounded-md border border-input bg-background pl-9 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ + + +
+
+ + {itemsFiltrados.length === 0 ? ( +
+ No hay resultados para los filtros seleccionados. +
+ ) : ( @@ -102,7 +152,7 @@ export default function MisAsignadosPage() { - {items.map(it => { + {itemsFiltrados.map(it => { const total = it.obligacionesPendientes + it.obligacionesAtrasadas + it.obligacionesCompletadas + it.tareasPendientes + it.tareasAtrasadas + it.tareasCompletadas; @@ -193,6 +243,7 @@ export default function MisAsignadosPage() { })}
+ )}
)} diff --git a/apps/web/components/contribuyente-selector.tsx b/apps/web/components/contribuyente-selector.tsx index 9b157de..87d4bb9 100644 --- a/apps/web/components/contribuyente-selector.tsx +++ b/apps/web/components/contribuyente-selector.tsx @@ -48,6 +48,11 @@ export function ContribuyenteSelector() { const selected = contribuyentes.find((c) => c.id === selectedContribuyenteId); + // Orden alfabético por nombre (locale español, sin distinguir acentos/mayúsculas) + const contribuyentesOrdenados = [...contribuyentes].sort((a, b) => + a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base', numeric: true }) + ); + return (