Compare commits

...

20 Commits

Author SHA1 Message Date
Horux Dev
a5636427be feat(suscripcion): aclarar que SAT Inc no está incluido en Business Control 2026-08-08 02:04:06 +00:00
Horux Dev
ad6eec51ef config(sat): deshabilita SAT incremental para business_control
- business_control solo usa daily + retry programado (9 AM / 4 PM).
- business_cloud y mi_empresa_plus siguen con incremental.
- Migración para mantener consistencia entre ambientes.
- Actualiza comentario en sat-sync.job.ts.
2026-08-08 01:45:28 +00:00
Horux Dev
a008659eda feat(sat): reporte diario de errores SAT por proxy
- Agrega tabla sat_proxy_errors con proxy_usado, error_code, stage, message.
- sat-client.service.ts expone proxyInfo usado en cada conexión SAT.
- sat.service.ts registra errores de bloqueo/devolución del SAT en sat_proxy_errors
  y guarda proxyUsed en sat_sync_jobs.
- Nuevo job sat-proxy-report.job.ts: cron 8 AM CDMX, envía email a ADMIN_EMAIL
  con errores de las últimas 24h agrupados por proxy/error_code.
- Template de email sat-proxy-report.ts y método sendSatProxyReport.
- Registra el cron en src/index.ts.
- Actualiza docs/SAT-SYNC-IMPLEMENTATION.md.
2026-08-03 14:29:54 +00:00
Horux Dev
b43c9f334a Revert "feat(sat): reporte diario de errores SAT por proxy"
This reverts commit 099f34c903.
2026-08-03 14:27:53 +00:00
Horux Dev
099f34c903 feat(sat): reporte diario de errores SAT por proxy
- Agrega tabla sat_proxy_errors con proxy_usado, error_code, stage, message.
- sat-client.service.ts expone proxyInfo usado en cada conexión SAT.
- sat.service.ts registra errores de bloqueo/devolución del SAT en sat_proxy_errors
  y guarda proxyUsed en sat_sync_jobs.
- Nuevo job sat-proxy-report.job.ts: cron 8 AM CDMX, envía email a ADMIN_EMAIL
  con errores de las últimas 24h agrupados por proxy/error_code.
- Template de email sat-proxy-report.ts y método sendSatProxyReport.
- Registra el cron en src/index.ts.
- Actualiza docs/SAT-SYNC-IMPLEMENTATION.md.
2026-08-03 14:27:19 +00:00
Horux Dev
3de0014e80 feat(sat): Fase 6 — concurrencia por contribuyente con proxies rotativos
- Procesa hasta SAT_CONCURRENT_CONTRIBUYENTES=10 RFCs en paralelo
- Scheduler daily e incremental trabajan por unidad de sync (contribuyente/tenant legacy)
- Agrega SAT_CONCURRENT_CONTRIBUYENTES a .env.example
- Actualiza docs/SAT-SYNC-IMPLEMENTATION.md con sección de proxies
2026-08-03 14:07:44 +00:00
Horux Dev
b39bbcdd0a feat(sat): integra ProxyManager en createSatService
- Usa proxyManager.createNextAgent() para obtener un proxy del pool.
- Pasa el agente al HttpsWebClient parcheado.
- Log indica si se usa proxy o IP directa.
- Comportamiento actual sin cambios si SAT_PROXY_LIST está vacío.
2026-08-03 04:35:19 +00:00
Horux Dev
5489c84e6b feat(sat): parchea librería SAT para soportar agente proxy
- HttpsWebClient acepta un cuarto parámetro opcional 'agent'.
- El agente se pasa a https.request() para tunelar conexiones.
- Mantiene el parche anterior de fallback para getResponse.
2026-08-03 04:30:44 +00:00
Horux Dev
24d35333df feat(sat): agrega ProxyManager para rotación de proxies
- Nuevo servicio apps/api/src/services/sat/proxy.service.ts.
- Soporta estrategias round-robin y random.
- Parsea SAT_PROXY_LIST y crea agentes HttpsProxyAgent.
- Script scripts/test-proxy-rotation.ts para validar configuración.
2026-08-03 04:07:58 +00:00
Horux Dev
3f31e25ae7 chore(sat): prepara entorno para proxies
- Agrega dependencia https-proxy-agent.
- Documenta variables SAT_PROXY_LIST, SAT_PROXY_STRATEGY y SAT_PROXY_FALLBACK_DIRECT en .env.example.
- Actualiza pnpm-lock.yaml.
2026-08-03 03:58:29 +00:00
Horux Dev
284c7620a9 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.
2026-08-02 20:06:51 +00:00
Horux Dev
dfc0183c12 feat(sat): metadata histórica solo domingos, 404 no fatal en daily, polling 9x5min y docs
- processDailySync ejecuta metadata histórica únicamente los domingos en CDMX.
- Errores 404 en daily se registran en error_message sin abortar el job.
- Timeouts/transitorios reales siguen lanzándose para reintento.
- Polling reducido a 9 intentos máximos cada 5 minutos por solicitud.
- Actualiza docs/SAT-SYNC-IMPLEMENTATION.md con arquitectura, crons, manejo de errores y comandos.
2026-08-02 19:22:49 +00:00
Horux Dev
b5701b603c fix(planes): actualiza precios mostrados en tarjetas Business Control y Enterprise 2026-07-01 15:46:44 +00:00
Horux Dev
dbdb6f3e5c fix(cfdi): filtro de fechas usa fecha_emision en lugar de fecha_efectiva 2026-06-30 22:06:49 +00:00
Horux Dev
8a61fcadfa docs: actualiza sección 33 con fix de noIdentificacion en backend 2026-06-30 21:50:37 +00:00
Horux Dev
ad72436c25 fix(cfdi): controller de conceptos recibe filtro noIdentificacion 2026-06-30 21:20:36 +00:00
Horux Dev
1b202bc542 fix(cfdi): filtros de Conceptos aplican solo al dar Aplicar/Limpiar 2026-06-30 21:09:51 +00:00
Horux Dev
57c4908e68 feat: recordatorios periódicos; supervisor invita auxiliares; owner edita usuarios; precios planes y MSI 2026-06-25 20:56:39 +00:00
Horux Dev
0bded50e57 chore: actualiza precios Business Control y Enterprise; overage por plan; MSI en checkout 2026-06-25 05:36:25 +00:00
Horux Dev
1130a1833c docs: actualiza CAMBIOS-2026-05-04.md y commitea fixes pendientes
- Monitor de sincronización SAT (sat-sync-monitor.job + alerta por correo).
- Scraper de CSF más robusto (iframes, blobs, popups, validación PDF).
- Reactivación de contribuyentes desactivados y limpieza al desactivar.
- Timeout de constancia aumentado a 5 min.
- Variables de entorno SAT en .env.example y env.ts.
2026-06-25 02:09:58 +00:00
54 changed files with 3955 additions and 636 deletions

View File

@@ -49,6 +49,17 @@ SMTP_FROM=Horux360 <noreply@horuxfin.com>
# ----- Notificaciones admin -------------------------------------------------- # ----- Notificaciones admin --------------------------------------------------
ADMIN_EMAIL=carlos@horuxfin.com # destino de "nuevo cliente" + alertas internas ADMIN_EMAIL=carlos@horuxfin.com # destino de "nuevo cliente" + alertas internas
# ----- Monitoreo sincronización SAT ------------------------------------------
# Email separado para alertas de SAT (fallos, jobs atorados, FIEL sin sync inicial).
# Si no se configura, usa ADMIN_EMAIL.
SAT_ALERT_EMAIL=
# Cron del monitor (default: cada 2 horas). Ej: 0 8 * * * para digest diario 8 AM.
SAT_MONITOR_SCHEDULE=0 */2 * * *
# Horas para considerar un job running/pending como atorado (default 2h).
SAT_STUCK_RUNNING_HOURS=2
# Ventana hacia atrás para reportar jobs fallados (default 24h).
SAT_FAILED_LOOKBACK_HOURS=24
# ----- Facturapi (emisión CFDI) — opcional ----------------------------------- # ----- Facturapi (emisión CFDI) — opcional -----------------------------------
# Sin esto, los tenants no pueden emitir facturas, pero la app arranca. # Sin esto, los tenants no pueden emitir facturas, pero la app arranca.
FACTURAPI_USER_KEY= # sk_user_... (cuenta maestra Horux 360) FACTURAPI_USER_KEY= # sk_user_... (cuenta maestra Horux 360)
@@ -80,3 +91,16 @@ METABASE_PG_PASSWORD=
# ----- SAT Playwright headless toggle (debug temporal) ---------------------- # ----- SAT Playwright headless toggle (debug temporal) ----------------------
# SAT_HEADLESS=false # solo dev — muestra browser para debug de scrapers # SAT_HEADLESS=false # solo dev — muestra browser para debug de scrapers
# ----- Proxies SAT (opcional) ------------------------------------------------
# Lista de proxies HTTP/HTTPS para descargas masivas del SAT. Formato:
# http://user:pass@host:port,http://user:pass@host:port,...
# Si se deja vacío, el sistema usa la IP pública del servidor (comportamiento actual).
SAT_PROXY_LIST=
# Estrategia de rotación: round-robin | random (default: round-robin)
SAT_PROXY_STRATEGY=round-robin
# Si es true, cuando todos los proxies fallan se intenta con la IP directa del servidor.
SAT_PROXY_FALLBACK_DIRECT=true
# Máximo de contribuyentes sincronizados en paralelo por el scheduler (default: 10).
# Aprovecha el pool de proxies; cada RFC suele usar una IP distinta en round-robin.
SAT_CONCURRENT_CONTRIBUYENTES=10

View File

@@ -35,6 +35,7 @@
"facturapi": "^4.14.2", "facturapi": "^4.14.2",
"fast-xml-parser": "^5.3.3", "fast-xml-parser": "^5.3.3",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"https-proxy-agent": "^7.0.6",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mercadopago": "^2.12.0", "mercadopago": "^2.12.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",

View File

@@ -0,0 +1,2 @@
-- Agrega proxy_used a sat_sync_jobs para diagnosticar bloqueos por IP
ALTER TABLE "sat_sync_jobs" ADD COLUMN IF NOT EXISTS "proxy_used" VARCHAR(255);

View File

@@ -0,0 +1,13 @@
-- Tabla de errores SAT por proxy para reportes diarios
CREATE TABLE IF NOT EXISTS "sat_proxy_errors" (
"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
"job_id" TEXT NOT NULL REFERENCES "sat_sync_jobs"("id") ON DELETE CASCADE,
"proxy_used" VARCHAR(255),
"error_code" VARCHAR(50),
"stage" VARCHAR(255),
"message" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "sat_proxy_errors_created_at_idx" ON "sat_proxy_errors"("created_at");
CREATE INDEX IF NOT EXISTS "sat_proxy_errors_proxy_used_created_at_idx" ON "sat_proxy_errors"("proxy_used", "created_at");

View File

@@ -0,0 +1,4 @@
-- Deshabilita SAT incremental para business_control; solo daily + retry programado.
UPDATE "despacho_plan_prices"
SET "permite_sat_incremental" = false
WHERE "plan" = 'business_control';

View File

@@ -677,8 +677,12 @@ model SatSyncJob {
// usuario (botón UI). Cambia la política de retry: 2 intentos vs 3 del // usuario (botón UI). Cambia la política de retry: 2 intentos vs 3 del
// bootstrap puro. Daily/incremental ignoran este campo. // bootstrap puro. Daily/incremental ignoran este campo.
isCustomRange Boolean @default(false) @map("is_custom_range") isCustomRange Boolean @default(false) @map("is_custom_range")
// Proxy usado en el último request SAT que falló (host:port). Ayuda a diagnosticar
// bloqueos por IP y a generar reportes diarios de errores por proxy.
proxyUsed String? @map("proxy_used") @db.VarChar(255)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
proxyErrors SatProxyError[]
@@index([tenantId]) @@index([tenantId])
@@index([status]) @@index([status])
@@ -686,6 +690,24 @@ model SatSyncJob {
@@map("sat_sync_jobs") @@map("sat_sync_jobs")
} }
// Errores de bloqueo/devolución del SAT por proxy.
// Permite reportes diarios de cuántos 404/500X/etc. ocurrieron en cada IP.
model SatProxyError {
id String @id @default(uuid())
jobId String @map("job_id")
proxyUsed String? @map("proxy_used") @db.VarChar(255)
errorCode String? @map("error_code") @db.VarChar(50)
stage String? @map("stage") @db.VarChar(255)
message String? @map("message")
createdAt DateTime @default(now()) @map("created_at")
job SatSyncJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
@@index([createdAt])
@@index([proxyUsed, createdAt])
@@map("sat_proxy_errors")
}
enum SatSyncType { enum SatSyncType {
initial initial
daily daily

View File

@@ -0,0 +1,20 @@
import 'dotenv/config';
import { ProxyManager } from '../src/services/sat/proxy.service.js';
const manager = new ProxyManager(
process.env.SAT_PROXY_LIST || '',
(process.env.SAT_PROXY_STRATEGY as any) || 'round-robin',
);
console.log(`Total de proxies: ${manager.getProxyCount()}`);
console.log(`Estrategia: ${process.env.SAT_PROXY_STRATEGY || 'round-robin'}`);
console.log('Próximos 10 proxies seleccionados:');
for (let i = 0; i < 10; i++) {
const proxy = manager.getNextProxy();
if (!proxy) {
console.log(` ${i + 1}. (sin proxy configurado)`);
} else {
console.log(` ${i + 1}. ${proxy.host}:${proxy.port}`);
}
}

View File

@@ -53,6 +53,12 @@ const envSchema = z.object({
// Admin notification email // Admin notification email
ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'), ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'),
// SAT sync monitoring alerts (optional; falls back to ADMIN_EMAIL)
SAT_ALERT_EMAIL: z.string().email().optional(),
SAT_MONITOR_SCHEDULE: z.string().default('0 */2 * * *'),
SAT_STUCK_RUNNING_HOURS: z.string().transform(v => parseInt(v, 10)).default('2'),
SAT_FAILED_LOOKBACK_HOURS: z.string().transform(v => parseInt(v, 10)).default('24'),
// Facturapi // Facturapi
FACTURAPI_USER_KEY: z.string().optional(), FACTURAPI_USER_KEY: z.string().optional(),

View File

@@ -13,6 +13,8 @@ const createRecordatorioSchema = z.object({
fechaLimite: z.string().min(8), // ISO date o yyyy-mm-dd fechaLimite: z.string().min(8), // ISO date o yyyy-mm-dd
notas: z.string().max(2000).optional(), notas: z.string().max(2000).optional(),
privado: z.boolean().optional(), privado: z.boolean().optional(),
recurrencia: z.enum(['unica', 'mensual', 'bimestral', 'trimestral', 'anual']).default('unica'),
fechaFin: z.string().min(8).optional(),
}); });
const updateRecordatorioSchema = z.object({ const updateRecordatorioSchema = z.object({
@@ -107,7 +109,7 @@ export async function createRecordatorio(req: Request, res: Response, next: Next
const evento = await recordatoriosService.createRecordatorio( const evento = await recordatoriosService.createRecordatorio(
req.tenantPool!, req.tenantPool!,
req.user!.userId, req.user!.userId,
{ ...data, tipo: 'custom', recurrencia: 'unica' } { ...data, tipo: 'custom' }
); );
res.status(201).json(evento); res.status(201).json(evento);

View File

@@ -260,7 +260,11 @@ export async function removeAuxiliar(req: Request, res: Response, next: NextFunc
// Supervisores available (for dropdown) // Supervisores available (for dropdown)
export async function getSupervisores(req: Request, res: Response, next: NextFunction) { export async function getSupervisores(req: Request, res: Response, next: NextFunction) {
try { try {
const supervisores = await carteraService.getSupervisores(req.tenantPool!, req.user!.tenantId); const allSupervisores = await carteraService.getSupervisores(req.tenantPool!, req.user!.tenantId);
// Un supervisor solo se ve a si mismo en el dropdown (no puede asignar a otro supervisor)
const supervisores = isSupervisor(req)
? allSupervisores.filter(s => s.userId === req.user!.userId)
: allSupervisores;
return res.json({ data: supervisores }); return res.json({ data: supervisores });
} catch (err) { return next(err); } } catch (err) { return next(err); }
} }

View File

@@ -128,6 +128,7 @@ export async function listConceptos(req: Request, res: Response, next: NextFunct
uuidLike?: string; uuidLike?: string;
claveProdServ?: string; claveProdServ?: string;
descripcionConcepto?: string; descripcionConcepto?: string;
noIdentificacion?: string;
orderBy?: 'fecha' | 'importe'; orderBy?: 'fecha' | 'importe';
orderDir?: 'asc' | 'desc'; orderDir?: 'asc' | 'desc';
} = { } = {
@@ -146,6 +147,7 @@ export async function listConceptos(req: Request, res: Response, next: NextFunct
uuidLike: req.query.uuidLike as string, uuidLike: req.query.uuidLike as string,
claveProdServ: req.query.claveProdServ as string, claveProdServ: req.query.claveProdServ as string,
descripcionConcepto: req.query.descripcionConcepto as string, descripcionConcepto: req.query.descripcionConcepto as string,
noIdentificacion: req.query.noIdentificacion as string,
orderBy: req.query.orderBy as 'fecha' | 'importe', orderBy: req.query.orderBy as 'fecha' | 'importe',
orderDir: req.query.orderDir as 'asc' | 'desc', orderDir: req.query.orderDir as 'asc' | 'desc',
}; };

View File

@@ -38,10 +38,14 @@ const createSchema = z.object({
const updateSchema = createSchema.partial(); 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) { export async function list(req: Request, res: Response, next: NextFunction) {
try { try {
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role); 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 // Batch lookup de nombres de supervisores
const supervisorIds = [...new Set(rows.map(r => r.supervisorUserId).filter(Boolean))] as string[]; 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) { export async function getById(req: Request, res: Response, next: NextFunction) {
try { 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')); if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
return res.json(row); return res.json(row);
} catch (err) { return next(err); } } catch (err) { return next(err); }
@@ -93,7 +97,7 @@ export async function create(req: Request, res: Response, next: NextFunction) {
} }
} }
const row = await contribuyenteService.createContribuyente(req.tenantPool!, data); const { row, reactivated } = await contribuyenteService.createContribuyente(req.tenantPool!, data);
// Si se asignó un supervisor, agregar el contribuyente a todas las carteras // Si se asignó un supervisor, agregar el contribuyente a todas las carteras
// top-level de ese supervisor para que aparezca directamente en su vista. // top-level de ese supervisor para que aparezca directamente en su vista.
@@ -119,7 +123,7 @@ export async function create(req: Request, res: Response, next: NextFunction) {
console.error('[Contribuyente] Overage adjust failed (non-blocking):', err.message || err); console.error('[Contribuyente] Overage adjust failed (non-blocking):', err.message || err);
} }
return res.status(201).json({ ...row, overage }); return res.status(reactivated ? 200 : 201).json({ ...row, reactivated, overage });
} catch (err: any) { } catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
if (err.code === '23505') return next(new AppError(409, 'Ya existe un contribuyente con este RFC')); if (err.code === '23505') return next(new AppError(409, 'Ya existe un contribuyente con este RFC'));

View File

@@ -70,15 +70,25 @@ export async function inviteUsuario(req: Request, res: Response, next: NextFunct
} }
const data = inviteSchema.parse(req.body); const data = inviteSchema.parse(req.body);
// Los supervisores solo pueden invitar clientes // Los supervisores solo pueden invitar clientes y auxiliares
if (req.user!.role === 'supervisor' && data.role !== 'cliente') { if (req.user!.role === 'supervisor' && !['cliente', 'auxiliar'].includes(data.role)) {
throw new AppError(403, 'Los supervisores solo pueden invitar clientes'); throw new AppError(403, 'Los supervisores solo pueden invitar clientes y auxiliares');
} }
// Validate: auxiliar requires a supervisor // Validate: auxiliar requires a supervisor
if (data.role === 'auxiliar' && !data.supervisorUserId) { if (data.role === 'auxiliar' && !data.supervisorUserId) {
// Un supervisor que invita un auxiliar se asigna a sí mismo por defecto
if (req.user!.role === 'supervisor') {
data.supervisorUserId = req.user!.userId;
} else {
throw new AppError(400, 'Debes asignar un supervisor al auxiliar'); throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
} }
}
// Un supervisor solo puede asignar auxiliares a sí mismo
if (req.user!.role === 'supervisor' && data.role === 'auxiliar' && data.supervisorUserId !== req.user!.userId) {
throw new AppError(403, 'Solo puedes asignar auxiliares a tu propia supervisión');
}
const usuario = await usuariosService.inviteUsuario(req.user!.tenantId, data); const usuario = await usuariosService.inviteUsuario(req.user!.tenantId, data);

View File

@@ -253,12 +253,19 @@ async function handlePaymentNotification(paymentId: string) {
// precio de renewal. Se detecta comparando el monto cobrado contra lo que // precio de renewal. Se detecta comparando el monto cobrado contra lo que
// `getPlanPrice(phase='firstYear')` devolvería para este plan. // `getPlanPrice(phase='firstYear')` devolvería para este plan.
const esPrimerPago = subscription.status === 'pending'; const esPrimerPago = subscription.status === 'pending';
const updateData: { status: string; currentPeriodEnd?: Date } = { status: 'authorized' }; const updateData: { status: string; currentPeriodStart?: Date; currentPeriodEnd?: Date } = { status: 'authorized' };
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. // 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) {
const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency); const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency);
updateData.currentPeriodEnd = nextPeriodEnd; updateData.currentPeriodEnd = nextPeriodEnd;
console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`); console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`);

View File

@@ -6,6 +6,9 @@ import { startSatSyncJob } from './jobs/sat-sync.job.js';
import { startWeeklyUpdateJob } from './jobs/weekly-update.job.js'; import { startWeeklyUpdateJob } from './jobs/weekly-update.job.js';
import { startMetricasInvalidationsJob } from './jobs/metricas-invalidations.job.js'; import { startMetricasInvalidationsJob } from './jobs/metricas-invalidations.job.js';
import { startNotificationsJob } from './jobs/notifications.job.js'; import { startNotificationsJob } from './jobs/notifications.job.js';
import { startSatSyncMonitorJob } from './jobs/sat-sync-monitor.job.js';
import { startSatProxyReportJob } from './jobs/sat-proxy-report.job.js';
import { startRecordatoriosPeriodicosJob } from './jobs/recordatorios-periodicos.job.js';
const PORT = parseInt(env.PORT, 10); const PORT = parseInt(env.PORT, 10);
@@ -23,13 +26,16 @@ const server = app.listen(PORT, '0.0.0.0', () => {
if (cronsEnabled) { if (cronsEnabled) {
startSatSyncJob(); startSatSyncJob();
startMetricasInvalidationsJob(); startMetricasInvalidationsJob();
startSatSyncMonitorJob();
startSatProxyReportJob();
startRecordatoriosPeriodicosJob();
if (sendRealEmails) { if (sendRealEmails) {
startWeeklyUpdateJob(); startWeeklyUpdateJob();
startNotificationsJob(); startNotificationsJob();
} else { } else {
console.log('[Cron] weekly-update + notifications omitidos en dev (evita emails reales)'); console.log('[Cron] weekly-update + notifications omitidos en dev (evita emails reales)');
} }
console.log(`[Cron] SAT + metricas activos (NODE_ENV=${env.NODE_ENV}, ENABLE_CRONS_IN_DEV=${process.env.ENABLE_CRONS_IN_DEV ?? 'unset'})`); console.log(`[Cron] SAT + metricas + SAT monitor activos (NODE_ENV=${env.NODE_ENV}, ENABLE_CRONS_IN_DEV=${process.env.ENABLE_CRONS_IN_DEV ?? 'unset'})`);
} else { } else {
console.log('[Cron] Jobs omitidos en dev (usar ENABLE_CRONS_IN_DEV=1 para activar)'); console.log('[Cron] Jobs omitidos en dev (usar ENABLE_CRONS_IN_DEV=1 para activar)');
} }

View File

@@ -0,0 +1,75 @@
/**
* Extensión periódica de recordatorios recurrentes.
*
* Cada vez que corre, itera los tenants activos y para cada serie periódica
* activa genera nuevas instancias futuras hasta mantener un horizonte mínimo
* (default 24 meses).
*
* Programado diariamente a las 6:00 AM (America/Mexico_City) porque es una
* tarea liviana y nos asegura que siempre haya instancias disponibles.
*/
import cron from 'node-cron';
import { prisma, tenantDb } from '../config/database.js';
import { extenderSeriesActivas } from '../services/recordatorios.service.js';
const SCHEDULE = '0 6 * * *'; // 06:00 AM diario
let task: ReturnType<typeof cron.schedule> | null = null;
export async function runRecordatoriosPeriodicosJob(): Promise<{
tenants: number;
series: number;
instancias: number;
}> {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
let seriesTotal = 0;
let instanciasTotal = 0;
for (const tenant of tenants) {
if (!tenant.databaseName) continue;
try {
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const result = await extenderSeriesActivas(pool);
if (result.series > 0) {
console.log(`[Recordatorios Periodicos] ${tenant.rfc}: ${result.instancias} instancias en ${result.series} series`);
}
seriesTotal += result.series;
instanciasTotal += result.instancias;
} catch (err: any) {
console.error(`[Recordatorios Periodicos] Error en ${tenant.rfc}:`, err.message || err);
}
}
return { tenants: tenants.length, series: seriesTotal, instancias: instanciasTotal };
}
export function startRecordatoriosPeriodicosJob(): void {
if (task) {
console.warn('[Recordatorios Periodicos Cron] Ya iniciado');
return;
}
task = cron.schedule(SCHEDULE, async () => {
try {
const result = await runRecordatoriosPeriodicosJob();
if (result.series > 0) {
console.log(`[Recordatorios Periodicos Cron] ${result.tenants} tenants — ${result.instancias} instancias en ${result.series} series`);
}
} catch (err: any) {
console.error('[Recordatorios Periodicos Cron] Error general:', err.message || err);
}
}, {
timezone: 'America/Mexico_City',
});
console.log(`[Recordatorios Periodicos Cron] Programado: ${SCHEDULE} (06:00 AM diario America/Mexico_City)`);
}
export function stopRecordatoriosPeriodicosJob(): void {
if (task) {
task.stop();
task = null;
}
}

View File

@@ -0,0 +1,93 @@
import cron from 'node-cron';
import { prisma } from '../config/database.js';
import { env } from '../config/env.js';
import { emailService } from '../services/email/email.service.js';
import type { SatProxyReportData } from '../services/email/templates/sat-proxy-report.js';
const PROXY_REPORT_CRON_SCHEDULE = '0 8 * * *'; // 8:00 AM CDMX diario
let reportTask: ReturnType<typeof cron.schedule> | null = null;
function cdmxDateString(d: Date): string {
return d.toLocaleDateString('es-MX', { timeZone: 'America/Mexico_City' });
}
function hoursAgo(hours: number): Date {
return new Date(Date.now() - hours * 60 * 60 * 1000);
}
export async function runSatProxyReport(): Promise<void> {
console.log('[SAT Proxy Report] Generando reporte diario de errores por proxy');
try {
const cutoff = hoursAgo(24);
const rows = await prisma.$queryRaw<Array<{ proxy: string | null; errorCode: string | null; count: bigint }>>`
SELECT proxy_used AS proxy, error_code AS "errorCode", COUNT(*) AS count
FROM sat_proxy_errors
WHERE created_at >= ${cutoff}
GROUP BY proxy_used, error_code
ORDER BY count DESC, proxy_used ASC, error_code ASC
`;
const byProxy = rows.map(r => ({
proxy: r.proxy || 'IP directa del servidor',
errorCode: r.errorCode || '—',
count: Number(r.count),
}));
const totalErrors = byProxy.reduce((sum, r) => sum + r.count, 0);
const now = new Date();
const data: SatProxyReportData = {
generatedAt: now.toLocaleString('es-MX', { timeZone: 'America/Mexico_City' }),
recipient: env.ADMIN_EMAIL,
dateFrom: cdmxDateString(hoursAgo(24)),
dateTo: cdmxDateString(now),
totalErrors,
byProxy,
};
const recipient = env.ADMIN_EMAIL;
if (!recipient) {
console.warn('[SAT Proxy Report] ADMIN_EMAIL no configurado, no se envía reporte');
return;
}
console.log(`[SAT Proxy Report] Enviando reporte a ${recipient}: ${totalErrors} errores en ${byProxy.length} grupos`);
await emailService.sendSatProxyReport(recipient, data);
console.log('[SAT Proxy Report] Reporte enviado');
} catch (error: any) {
console.error('[SAT Proxy Report] Error generando reporte:', error.message || error);
}
}
export function startSatProxyReportJob(): void {
if (reportTask) {
console.log('[SAT Proxy Report] Job ya está programado');
return;
}
if (!cron.validate(PROXY_REPORT_CRON_SCHEDULE)) {
console.error('[SAT Proxy Report] Expresión cron inválida:', PROXY_REPORT_CRON_SCHEDULE);
return;
}
reportTask = cron.schedule(PROXY_REPORT_CRON_SCHEDULE, async () => {
try {
await runSatProxyReport();
} catch (error: any) {
console.error('[SAT Proxy Report Cron] Error:', error.message || error);
}
}, {
timezone: 'America/Mexico_City',
});
console.log(`[SAT Proxy Report] Programado: ${PROXY_REPORT_CRON_SCHEDULE} (America/Mexico_City)`);
}
export function stopSatProxyReportJob(): void {
if (reportTask) {
reportTask.stop();
reportTask = null;
}
}

View File

@@ -0,0 +1,303 @@
import cron from 'node-cron';
import { prisma, tenantDb } from '../config/database.js';
import { env } from '../config/env.js';
import { emailService } from '../services/email/email.service.js';
import { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js';
import type { SatSyncAlertData } from '../services/email/templates/sat-sync-alert.js';
let monitorTask: ReturnType<typeof cron.schedule> | null = null;
interface TenantInfo {
id: string;
rfc: string;
nombre: string;
databaseName: string | null;
}
interface ContribuyenteInfo {
id: string;
rfc: string;
nombre: string;
}
function hoursAgo(hours: number): Date {
return new Date(Date.now() - hours * 60 * 60 * 1000);
}
async function loadActiveTenants(): Promise<Map<string, TenantInfo>> {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, nombre: true, databaseName: true },
});
const map = new Map<string, TenantInfo>();
for (const t of tenants) {
map.set(t.id, t);
}
return map;
}
async function loadContribuyentesForTenant(tenant: TenantInfo): Promise<Map<string, ContribuyenteInfo>> {
const map = new Map<string, ContribuyenteInfo>();
if (!tenant.databaseName) return map;
try {
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const { rows } = await pool.query(`
SELECT c.entidad_id AS id, c.rfc, eg.nombre
FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
`);
for (const r of rows) {
map.set(r.id, { id: r.id, rfc: r.rfc, nombre: r.nombre });
}
} catch (err: any) {
console.error(`[SAT Monitor] Error cargando contribuyentes para tenant ${tenant.rfc}:`, err.message);
}
return map;
}
async function findFailedJobs(lookbackHours: number) {
const cutoff = hoursAgo(lookbackHours);
return prisma.satSyncJob.findMany({
where: {
status: 'failed',
completedAt: { gte: cutoff },
},
orderBy: { completedAt: 'desc' },
});
}
async function findPendingOldJobs(pendingHours: number) {
const cutoff = hoursAgo(pendingHours);
return prisma.satSyncJob.findMany({
where: {
status: 'pending',
createdAt: { lte: cutoff },
OR: [
{ nextRetryAt: null },
{ nextRetryAt: { lte: new Date() } },
],
},
orderBy: { createdAt: 'asc' },
});
}
async function findMissingInitialSync(tenants: Map<string, TenantInfo>): Promise<SatSyncAlertData['missingInitial']> {
const missing: SatSyncAlertData['missingInitial'] = [];
for (const tenant of tenants.values()) {
if (!tenant.databaseName) continue;
try {
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const { rows: contribuyentes } = await pool.query(`
SELECT c.entidad_id AS id, c.rfc, eg.nombre
FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
JOIN fiel_contribuyente f ON f.contribuyente_id = c.entidad_id
WHERE f.is_active = true
AND f.valid_until >= NOW()
ORDER BY eg.nombre
`);
if (contribuyentes.length === 0) continue;
const contribuyenteIds = contribuyentes.map((c: any) => c.id);
const initialJobs = await prisma.satSyncJob.findMany({
where: {
tenantId: tenant.id,
contribuyenteId: { in: contribuyenteIds },
type: 'initial',
status: { in: ['completed', 'running', 'pending'] },
},
select: { contribuyenteId: true, status: true },
});
const completedOrInProgressIds = new Set(initialJobs.map(j => j.contribuyenteId));
for (const c of contribuyentes) {
if (!completedOrInProgressIds.has(c.id)) {
missing.push({
tenantName: tenant.nombre,
tenantRfc: tenant.rfc,
contribuyenteName: c.nombre,
contribuyenteRfc: c.rfc,
});
}
}
} catch (err: any) {
console.error(`[SAT Monitor] Error revisando FIEL sin sync inicial para tenant ${tenant.rfc}:`, err.message);
}
}
return missing;
}
function buildAlertData(
tenants: Map<string, TenantInfo>,
contribuyenteMaps: Map<string, Map<string, ContribuyenteInfo>>,
failed: Awaited<ReturnType<typeof findFailedJobs>>,
stale: Array<{ id: string; tenantId: string; kind: 'pending-stale' | 'running-stale'; ageHours: number }>,
staleJobsById: Map<string, Awaited<ReturnType<typeof prisma.satSyncJob.findFirst>>>,
pendingOld: Awaited<ReturnType<typeof findPendingOldJobs>>,
missingInitial: SatSyncAlertData['missingInitial']
): SatSyncAlertData {
const now = new Date();
const generatedAt = now.toLocaleString('es-MX', { timeZone: 'America/Mexico_City' });
const resolveJob = (job: { tenantId: string; contribuyenteId: string | null }) => {
const tenant = tenants.get(job.tenantId);
const contribMap = contribuyenteMaps.get(job.tenantId);
const contrib = job.contribuyenteId ? contribMap?.get(job.contribuyenteId) : undefined;
return {
tenantName: tenant?.nombre || job.tenantId,
tenantRfc: tenant?.rfc || '—',
contribuyenteName: contrib?.nombre || null,
contribuyenteRfc: contrib?.rfc || null,
};
};
return {
generatedAt,
recipient: env.SAT_ALERT_EMAIL ?? env.ADMIN_EMAIL,
summary: {
failed: failed.length,
stale: stale.length,
stuckRunning: 0,
pendingOld: pendingOld.length,
missingInitial: missingInitial.length,
},
failed: failed.map(j => ({
...resolveJob(j),
type: j.type,
errorMessage: j.errorMessage,
completedAt: j.completedAt,
})),
stale: stale.map(e => {
const job = staleJobsById.get(e.id);
return {
id: e.id,
...resolveJob({ tenantId: e.tenantId, contribuyenteId: job?.contribuyenteId ?? null }),
type: job?.type || '—',
kind: e.kind,
ageHours: e.ageHours,
};
}),
stuckRunning: [],
pendingOld: pendingOld.map(j => ({
id: j.id,
...resolveJob(j),
type: j.type,
createdAt: j.createdAt,
nextRetryAt: j.nextRetryAt,
hoursPending: Math.round((now.getTime() - j.createdAt.getTime()) / 3_600_000),
})),
missingInitial,
};
}
export async function runSatSyncMonitor(): Promise<void> {
console.log('[SAT Monitor] Iniciando revisión de sincronizaciones SAT');
const pendingHours = env.SAT_STUCK_RUNNING_HOURS;
const failedLookbackHours = env.SAT_FAILED_LOOKBACK_HOURS;
try {
const tenants = await loadActiveTenants();
const [failed, staleResult, pendingOld, missingInitial] = await Promise.all([
findFailedJobs(failedLookbackHours),
sweepStaleSatJobs({ apply: false }),
findPendingOldJobs(pendingHours),
findMissingInitialSync(tenants),
]);
const jobTenantIds = new Set<string>();
for (const j of [...failed, ...pendingOld]) {
jobTenantIds.add(j.tenantId);
}
for (const e of staleResult.entries) {
jobTenantIds.add(e.tenantId);
}
const staleJobsById = new Map(
(
await prisma.satSyncJob.findMany({
where: { id: { in: staleResult.entries.map(e => e.id) } },
})
).map(j => [j.id, j])
);
for (const e of staleResult.entries) {
const job = staleJobsById.get(e.id);
if (job?.contribuyenteId && job.tenantId) {
jobTenantIds.add(job.tenantId);
}
}
const contribuyenteMaps = new Map<string, Map<string, ContribuyenteInfo>>();
for (const tenantId of jobTenantIds) {
const tenant = tenants.get(tenantId);
if (!tenant) continue;
const map = await loadContribuyentesForTenant(tenant);
contribuyenteMaps.set(tenantId, map);
}
const alertData = buildAlertData(
tenants,
contribuyenteMaps,
failed,
staleResult.entries,
staleJobsById,
pendingOld,
missingInitial
);
const hasIssues =
alertData.summary.failed > 0 ||
alertData.summary.stale > 0 ||
alertData.summary.pendingOld > 0 ||
alertData.summary.missingInitial > 0;
if (!hasIssues) {
console.log('[SAT Monitor] Sin anomalías detectadas');
return;
}
const recipient = env.SAT_ALERT_EMAIL ?? env.ADMIN_EMAIL;
console.log(`[SAT Monitor] Enviando alerta a ${recipient}:`, alertData.summary);
await emailService.sendSatSyncAlert(recipient, alertData);
console.log('[SAT Monitor] Alerta enviada');
} catch (error: any) {
console.error('[SAT Monitor] Error en revisión:', error.message || error);
}
}
export function startSatSyncMonitorJob(): void {
if (monitorTask) {
console.log('[SAT Monitor] Job ya está programado');
return;
}
const schedule = env.SAT_MONITOR_SCHEDULE;
if (!cron.validate(schedule)) {
console.error('[SAT Monitor] Expresión cron inválida:', schedule);
return;
}
monitorTask = cron.schedule(schedule, async () => {
try {
await runSatSyncMonitor();
} catch (error: any) {
console.error('[SAT Monitor Cron] Error:', error.message || error);
}
}, {
timezone: 'America/Mexico_City',
});
console.log(`[SAT Monitor] Programado: ${schedule}`);
}
export function stopSatSyncMonitorJob(): void {
if (monitorTask) {
monitorTask.stop();
monitorTask = null;
}
}

View File

@@ -1,6 +1,6 @@
import cron from 'node-cron'; import cron from 'node-cron';
import { prisma } from '../config/database.js'; 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 { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js';
import { hasFielConfigured } from '../services/fiel.service.js'; import { hasFielConfigured } from '../services/fiel.service.js';
import { consultarOpinion, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js'; import { consultarOpinion, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js';
@@ -11,18 +11,22 @@ import { consultarConstancia, purgeConstanciasAntiguas } from '../services/const
import { tenantDb } from '../config/database.js'; import { tenantDb } from '../config/database.js';
import type { Pool } from 'pg'; 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 RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10:00 AM todos los días
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas 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 (legacy, se mantiene por compatibilidad)
const CONCURRENT_CONTRIBUYENTES = Number(process.env.SAT_CONCURRENT_CONTRIBUYENTES || '10'); // Máximo de contribuyentes en paralelo
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM 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 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 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) const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos pre-vencimiento (7d/3d/1d/0d)
let isRunning = false; let isRunning = false;
let isIncrementalRunning = false; let isIncrementalRunning = false;
let isRecoveryRunning = false; let isRecoveryRunning = false;
let isDailyRetryRunning = false;
/** /**
* Verifica si un tenant tiene FIEL a nivel tenant (legacy Horux 360) * Verifica si un tenant tiene FIEL a nivel tenant (legacy Horux 360)
@@ -46,7 +50,7 @@ async function hasAnyFielConfigured(tenantId: string, databaseName?: string | nu
try { try {
const pool = await tenantDb.getPool(tenantId, databaseName); const pool = await tenantDb.getPool(tenantId, databaseName);
const { rows } = await pool.query( 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; return rows.length > 0;
} catch (err: any) { } catch (err: any) {
@@ -95,7 +99,135 @@ async function needsInitialSync(tenantId: string, contribuyenteId?: string): Pro
} }
/** /**
* Ejecuta sincronización para un tenant y sus contribuyentes * 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 };
}
/**
* Unidad mínima de sincronización: un tenant (legacy) o un contribuyente.
*/
interface SyncUnit {
tenantId: string;
contribuyenteId?: string;
syncType: 'initial' | 'daily' | 'incremental';
}
/**
* Recolecta todas las unidades de sync para un conjunto de tenants.
* - Modo incremental: solo incluye contribuyentes/tenants con initial completado.
* - Modo daily/initial: determina initial vs daily por contribuyente.
*/
async function getSyncUnits(
tenantIds: string[],
options: { incremental?: boolean; logPrefix?: string } = {}
): Promise<SyncUnit[]> {
const { incremental = false, logPrefix = '[SAT Cron]' } = options;
const units: SyncUnit[] = [];
for (const tenantId of tenantIds) {
try {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true },
});
let contribuyenteIds: string[] = [];
if (tenant?.databaseName) {
const { ids, total } = await getContribuyentesParaSync(tenantId, tenant.databaseName, logPrefix);
if (total > 0 && ids.length === 0) {
console.log(`${logPrefix} Tenant ${tenantId}: ningún contribuyente con FIEL vigente, se omite`);
continue;
}
contribuyenteIds = ids;
}
// Tenant legacy sin contribuyentes (Horux 360)
if (contribuyenteIds.length === 0) {
if (incremental) {
const hasInitial = await prisma.satSyncJob.findFirst({
where: { tenantId, contribuyenteId: null, type: 'initial', status: 'completed' },
});
if (!hasInitial) continue;
units.push({ tenantId, syncType: 'incremental' });
} else {
const needsInitial = await needsInitialSync(tenantId);
units.push({ tenantId, syncType: needsInitial ? 'initial' : 'daily' });
}
continue;
}
// Contribuyentes del tenant
for (const contribuyenteId of contribuyenteIds) {
if (incremental) {
const hasInitial = await prisma.satSyncJob.findFirst({
where: { tenantId, contribuyenteId, type: 'initial', status: 'completed' },
});
if (!hasInitial) continue;
units.push({ tenantId, contribuyenteId, syncType: 'incremental' });
} else {
const needsInitial = await needsInitialSync(tenantId, contribuyenteId);
units.push({ tenantId, contribuyenteId, syncType: needsInitial ? 'initial' : 'daily' });
}
}
} catch (error: any) {
console.error(`${logPrefix} Error recolectando unidades para tenant ${tenantId}:`, error.message);
}
}
return units;
}
/**
* Ejecuta sync para una unidad (tenant o contribuyente), respetando locks.
*/
async function syncUnit(unit: SyncUnit, logPrefix: string): Promise<void> {
try {
const status = await getSyncStatus(unit.tenantId, unit.contribuyenteId);
if (status.hasActiveSync) {
console.log(`${logPrefix} ${unit.tenantId}${unit.contribuyenteId ? ` contribuyente ${unit.contribuyenteId}` : ''} ya tiene sync activo, omitiendo`);
return;
}
console.log(`${logPrefix} Iniciando sync ${unit.syncType} para ${unit.tenantId}${unit.contribuyenteId ? ` contribuyente ${unit.contribuyenteId}` : ''}`);
const jobId = await startSync(unit.tenantId, unit.syncType, undefined, undefined, unit.contribuyenteId);
console.log(`${logPrefix} Job ${jobId} iniciado`);
} catch (error: any) {
console.error(`${logPrefix} Error sincronizando ${unit.tenantId}${unit.contribuyenteId ? ` contribuyente ${unit.contribuyenteId}` : ''}:`, error.message);
}
}
/**
* Ejecuta sincronización para un tenant y sus contribuyentes (modo secuencial legacy)
*/ */
async function syncTenant(tenantId: string): Promise<void> { async function syncTenant(tenantId: string): Promise<void> {
try { try {
@@ -107,9 +239,12 @@ async function syncTenant(tenantId: string): Promise<void> {
let contribuyenteIds: string[] = []; let contribuyenteIds: string[] = [];
if (tenant?.databaseName) { if (tenant?.databaseName) {
const pool = await tenantDb.getPool(tenantId, tenant.databaseName); const { ids, total } = await getContribuyentesParaSync(tenantId, tenant.databaseName, '[SAT Cron]');
const { rows } = await pool.query('SELECT entidad_id FROM contribuyentes'); if (total > 0 && ids.length === 0) {
contribuyenteIds = rows.map((r: any) => r.entidad_id); 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) // Si no hay contribuyentes, sincronizar a nivel tenant (legacy Horux 360)
@@ -153,6 +288,27 @@ async function syncTenant(tenantId: string): Promise<void> {
/** /**
* Ejecuta el job de sincronización para todos los tenants * 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> { async function runSyncJob(): Promise<void> {
if (isRunning) { if (isRunning) {
console.log('[SAT Cron] Job ya en ejecución, omitiendo'); console.log('[SAT Cron] Job ya en ejecución, omitiendo');
@@ -171,13 +327,36 @@ async function runSyncJob(): Promise<void> {
return; return;
} }
// Procesar en lotes para no saturar const hour = cdmxHour();
for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) { const groupIndex = hour - DAILY_WINDOW_START; // 6→0 … 10→4
const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS); if (groupIndex < 0 || groupIndex >= DAILY_GROUPS) {
await Promise.all(batch.map(syncTenant)); 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;
}
// Recolectar unidades de sync (contribuyentes o tenants legacy)
const units = await getSyncUnits(groupTenants, { logPrefix: '[SAT Cron]' });
console.log(`[SAT Cron] Ventana ${hour}:00 CDMX — ${units.length} unidades de sync listas (max ${CONCURRENT_CONTRIBUYENTES} paralelas)`);
if (units.length === 0) {
console.log('[SAT Cron] No hay unidades de sync en este grupo');
return;
}
// Procesar en lotes de contribuyentes para aprovechar los proxies
for (let i = 0; i < units.length; i += CONCURRENT_CONTRIBUYENTES) {
const batch = units.slice(i, i + CONCURRENT_CONTRIBUYENTES);
await Promise.all(batch.map(unit => syncUnit(unit, '[SAT Cron]')));
// Pequeña pausa entre lotes // Pequeña pausa entre lotes
if (i + CONCURRENT_SYNCS < tenantIds.length) { if (i + CONCURRENT_CONTRIBUYENTES < units.length) {
await new Promise(resolve => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 5000));
} }
} }
@@ -194,7 +373,8 @@ async function runSyncJob(): Promise<void> {
* Obtiene los tenants activos cuyo plan habilita SAT incremental (3 syncs/día * Obtiene los tenants activos cuyo plan habilita SAT incremental (3 syncs/día
* adicionales al daily). El flag vive en `despacho_plan_prices.permite_sat_incremental`, * adicionales al daily). El flag vive en `despacho_plan_prices.permite_sat_incremental`,
* editable por admin global desde `/configuracion/precios-suscripcion`. * editable por admin global desde `/configuracion/precios-suscripcion`.
* Default backfill: mi_empresa_plus, business_control, business_cloud. * Planes con incremental: mi_empresa_plus, business_cloud.
* business_control usa solo daily + retry programado.
*/ */
async function getTenantsConSatIncremental(): Promise<string[]> { async function getTenantsConSatIncremental(): Promise<string[]> {
const planesIncrementales = await prisma.despachoPlanPrice.findMany({ const planesIncrementales = await prisma.despachoPlanPrice.findMany({
@@ -232,9 +412,12 @@ async function incrementalSyncTenant(tenantId: string): Promise<void> {
let contribuyenteIds: string[] = []; let contribuyenteIds: string[] = [];
if (tenant?.databaseName) { if (tenant?.databaseName) {
const pool = await tenantDb.getPool(tenantId, tenant.databaseName); const { ids, total } = await getContribuyentesParaSync(tenantId, tenant.databaseName, '[SAT Cron Inc]');
const { rows } = await pool.query('SELECT entidad_id FROM contribuyentes'); if (total > 0 && ids.length === 0) {
contribuyenteIds = rows.map((r: any) => r.entidad_id); 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) // Si no hay contribuyentes, sincronizar a nivel tenant (legacy)
@@ -306,11 +489,19 @@ async function runIncrementalSyncJob(): Promise<void> {
if (tenantIds.length === 0) return; if (tenantIds.length === 0) return;
for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) { const units = await getSyncUnits(tenantIds, { incremental: true, logPrefix: '[SAT Cron Inc]' });
const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS); console.log(`[SAT Cron Inc] ${units.length} unidades de sync listas (max ${CONCURRENT_CONTRIBUYENTES} paralelas)`);
await Promise.all(batch.map(incrementalSyncTenant));
if (i + CONCURRENT_SYNCS < tenantIds.length) { if (units.length === 0) {
console.log('[SAT Cron Inc] No hay unidades de sync');
return;
}
for (let i = 0; i < units.length; i += CONCURRENT_CONTRIBUYENTES) {
const batch = units.slice(i, i + CONCURRENT_CONTRIBUYENTES);
await Promise.all(batch.map(unit => syncUnit(unit, '[SAT Cron Inc]')));
if (i + CONCURRENT_CONTRIBUYENTES < units.length) {
await new Promise(resolve => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 5000));
} }
} }
@@ -529,9 +720,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 scheduledTask: ReturnType<typeof cron.schedule> | null = null;
let retryTask: ReturnType<typeof cron.schedule> | null = null; let retryTask: ReturnType<typeof cron.schedule> | null = null;
let recoveryTask: 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 opinionTask: ReturnType<typeof cron.schedule> | null = null;
let csfTask: ReturnType<typeof cron.schedule> | null = null; let csfTask: ReturnType<typeof cron.schedule> | null = null;
let incrementalTask: ReturnType<typeof cron.schedule> | null = null; let incrementalTask: ReturnType<typeof cron.schedule> | null = null;
@@ -585,6 +797,28 @@ export function startSatSyncJob(): void {
timezone: 'America/Mexico_City', 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 // Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás). // (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS) // Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS)
@@ -691,6 +925,7 @@ export function startSatSyncJob(): void {
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`); console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[SAT Cron] Retry programado cada hora`); console.log(`[SAT Cron] Retry programado cada hora`);
console.log(`[SAT Recovery Cron] Programado para: ${RECOVERY_CRON_SCHEDULE} (America/Mexico_City)`); 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(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[CSF Cron] Programado para: ${CSF_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)`); console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`);
@@ -714,6 +949,14 @@ export function stopSatSyncJob(): void {
recoveryTask.stop(); recoveryTask.stop();
recoveryTask = null; recoveryTask = null;
} }
if (retry9amTask) {
retry9amTask.stop();
retry9amTask = null;
}
if (retry4pmTask) {
retry4pmTask.stop();
retry4pmTask = null;
}
if (opinionTask) { if (opinionTask) {
opinionTask.stop(); opinionTask.stop();
opinionTask = null; opinionTask = null;

View File

@@ -0,0 +1,19 @@
-- Recordatorios periódicos: maestro + instancias materializadas en la misma tabla
ALTER TABLE recordatorios
ADD COLUMN IF NOT EXISTS recurrencia VARCHAR(20) DEFAULT 'unica' NOT NULL,
ADD COLUMN IF NOT EXISTS serie_id INTEGER REFERENCES recordatorios(id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS activo BOOLEAN DEFAULT true NOT NULL,
ADD COLUMN IF NOT EXISTS fecha_inicio DATE,
ADD COLUMN IF NOT EXISTS fecha_fin DATE;
-- Backfill seguro para filas existentes (el default ya cubre recurrencia/activo, pero dejamos explícito)
UPDATE recordatorios
SET recurrencia = 'unica',
activo = true
WHERE recurrencia IS NULL
OR activo IS NULL;
-- Índices para consultas de serie y regeneración
CREATE INDEX IF NOT EXISTS recordatorios_serie_id_idx ON recordatorios(serie_id);
CREATE INDEX IF NOT EXISTS recordatorios_recurrencia_activo_idx ON recordatorios(recurrencia, activo);

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(); 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({ const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 10, max: 25,
message: { message: 'Demasiados intentos de login. Intenta de nuevo en 15 minutos.' }, message: { message: 'Demasiados intentos de login. Intenta de nuevo en 15 minutos.' },
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,

View File

@@ -10,7 +10,7 @@ router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);
// Static routes first // Static routes first
router.get('/supervisores', authorize('owner'), ctrl.getSupervisores); router.get('/supervisores', authorize('owner', 'supervisor'), ctrl.getSupervisores);
// Asignaciones de obligaciones/tareas a auxiliares (antes de /:id para evitar match dinámico) // Asignaciones de obligaciones/tareas a auxiliares (antes de /:id para evitar match dinámico)
router.get('/asignaciones', authorize('owner', 'supervisor'), asignacionesCtrl.listPorSupervisor); router.get('/asignaciones', authorize('owner', 'supervisor'), asignacionesCtrl.listPorSupervisor);

View File

@@ -323,7 +323,7 @@ export async function logout(token: string): Promise<void> {
// Password reset // 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). * Solicita recuperación de contraseña. No revela si el email existe (anti-enumeration).

View File

@@ -102,12 +102,12 @@ export async function getCfdis(pool: Pool, filters: CfdiFilters): Promise<CfdiLi
} }
if (filters.fechaInicio) { if (filters.fechaInicio) {
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`; whereClause += ` AND fecha_emision::date >= $${paramIndex++}::date`;
params.push(filters.fechaInicio); params.push(filters.fechaInicio);
} }
if (filters.fechaFin) { if (filters.fechaFin) {
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`; whereClause += ` AND fecha_emision::date <= $${paramIndex++}::date`;
params.push(filters.fechaFin); params.push(filters.fechaFin);
} }
@@ -214,11 +214,11 @@ export async function getConceptosList(
params.push(filters.estado); params.push(filters.estado);
} }
if (filters.fechaInicio) { if (filters.fechaInicio) {
whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') >= $${paramIndex++}::date`; whereClause += ` AND c.fecha_emision::date >= $${paramIndex++}::date`;
params.push(filters.fechaInicio); params.push(filters.fechaInicio);
} }
if (filters.fechaFin) { if (filters.fechaFin) {
whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`; whereClause += ` AND c.fecha_emision::date <= $${paramIndex++}::date`;
params.push(filters.fechaFin); params.push(filters.fechaFin);
} }
if (filters.rfc) { if (filters.rfc) {
@@ -385,11 +385,11 @@ export async function getCfdiXmlsForZip(
params.push(filters.estado); params.push(filters.estado);
} }
if (filters.fechaInicio) { if (filters.fechaInicio) {
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`; whereClause += ` AND fecha_emision::date >= $${paramIndex++}::date`;
params.push(filters.fechaInicio); params.push(filters.fechaInicio);
} }
if (filters.fechaFin) { if (filters.fechaFin) {
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`; whereClause += ` AND fecha_emision::date <= $${paramIndex++}::date`;
params.push(filters.fechaFin); params.push(filters.fechaFin);
} }
if (filters.rfc) { if (filters.rfc) {

View File

@@ -11,7 +11,7 @@ import { loginSatCsf } from './sat/sat-csf-login.js';
import { extractCsfPdf } from './sat/sat-csf-scraper.js'; import { extractCsfPdf } from './sat/sat-csf-scraper.js';
import { parseCsfPdf, type ConstanciaSituacionFiscal, type Domicilio, type RegimenCsf } from './sat/sat-csf-parser.js'; import { parseCsfPdf, type ConstanciaSituacionFiscal, type Domicilio, type RegimenCsf } from './sat/sat-csf-parser.js';
const PROCESS_TIMEOUT = 180_000; const PROCESS_TIMEOUT = 300_000;
export interface ConstanciaRow { export interface ConstanciaRow {
id: number; id: number;

View File

@@ -135,10 +135,61 @@ export async function getContribuyenteById(pool: Pool, id: string, tenantId?: st
return mergeContribuyenteWithTenant(row, tenantData); return mergeContribuyenteWithTenant(row, tenantData);
} }
export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise<ContribuyenteRow> { export async function createContribuyente(
pool: Pool,
data: CreateContribuyenteData,
): Promise<{ row: ContribuyenteRow; reactivated: boolean }> {
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
// 1. Si el RFC ya existe, reactivar la entidad desactivada en lugar de
// violar el UNIQUE de contribuyentes.rfc. Si está activa, lanzar
// error 23505 para que el controller devuelva 409.
const { rows: existing } = await client.query<{ entidad_id: string; active: boolean }>(`
SELECT c.entidad_id, e.active
FROM contribuyentes c
JOIN entidades_gestionadas e ON e.id = c.entidad_id
WHERE UPPER(c.rfc) = UPPER($1)
`, [data.rfc]);
if (existing.length > 0) {
const { entidad_id, active } = existing[0];
if (active) {
await client.query('ROLLBACK');
const err: any = new Error('Ya existe un contribuyente activo con este RFC');
err.code = '23505';
throw err;
}
await client.query(`
UPDATE entidades_gestionadas
SET active = true,
nombre = $1,
identificador = $2,
supervisor_user_id = $3,
updated_at = now()
WHERE id = $4
`, [data.razonSocial, data.rfc.toUpperCase(), data.supervisorUserId ?? null, entidad_id]);
await client.query(`
UPDATE contribuyentes
SET regimen_fiscal = $1,
codigo_postal = $2,
domicilio = $3
WHERE entidad_id = $4
`, [data.regimenFiscal ?? null, data.codigoPostal ?? null, data.domicilio ? JSON.stringify(data.domicilio) : null, entidad_id]);
await client.query('COMMIT');
await backfillCfdiContribuyente(pool, entidad_id, data.rfc.toUpperCase()).catch(
(err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err)
);
return { row: (await getContribuyenteById(pool, entidad_id))!, reactivated: true };
}
// 2. Caso normal: crear nuevo contribuyente
const { rows: [entidad] } = await client.query(` const { rows: [entidad] } = await client.query(`
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id) INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
VALUES ('CONTRIBUYENTE', $1, $2, $3) VALUES ('CONTRIBUYENTE', $1, $2, $3)
@@ -157,7 +208,7 @@ export async function createContribuyente(pool: Pool, data: CreateContribuyenteD
(err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err) (err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err)
); );
return (await getContribuyenteById(pool, entidad.id))!; return { row: (await getContribuyenteById(pool, entidad.id))!, reactivated: false };
} catch (err) { } catch (err) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
throw err; throw err;
@@ -220,11 +271,39 @@ export async function updateContribuyente(pool: Pool, id: string, data: Partial<
} }
export async function deactivateContribuyente(pool: Pool, id: string): Promise<boolean> { export async function deactivateContribuyente(pool: Pool, id: string): Promise<boolean> {
const { rowCount } = await pool.query( const client = await pool.connect();
try {
await client.query('BEGIN');
const { rowCount } = await client.query(
'UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1', 'UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1',
[id] [id]
); );
return (rowCount ?? 0) > 0; const ok = (rowCount ?? 0) > 0;
if (ok) {
// Limpiar asignaciones para que no aparezca en carteras ni accesos de cliente
await client.query('DELETE FROM cartera_entidades WHERE entidad_id = $1', [id]).catch((err) => {
console.error('[Contribuyente] Error limpiando cartera_entidades:', err);
});
await client.query('DELETE FROM cliente_accesos WHERE entidad_id = $1', [id]).catch((err) => {
console.error('[Contribuyente] Error limpiando cliente_accesos:', err);
});
// Desactivar FIEL para que no siga sincronizándose mientras está inactivo
await client.query('UPDATE fiel_contribuyente SET is_active = false WHERE contribuyente_id = $1', [id]).catch((err) => {
console.error('[Contribuyente] Error desactivando FIEL:', err);
});
}
await client.query('COMMIT');
return ok;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
} }
/** /**

View File

@@ -44,6 +44,17 @@ export const emailService = {
await sendEmail(env.ADMIN_EMAIL, `Pago fallido: ${data.nombre}`, paymentFailedEmail(data)); await sendEmail(env.ADMIN_EMAIL, `Pago fallido: ${data.nombre}`, paymentFailedEmail(data));
}, },
sendSatSyncAlert: async (to: string, data: import('./templates/sat-sync-alert.js').SatSyncAlertData) => {
const { satSyncAlertEmail } = await import('./templates/sat-sync-alert.js');
const total = data.summary.failed + data.summary.stale + data.summary.stuckRunning + data.summary.pendingOld + data.summary.missingInitial;
await sendEmail(to, `🚨 Alerta SAT: ${total} anomalía${total === 1 ? '' : 's'} detectada${total === 1 ? '' : 's'}`, satSyncAlertEmail(data));
},
sendSatProxyReport: async (to: string, data: import('./templates/sat-proxy-report.js').SatProxyReportData) => {
const { satProxyReportEmail } = await import('./templates/sat-proxy-report.js');
await sendEmail(to, `📊 Reporte diario SAT: ${data.totalErrors} errores por proxy`, satProxyReportEmail(data));
},
sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => { sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => {
const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring.js'); const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring.js');
await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data)); await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data));

View File

@@ -0,0 +1,63 @@
import { baseTemplate, heading, infoBox, BRAND_COLORS as C } from './base.js';
export interface ProxyErrorRow {
proxy: string;
errorCode: string;
count: number;
}
export interface SatProxyReportData {
generatedAt: string;
recipient: string;
dateFrom: string;
dateTo: string;
totalErrors: number;
byProxy: ProxyErrorRow[];
}
function tableHeader(cells: string[]): string {
return `<tr>
${cells.map(c => `<th align="left" style="padding:8px 12px;background-color:${C.bgLight};color:${C.textMuted};font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;border-bottom:1px solid ${C.border};">${c}</th>`).join('')}
</tr>`;
}
function tableRow(cells: string[]): string {
return `<tr>
${cells.map(c => `<td style="padding:10px 12px;border-bottom:1px solid ${C.border};color:${C.textPrimary};font-size:13px;vertical-align:top;">${c}</td>`).join('')}
</tr>`;
}
export function satProxyReportEmail(data: SatProxyReportData): string {
const rowsHtml = data.byProxy.length > 0
? data.byProxy.map(r => tableRow([
r.proxy ? `<code style="font-size:12px;">${r.proxy}</code>` : '<span style="color:#dc2626;">IP directa del servidor</span>',
r.errorCode || '—',
`<strong>${r.count}</strong>`,
])).join('')
: tableRow(['—', '—', '<span style="color:#16a34a;">Sin errores de bloqueo</span>']);
return baseTemplate(`
${heading('📊 Reporte diario de errores SAT por proxy')}
<p style="color:${C.textPrimary};margin:0 0 16px;">
Resumen de errores de bloqueo/devolución del SAT en las últimas 24 horas,
agrupados por proxy/IP usada.
</p>
${infoBox(`
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td style="padding:6px 0;color:${C.textMuted};">Período</td><td style="padding:6px 0;color:${C.textPrimary};font-weight:600;text-align:right;">${data.dateFrom}${data.dateTo}</td></tr>
<tr><td style="padding:6px 0;color:${C.textMuted};">Errores totales</td><td style="padding:6px 0;color:${data.totalErrors > 0 ? '#dc2626' : '#16a34a'};font-weight:600;text-align:right;">${data.totalErrors}</td></tr>
</table>
`)}
<h3 style="font-family:'Inter', sans-serif;font-weight:600;color:${C.textPrimary};margin:28px 0 12px;font-size:16px;">Errores por proxy (${data.byProxy.length})</h3>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<thead>${tableHeader(['Proxy / IP', 'Código', 'Cantidad'])}</thead>
<tbody>${rowsHtml}</tbody>
</table>
<p style="color:${C.textMuted};margin:24px 0 0;font-size:12px;">
Reporte generado el ${data.generatedAt} para ${data.recipient}.<br/>
Configura la lista de proxies con SAT_PROXY_LIST y la concurrencia con SAT_CONCURRENT_CONTRIBUYENTES.
</p>
`);
}

View File

@@ -0,0 +1,194 @@
import { baseTemplate, heading, infoBox, BRAND_COLORS as C } from './base.js';
export interface SatSyncAlertData {
generatedAt: string;
recipient: string;
summary: {
failed: number;
stale: number;
stuckRunning: number;
pendingOld: number;
missingInitial: number;
};
failed: Array<{
tenantName: string;
tenantRfc: string;
contribuyenteName?: string | null;
contribuyenteRfc?: string | null;
type: string;
errorMessage?: string | null;
completedAt?: Date | string | null;
}>;
stale: Array<{
id: string;
tenantName: string;
tenantRfc: string;
contribuyenteName?: string | null;
contribuyenteRfc?: string | null;
type: string;
kind: 'pending-stale' | 'running-stale';
ageHours: number;
}>;
stuckRunning: Array<{
id: string;
tenantName: string;
tenantRfc: string;
contribuyenteName?: string | null;
contribuyenteRfc?: string | null;
type: string;
progressPercent: number;
startedAt?: Date | string | null;
hoursRunning: number;
}>;
pendingOld: Array<{
id: string;
tenantName: string;
tenantRfc: string;
contribuyenteName?: string | null;
contribuyenteRfc?: string | null;
type: string;
createdAt?: Date | string | null;
nextRetryAt?: Date | string | null;
hoursPending: number;
}>;
missingInitial: Array<{
tenantName: string;
tenantRfc: string;
contribuyenteName: string;
contribuyenteRfc: string;
}>;
}
function fmtDate(value?: Date | string | null): string {
if (!value) return 'N/A';
const d = typeof value === 'string' ? new Date(value) : value;
return d.toLocaleString('es-MX', { timeZone: 'America/Mexico_City' });
}
function tableHeader(cells: string[]): string {
return `<tr>
${cells.map(c => `<th align="left" style="padding:8px 12px;background-color:${C.bgLight};color:${C.textMuted};font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;border-bottom:1px solid ${C.border};">${c}</th>`).join('')}
</tr>`;
}
function tableRow(cells: string[]): string {
return `<tr>
${cells.map(c => `<td style="padding:10px 12px;border-bottom:1px solid ${C.border};color:${C.textPrimary};font-size:13px;vertical-align:top;">${c}</td>`).join('')}
</tr>`;
}
function section(title: string, color: string, rowsHtml: string, headers: string[]): string {
return `
<h3 style="font-family:'Inter', sans-serif;font-weight:600;color:${color};margin:28px 0 12px;font-size:16px;">${title}</h3>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<thead>${tableHeader(headers)}</thead>
<tbody>${rowsHtml}</tbody>
</table>
`;
}
export function satSyncAlertEmail(data: SatSyncAlertData): string {
const { summary } = data;
const summaryRows = [
{ label: 'Jobs fallidos recientes', value: summary.failed, color: summary.failed > 0 ? '#dc2626' : C.textPrimary },
{ label: 'Jobs stale detectados', value: summary.stale, color: summary.stale > 0 ? '#dc2626' : C.textPrimary },
{ label: 'Running atorados sin progreso', value: summary.stuckRunning, color: summary.stuckRunning > 0 ? '#f59e0b' : C.textPrimary },
{ label: 'Pending sin atender', value: summary.pendingOld, color: summary.pendingOld > 0 ? '#f59e0b' : C.textPrimary },
{ label: 'Contribuyentes con FIEL sin sync inicial', value: summary.missingInitial, color: summary.missingInitial > 0 ? '#dc2626' : C.textPrimary },
]
.map(r => `<tr><td style="padding:6px 0;color:${C.textMuted};">${r.label}</td><td style="padding:6px 0;color:${r.color};font-weight:600;text-align:right;">${r.value}</td></tr>`)
.join('');
const failedHtml = data.failed.length > 0
? section(
`Jobs fallidos (${data.failed.length})`,
'#dc2626',
data.failed.map(j => tableRow([
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
j.type,
`<span style="color:#dc2626;">${j.errorMessage || 'Sin mensaje'}</span>`,
fmtDate(j.completedAt),
])).join(''),
['Tenant', 'Contribuyente', 'Tipo', 'Error', 'Fecha fallo']
)
: '';
const staleHtml = data.stale.length > 0
? section(
`Jobs stale detectados por el watchdog (${data.stale.length})`,
'#dc2626',
data.stale.map(j => tableRow([
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
j.type,
j.kind === 'running-stale' ? 'Running abandonado' : 'Pending abandonado',
`${j.ageHours}h`,
])).join(''),
['Tenant', 'Contribuyente', 'Tipo', 'Problema', 'Antigüedad']
)
: '';
const stuckHtml = data.stuckRunning.length > 0
? section(
`Running atorados sin avance (${data.stuckRunning.length})`,
'#f59e0b',
data.stuckRunning.map(j => tableRow([
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
j.type,
`${j.progressPercent}%`,
`${j.hoursRunning}h`,
fmtDate(j.startedAt),
])).join(''),
['Tenant', 'Contribuyente', 'Tipo', 'Progreso', 'Tiempo', 'Inicio']
)
: '';
const pendingHtml = data.pendingOld.length > 0
? section(
`Pending sin atender (${data.pendingOld.length})`,
'#f59e0b',
data.pendingOld.map(j => tableRow([
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
j.type,
`${j.hoursPending}h`,
j.nextRetryAt ? fmtDate(j.nextRetryAt) : 'Sin reintento',
])).join(''),
['Tenant', 'Contribuyente', 'Tipo', 'Tiempo pendiente', 'Próximo reintento']
)
: '';
const missingHtml = data.missingInitial.length > 0
? section(
`Contribuyentes con FIEL sin sync inicial (${data.missingInitial.length})`,
'#dc2626',
data.missingInitial.map(j => tableRow([
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
`<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc}</span>`,
])).join(''),
['Tenant', 'Contribuyente']
)
: '';
return baseTemplate(`
${heading('🚨 Alerta de sincronización SAT')}
<p style="color:${C.textPrimary};margin:0 0 16px;">
El monitoreo de sincronizaciones SAT detectó anomalías que requieren revisión interna.
</p>
${infoBox(`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${summaryRows}</table>`)}
${failedHtml}
${staleHtml}
${stuckHtml}
${pendingHtml}
${missingHtml}
<p style="color:${C.textMuted};margin:24px 0 0;font-size:12px;">
Reporte generado el ${data.generatedAt} para ${data.recipient}.<br/>
Configura umbrales con SAT_STUCK_RUNNING_HOURS y SAT_FAILED_LOOKBACK_HOURS.
</p>
`);
}

View File

@@ -378,6 +378,7 @@ export async function processProximosRecordatorios(
WHERE completado = false WHERE completado = false
AND fecha_limite = (CURRENT_DATE + ${dias})::date AND fecha_limite = (CURRENT_DATE + ${dias})::date
AND ${col} IS NULL AND ${col} IS NULL
AND (serie_id IS NOT NULL OR recurrencia = 'unica')
`); `);
for (const r of rows) { for (const r of rows) {

View File

@@ -201,18 +201,27 @@ export async function handleAddonPayment(addonId: string, mpPaymentId: string, s
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
// Overage automático: Business Control y Enterprise (business_cloud) incluyen // Overage automático: Business Control y Enterprise (business_cloud) incluyen
// 100 contribuyentes; cada uno adicional cuesta $45/mes. Se modela como un // 100 contribuyentes; cada RFC adicional cuesta:
// único `SubscriptionAddon` con `codename = 'contribuyente_extra_business_cloud'` // - Business Control: $25/mes
// (codename heredado por compat con suscripciones existentes; nombre display ya // - Enterprise: $60/mes
// es genérico), `contribuyenteId = null` (tenant-level) y // Se modela como un único `SubscriptionAddon` con
// `quantity = activeCount 100`. El cobro MP usa un preapproval propio; cuando // `codename = 'contribuyente_extra_business_cloud'` (codename heredado por
// `quantity` cambia, se actualiza vía `updatePreapprovalAmount` (sin // compat con suscripciones existentes; nombre display ya es genérico),
// re-autorización del usuario). // `contribuyenteId = null` (tenant-level) y `quantity = activeCount 100`.
// El cobro MP usa un preapproval propio; cuando `quantity` cambia, se actualiza
// vía `updatePreapprovalAmount` (sin re-autorización del usuario).
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
const DESPACHO_INCLUDED_RFCS = 100; const DESPACHO_INCLUDED_RFCS = 100;
const OVERAGE_ADDON_CODENAME = 'contribuyente_extra_business_cloud'; const OVERAGE_ADDON_CODENAME = 'contribuyente_extra_business_cloud';
function getOverageUnitPrice(plan: string): number {
if (plan === 'business_control') return 25;
if (plan === 'business_cloud') return 60;
// Fallback por si en el futuro se agrega otro plan con overage
return 45;
}
export type OverageAction = 'none' | 'created' | 'updated' | 'cancelled' | 'skipped'; export type OverageAction = 'none' | 'created' | 'updated' | 'cancelled' | 'skipped';
export interface OverageAdjustResult { export interface OverageAdjustResult {
@@ -228,9 +237,9 @@ export interface OverageAdjustResult {
/** /**
* Ajusta el add-on de overage para el tenant según el número actual de * Ajusta el add-on de overage para el tenant según el número actual de
* contribuyentes activos. Aplica a planes Business Control y Enterprise * contribuyentes activos. Aplica a planes Business Control y Enterprise
* (business_cloud) — ambos incluyen 100 contribuyentes y cobran $45/mes por * (business_cloud) — ambos incluyen 100 contribuyentes y cobran $25/mes
* cada adicional. Idempotente: llamar varias veces con el mismo `activeCount` * (Business Control) o $60/mes (Enterprise) por cada adicional. Idempotente:
* no tiene efecto. * llamar varias veces con el mismo `activeCount` no tiene efecto.
* *
* Casos: * Casos:
* - Plan no permite overage (mi_empresa, mi_empresa_plus, trial, etc.) → 'skipped' * - Plan no permite overage (mi_empresa, mi_empresa_plus, trial, etc.) → 'skipped'
@@ -291,7 +300,7 @@ export async function adjustDespachoOverage(
} }
// Hay overage → crear o actualizar addon // Hay overage → crear o actualizar addon
const price = Number(catalogo.precio); const price = getOverageUnitPrice(sub.plan);
const newAmount = price * overage; const newAmount = price * overage;
if (!existing) { if (!existing) {

View File

@@ -221,6 +221,9 @@ export async function createProrationPreference(params: {
pending: `${backUrlBase()}/configuracion/suscripcion?upgrade=pending`, pending: `${backUrlBase()}/configuracion/suscripcion?upgrade=pending`,
}, },
auto_return: 'approved', auto_return: 'approved',
payment_methods: {
installments: 12,
},
}, },
}); });
@@ -266,6 +269,9 @@ export async function createSubscriptionPreference(params: {
pending: `${backUrlBase()}/configuracion/suscripcion?subscription=pending`, pending: `${backUrlBase()}/configuracion/suscripcion?subscription=pending`,
}, },
auto_return: 'approved', auto_return: 'approved',
payment_methods: {
installments: 12,
},
}, },
}); });

View File

@@ -1,77 +1,221 @@
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared'; import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
export type RecurrenciaRecordatorio = 'unica' | 'mensual' | 'bimestral' | 'trimestral' | 'anual';
const RECURRENCIA_DELTA_MESES: Record<RecurrenciaRecordatorio, number> = {
unica: 0,
mensual: 1,
bimestral: 2,
trimestral: 3,
anual: 12,
};
interface RecordatorioRow {
id: number;
titulo: string;
descripcion: string | null;
fecha_limite: Date;
notas: string | null;
completado: boolean;
privado: boolean;
creado_por: string;
created_at: Date;
recurrencia: RecurrenciaRecordatorio;
serie_id: number | null;
activo: boolean;
fecha_inicio: Date | null;
fecha_fin: Date | null;
}
function toISODate(d: Date): string {
return d.toISOString().split('T')[0];
}
function startOfDay(d: Date): Date {
const r = new Date(d);
r.setHours(0, 0, 0, 0);
return r;
}
function addMonthsLocal(date: Date, months: number): Date {
const result = new Date(date);
const day = result.getDate();
result.setMonth(result.getMonth() + months);
// Si el mes resultante no tiene el mismo día, volver al último día del mes anterior
if (result.getDate() !== day) {
result.setDate(0);
}
return startOfDay(result);
}
function calcularSiguienteFecha(fecha: Date, recurrencia: RecurrenciaRecordatorio): Date {
return addMonthsLocal(fecha, RECURRENCIA_DELTA_MESES[recurrencia]);
}
function generarFechasInstancias(
fechaInicio: Date,
recurrencia: RecurrenciaRecordatorio,
fechaFin: Date | null | undefined,
maxMesesHorizonte = 24,
): Date[] {
const delta = RECURRENCIA_DELTA_MESES[recurrencia];
if (delta === 0) return [];
const inicio = startOfDay(fechaInicio);
const limiteHorizonte = addMonthsLocal(new Date(), maxMesesHorizonte);
const limite = fechaFin ? startOfDay(fechaFin) : limiteHorizonte;
const fechas: Date[] = [];
let current = inicio;
while (current <= limite) {
fechas.push(new Date(current));
current = calcularSiguienteFecha(current, recurrencia);
}
return fechas;
}
function rowToEventoFiscal(r: RecordatorioRow): EventoFiscal {
return {
id: r.id,
titulo: r.titulo,
descripcion: r.descripcion || '',
tipo: 'custom' as const,
fechaLimite: toISODate(r.fecha_limite),
recurrencia: r.recurrencia,
completado: r.completado,
notas: r.notas,
privado: r.privado,
creadoPor: r.creado_por,
createdAt: r.created_at?.toISOString(),
// metadata extra para el frontend
serieId: r.serie_id ?? undefined,
esPeriodico: r.recurrencia !== 'unica',
} as EventoFiscal;
}
async function findById(pool: Pool, id: number): Promise<RecordatorioRow | null> {
const { rows } = await pool.query<RecordatorioRow>(
`SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado,
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
FROM recordatorios WHERE id = $1`,
[id]
);
return rows[0] ?? null;
}
async function getMaestroDesdeId(pool: Pool, id: number): Promise<RecordatorioRow | null> {
const row = await findById(pool, id);
if (!row) return null;
if (row.serie_id === null) return row;
return findById(pool, row.serie_id);
}
async function generarInstancias(
pool: Pool,
maestro: RecordatorioRow,
maxMesesHorizonte = 24,
): Promise<number> {
if (!maestro.fecha_inicio) return 0;
if (maestro.recurrencia === 'unica') return 0;
const fechas = generarFechasInstancias(
maestro.fecha_inicio,
maestro.recurrencia,
maestro.fecha_fin ?? undefined,
maxMesesHorizonte,
);
if (fechas.length === 0) return 0;
let insertadas = 0;
for (const fecha of fechas) {
const { rowCount } = await pool.query(
`INSERT INTO recordatorios (
titulo, descripcion, fecha_limite, notas, completado, privado,
creado_por, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
)
VALUES ($1, $2, $3, $4, false, $5, $6, 'unica', $7, true, $8, $9)
ON CONFLICT DO NOTHING`,
[
maestro.titulo,
maestro.descripcion,
toISODate(fecha),
maestro.notas,
maestro.privado,
maestro.creado_por,
maestro.id,
toISODate(maestro.fecha_inicio),
maestro.fecha_fin ? toISODate(maestro.fecha_fin) : null,
]
);
insertadas += rowCount ?? 0;
}
return insertadas;
}
/** /**
* Obtiene recordatorios visibles para el usuario. * Obtiene recordatorios visibles para el usuario.
* - Públicos: todos los del tenant * - Públicos: todos los del tenant
* - Privados: solo los creados por el usuario * - Privados: solo los creados por el usuario
* Excluye los maestros periódicos (serie_id IS NULL AND recurrencia != 'unica')
* para no duplicar eventos en el calendario.
*/ */
export async function getRecordatorios( export async function getRecordatorios(
pool: Pool, pool: Pool,
userId: string, userId: string,
año: number año: number
): Promise<EventoFiscal[]> { ): Promise<EventoFiscal[]> {
const { rows } = await pool.query(` const { rows } = await pool.query<RecordatorioRow>(`
SELECT id, titulo, descripcion, fecha_limite as "fechaLimite", SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado,
notas, completado, privado, creado_por as "creadoPor", creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
created_at as "createdAt"
FROM recordatorios FROM recordatorios
WHERE EXTRACT(YEAR FROM fecha_limite) = $1 WHERE EXTRACT(YEAR FROM fecha_limite) = $1
AND activo = true
AND (privado = false OR creado_por = $2) AND (privado = false OR creado_por = $2)
AND NOT (serie_id IS NULL AND recurrencia <> 'unica')
ORDER BY fecha_limite ORDER BY fecha_limite
`, [año, userId]); `, [año, userId]);
return rows.map(r => ({ return rows.map(rowToEventoFiscal);
id: r.id,
titulo: r.titulo,
descripcion: r.descripcion || '',
tipo: 'custom' as const,
fechaLimite: r.fechaLimite instanceof Date
? r.fechaLimite.toISOString().split('T')[0]
: String(r.fechaLimite).split('T')[0],
recurrencia: 'unica' as const,
completado: r.completado,
notas: r.notas,
privado: r.privado,
creadoPor: r.creadoPor,
createdAt: r.createdAt?.toISOString(),
}));
} }
export async function createRecordatorio( export async function createRecordatorio(
pool: Pool, pool: Pool,
userId: string, userId: string,
data: EventoCreate & { privado?: boolean } data: EventoCreate & { privado?: boolean; fechaFin?: string | null }
): Promise<EventoFiscal> { ): Promise<EventoFiscal> {
const { rows } = await pool.query(` const recurrencia = (data.recurrencia as RecurrenciaRecordatorio) || 'unica';
INSERT INTO recordatorios (titulo, descripcion, fecha_limite, notas, privado, creado_por) const fechaFin = data.fechaFin ? new Date(data.fechaFin + 'T00:00:00') : null;
VALUES ($1, $2, $3, $4, $5, $6) const fechaLimite = new Date(data.fechaLimite + 'T00:00:00');
RETURNING id, titulo, descripcion, fecha_limite as "fechaLimite",
notas, completado, privado, creado_por as "creadoPor", const { rows } = await pool.query<RecordatorioRow>(`
created_at as "createdAt" INSERT INTO recordatorios (
titulo, descripcion, fecha_limite, notas, completado, privado,
creado_por, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
)
VALUES ($1, $2, $3, $4, false, $5, $6, $7, NULL, true, $8, $9)
RETURNING id, titulo, descripcion, fecha_limite, notas, completado, privado,
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
`, [ `, [
data.titulo, data.titulo,
data.descripcion || null, data.descripcion || null,
data.fechaLimite, toISODate(fechaLimite),
data.notas || null, data.notas || null,
data.privado ?? false, data.privado ?? false,
userId, userId,
recurrencia,
toISODate(fechaLimite),
fechaFin ? toISODate(fechaFin) : null,
]); ]);
const r = rows[0]; const maestro = rows[0];
return {
id: r.id, if (recurrencia !== 'unica') {
titulo: r.titulo, await generarInstancias(pool, maestro);
descripcion: r.descripcion || '', }
tipo: 'custom',
fechaLimite: r.fechaLimite instanceof Date return rowToEventoFiscal(maestro);
? r.fechaLimite.toISOString().split('T')[0]
: String(r.fechaLimite).split('T')[0],
recurrencia: 'unica',
completado: r.completado,
notas: r.notas,
createdAt: r.createdAt?.toISOString(),
};
} }
export async function updateRecordatorio( export async function updateRecordatorio(
@@ -80,14 +224,32 @@ export async function updateRecordatorio(
id: number, id: number,
data: EventoUpdate & { privado?: boolean } data: EventoUpdate & { privado?: boolean }
): Promise<EventoFiscal | null> { ): Promise<EventoFiscal | null> {
// Verify ownership or public const row = await findById(pool, id);
const { rows: existing } = await pool.query( if (!row) return null;
`SELECT id, creado_por FROM recordatorios WHERE id = $1`,
[id]
);
if (existing.length === 0) return null; // Completar una instancia individual de una serie periódica
const soloCompletado =
data.completado !== undefined &&
data.titulo === undefined &&
data.descripcion === undefined &&
data.fechaLimite === undefined &&
data.notas === undefined &&
data.privado === undefined;
if (soloCompletado && row.serie_id !== null) {
await pool.query(`UPDATE recordatorios SET completado = $1, updated_at = NOW() WHERE id = $2`, [
data.completado,
id,
]);
const updated = await findById(pool, id);
return updated ? rowToEventoFiscal(updated) : null;
}
const maestro = row.serie_id === null ? row : await findById(pool, row.serie_id);
if (!maestro) return null;
// Recordatorio único: editar directamente
if (maestro.recurrencia === 'unica') {
const sets: string[] = []; const sets: string[] = [];
const params: any[] = []; const params: any[] = [];
let idx = 1; let idx = 1;
@@ -95,39 +257,90 @@ export async function updateRecordatorio(
if (data.titulo !== undefined) { sets.push(`titulo = $${idx++}`); params.push(data.titulo); } if (data.titulo !== undefined) { sets.push(`titulo = $${idx++}`); params.push(data.titulo); }
if (data.descripcion !== undefined) { sets.push(`descripcion = $${idx++}`); params.push(data.descripcion); } if (data.descripcion !== undefined) { sets.push(`descripcion = $${idx++}`); params.push(data.descripcion); }
if (data.fechaLimite !== undefined) { sets.push(`fecha_limite = $${idx++}`); params.push(data.fechaLimite); } if (data.fechaLimite !== undefined) { sets.push(`fecha_limite = $${idx++}`); params.push(data.fechaLimite); }
if (data.completado !== undefined) { sets.push(`completado = $${idx++}`); params.push(data.completado); }
if (data.notas !== undefined) { sets.push(`notas = $${idx++}`); params.push(data.notas); } if (data.notas !== undefined) { sets.push(`notas = $${idx++}`); params.push(data.notas); }
if (data.privado !== undefined) { sets.push(`privado = $${idx++}`); params.push(data.privado); } if (data.privado !== undefined) { sets.push(`privado = $${idx++}`); params.push(data.privado); }
if (data.completado !== undefined) { sets.push(`completado = $${idx++}`); params.push(data.completado); }
if (sets.length === 0) return null; if (sets.length === 0) return rowToEventoFiscal(maestro);
sets.push(`updated_at = NOW()`); sets.push(`updated_at = NOW()`);
params.push(id); params.push(maestro.id);
const { rows } = await pool.query(` const { rows } = await pool.query<RecordatorioRow>(`
UPDATE recordatorios SET ${sets.join(', ')} UPDATE recordatorios SET ${sets.join(', ')}
WHERE id = $${idx} WHERE id = $${idx}
RETURNING id, titulo, descripcion, fecha_limite as "fechaLimite", RETURNING id, titulo, descripcion, fecha_limite, notas, completado, privado,
notas, completado, privado, creado_por as "creadoPor", creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
created_at as "createdAt"
`, params); `, params);
if (rows.length === 0) return null; return rows[0] ? rowToEventoFiscal(rows[0]) : null;
}
const r = rows[0]; // Serie periódica: editar maestro y propagar a instancias futuras no completadas
return { const updatesMaestro: string[] = [];
id: r.id, const paramsMaestro: any[] = [];
titulo: r.titulo, let idx = 1;
descripcion: r.descripcion || '',
tipo: 'custom', if (data.titulo !== undefined) { updatesMaestro.push(`titulo = $${idx++}`); paramsMaestro.push(data.titulo); }
fechaLimite: r.fechaLimite instanceof Date if (data.descripcion !== undefined) { updatesMaestro.push(`descripcion = $${idx++}`); paramsMaestro.push(data.descripcion); }
? r.fechaLimite.toISOString().split('T')[0] if (data.notas !== undefined) { updatesMaestro.push(`notas = $${idx++}`); paramsMaestro.push(data.notas); }
: String(r.fechaLimite).split('T')[0], if (data.privado !== undefined) { updatesMaestro.push(`privado = $${idx++}`); paramsMaestro.push(data.privado); }
recurrencia: 'unica',
completado: r.completado, const nuevaFechaInicio = data.fechaLimite ? new Date(data.fechaLimite + 'T00:00:00') : null;
notas: r.notas, if (nuevaFechaInicio) {
createdAt: r.createdAt?.toISOString(), updatesMaestro.push(`fecha_limite = $${idx++}`);
}; paramsMaestro.push(toISODate(nuevaFechaInicio));
updatesMaestro.push(`fecha_inicio = $${idx++}`);
paramsMaestro.push(toISODate(nuevaFechaInicio));
}
if (updatesMaestro.length > 0) {
updatesMaestro.push(`updated_at = NOW()`);
paramsMaestro.push(maestro.id);
await pool.query(
`UPDATE recordatorios SET ${updatesMaestro.join(', ')} WHERE id = $${idx}`,
paramsMaestro
);
}
// Propagar campos de contenido a instancias futuras no completadas
const updatesInstancias: string[] = [];
const paramsInstancias: any[] = [];
let iIdx = 1;
if (data.titulo !== undefined) { updatesInstancias.push(`titulo = $${iIdx++}`); paramsInstancias.push(data.titulo); }
if (data.descripcion !== undefined) { updatesInstancias.push(`descripcion = $${iIdx++}`); paramsInstancias.push(data.descripcion); }
if (data.notas !== undefined) { updatesInstancias.push(`notas = $${iIdx++}`); paramsInstancias.push(data.notas); }
if (data.privado !== undefined) { updatesInstancias.push(`privado = $${iIdx++}`); paramsInstancias.push(data.privado); }
if (updatesInstancias.length > 0) {
paramsInstancias.push(maestro.id);
await pool.query(
`UPDATE recordatorios
SET ${updatesInstancias.join(', ')}
WHERE serie_id = $${iIdx}
AND fecha_limite >= CURRENT_DATE
AND completado = false`,
paramsInstancias
);
}
// Si cambió la fecha de inicio, regenerar instancias futuras
if (nuevaFechaInicio) {
await pool.query(
`DELETE FROM recordatorios
WHERE serie_id = $1
AND fecha_limite >= CURRENT_DATE
AND completado = false`,
[maestro.id]
);
const maestroActualizado = await findById(pool, maestro.id);
if (maestroActualizado) {
await generarInstancias(pool, maestroActualizado);
}
}
const maestroFinal = await findById(pool, maestro.id);
return maestroFinal ? rowToEventoFiscal(maestroFinal) : null;
} }
export async function deleteRecordatorio( export async function deleteRecordatorio(
@@ -135,9 +348,70 @@ export async function deleteRecordatorio(
userId: string, userId: string,
id: number id: number
): Promise<boolean> { ): Promise<boolean> {
const { rowCount } = await pool.query( const row = await findById(pool, id);
`DELETE FROM recordatorios WHERE id = $1`, if (!row) return false;
[id]
// Serie periódica: cancelar (desactivar maestro y borrar instancias futuras no completadas)
if (row.recurrencia !== 'unica' || row.serie_id !== null) {
const maestro = row.serie_id === null ? row : await findById(pool, row.serie_id);
if (!maestro) return false;
await pool.query(`UPDATE recordatorios SET activo = false, updated_at = NOW() WHERE id = $1`, [maestro.id]);
await pool.query(
`DELETE FROM recordatorios
WHERE serie_id = $1
AND fecha_limite >= CURRENT_DATE
AND completado = false`,
[maestro.id]
); );
return true;
}
// Único: borrar directamente
const { rowCount } = await pool.query(`DELETE FROM recordatorios WHERE id = $1`, [id]);
return (rowCount ?? 0) > 0; return (rowCount ?? 0) > 0;
} }
/**
* Extiende las series activas para mantener un horizonte futuro de instancias.
* Útil para llamar desde un cron periódico.
*/
export async function extenderSeriesActivas(
pool: Pool,
mesesHorizonte = 24,
): Promise<{ series: number; instancias: number }> {
const { rows: maestros } = await pool.query<RecordatorioRow>(`
SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado,
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
FROM recordatorios
WHERE serie_id IS NULL
AND recurrencia <> 'unica'
AND activo = true
`);
let seriesProcesadas = 0;
let instanciasCreadas = 0;
const horizonte = addMonthsLocal(new Date(), mesesHorizonte);
for (const maestro of maestros) {
const { rows: ultimas } = await pool.query<{ fecha_limite: Date }>(`
SELECT fecha_limite
FROM recordatorios
WHERE serie_id = $1
ORDER BY fecha_limite DESC
LIMIT 1
`, [maestro.id]);
const ultima = ultimas[0]?.fecha_limite;
if (!ultima || startOfDay(ultima) < addMonthsLocal(new Date(), mesesHorizonte - 6)) {
const creadas = await generarInstancias(pool, maestro, mesesHorizonte);
if (creadas > 0) {
seriesProcesadas++;
instanciasCreadas += creadas;
}
}
}
return { series: seriesProcesadas, instancias: instanciasCreadas };
}

View File

@@ -0,0 +1,100 @@
import { HttpsProxyAgent } from 'https-proxy-agent';
export interface ProxyConfig {
url: string;
host: string;
port: number;
username?: string;
password?: string;
}
export type ProxyStrategy = 'round-robin' | 'random';
function parseProxyList(raw: string): ProxyConfig[] {
if (!raw.trim()) return [];
const configs: ProxyConfig[] = [];
const items = raw.split(',').map(s => s.trim()).filter(Boolean);
for (const item of items) {
try {
const url = new URL(item);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
console.warn(`[ProxyManager] Protocolo no soportado, se omite: ${item}`);
continue;
}
configs.push({
url: item,
host: url.hostname,
port: Number(url.port) || (url.protocol === 'https:' ? 443 : 80),
username: url.username || undefined,
password: url.password || undefined,
});
} catch (err) {
console.warn(`[ProxyManager] URL de proxy inválida, se omite: ${item}`);
}
}
return configs;
}
export class ProxyManager {
private proxies: ProxyConfig[];
private strategy: ProxyStrategy;
private currentIndex = 0;
constructor(
proxyList = process.env.SAT_PROXY_LIST || '',
strategy: ProxyStrategy = (process.env.SAT_PROXY_STRATEGY as ProxyStrategy) || 'round-robin',
) {
this.proxies = parseProxyList(proxyList);
this.strategy = ['round-robin', 'random'].includes(strategy) ? strategy : 'round-robin';
if (this.proxies.length > 0) {
console.log(`[ProxyManager] ${this.proxies.length} proxy(s) configurados (estrategia: ${this.strategy})`);
} else {
console.log('[ProxyManager] No hay proxies configurados; se usará la IP directa del servidor');
}
}
hasProxies(): boolean {
return this.proxies.length > 0;
}
getProxyCount(): number {
return this.proxies.length;
}
getNextProxy(): ProxyConfig | null {
if (this.proxies.length === 0) return null;
if (this.strategy === 'random') {
return this.proxies[Math.floor(Math.random() * this.proxies.length)];
}
const proxy = this.proxies[this.currentIndex];
this.currentIndex = (this.currentIndex + 1) % this.proxies.length;
return proxy;
}
createAgent(proxy: ProxyConfig): HttpsProxyAgent<string> {
return new HttpsProxyAgent(proxy.url);
}
/**
* Crea un agente con el siguiente proxy del pool.
* Útil cuando se quiere una nueva conexión por solicitud.
*/
createNextAgent(): HttpsProxyAgent<string> | null {
const proxy = this.getNextProxy();
if (!proxy) return null;
console.log(`[ProxyManager] Usando proxy: ${proxy.host}:${proxy.port}`);
return this.createAgent(proxy);
}
}
/**
* Instancia global del proxy manager.
* Lee SAT_PROXY_LIST y SAT_PROXY_STRATEGY del entorno.
*/
export const proxyManager = new ProxyManager();

View File

@@ -10,6 +10,7 @@ import {
DocumentStatus, DocumentStatus,
ServiceEndpoints, ServiceEndpoints,
} from '@nodecfdi/sat-ws-descarga-masiva'; } from '@nodecfdi/sat-ws-descarga-masiva';
import { proxyManager } from './proxy.service.js';
export interface FielData { export interface FielData {
cerContent: string; cerContent: string;
@@ -17,10 +18,33 @@ export interface FielData {
password: string; password: string;
} }
export interface ProxyInfo {
host: string;
port: number;
url: 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 * Crea el servicio de descarga masiva del SAT usando los datos de la FIEL
*/ */
export function createSatService(fielData: FielData): Service { export function createSatService(fielData: FielData): { service: Service; proxyInfo: ProxyInfo | null } {
// Crear FIEL usando el método estático create // Crear FIEL usando el método estático create
const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password); const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password);
@@ -29,14 +53,33 @@ export function createSatService(fielData: FielData): Service {
throw new Error('La FIEL no es válida o está vencida'); throw new Error('La FIEL no es válida o está vencida');
} }
// Crear cliente HTTP // Crear cliente HTTP con timeout explícito para evitar el bug de la librería
const webClient = new HttpsWebClient(); // cuando ocurre un timeout de red. Si hay proxies configurados, se usa uno
// del pool para reducir el riesgo de bloqueo por IP del SAT.
const proxy = proxyManager.getNextProxy();
const proxyAgent = proxy ? proxyManager.createAgent(proxy) : null;
if (proxyAgent) {
console.log('[SAT] Usando proxy para la conexión con el SAT');
} else {
console.log('[SAT] Sin proxy configurado; usando IP directa del servidor');
}
const webClient = new (HttpsWebClient as any)(
undefined,
undefined,
SAT_WEB_CLIENT_TIMEOUT_MS,
proxyAgent,
);
// Crear request builder con la FIEL // Crear request builder con la FIEL
const requestBuilder = new FielRequestBuilder(fiel); const requestBuilder = new FielRequestBuilder(fiel);
// Crear y retornar el servicio // Crear y retornar el servicio
return new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi()); const service = new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi());
return {
service,
proxyInfo: proxy ? { host: proxy.host, port: proxy.port, url: proxy.url } : null,
};
} }
export interface QueryResult { export interface QueryResult {
@@ -73,10 +116,13 @@ export async function querySat(
): Promise<QueryResult> { ): Promise<QueryResult> {
try { try {
// El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca // El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca
// a medianoche, dos fechas dentro del mismo día calendario resultan iguales. // a medianoche en zona horaria de México, dos fechas dentro del mismo día
// Ajustamos fechaFin al día siguiente para evitar el error. // calendario mexicano resultan iguales. Ajustamos fechaFin al día siguiente
// en hora México para evitar el error.
let adjustedFechaFin = fechaFin; 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); adjustedFechaFin = new Date(fechaFin.getTime() + 24 * 60 * 60 * 1000);
} }
@@ -110,7 +156,30 @@ export async function querySat(
statusCode: result.getStatus().getCode().toString(), statusCode: result.getStatus().getCode().toString(),
}; };
} catch (error: any) { } 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 { return {
success: false, success: false,
message: error.message || 'Error al realizar consulta', message: error.message || 'Error al realizar consulta',
@@ -174,6 +243,7 @@ export async function verifySatRequest(
if (entryId === 'Finished') status = 'ready'; if (entryId === 'Finished') status = 'ready';
else if (entryId === 'InProgress') status = 'processing'; else if (entryId === 'InProgress') status = 'processing';
else if (entryId === 'Accepted') status = 'pending'; else if (entryId === 'Accepted') status = 'pending';
else if (entryId === 'Unknown' && result.getStatus().getCode().toString() === '404') status = 'failed';
else status = 'pending'; else status = 'pending';
} }
@@ -183,6 +253,38 @@ export async function verifySatRequest(
const statusMsg = result.getStatus().getMessage(); const statusMsg = result.getStatus().getMessage();
const reqValue = statusRequest.getValue(); const reqValue = statusRequest.getValue();
const reqEntry = statusRequest.getEntryId(); 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; let message = statusMsg;
if (status === 'rejected' || status === 'failed') { if (status === 'rejected' || status === 'failed') {
const codeReqStr = codeRequestValue const codeReqStr = codeRequestValue
@@ -200,7 +302,7 @@ export async function verifySatRequest(
statusCode, statusCode,
}; };
} catch (error: any) { } 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) // Errores de la librería (ej. webError.getResponse is not a function)
// no son fallos del SAT — devolver 'pending' para reintentar polling // no son fallos del SAT — devolver 'pending' para reintentar polling
return { return {
@@ -250,8 +352,34 @@ export async function downloadSatPackage(
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss). * Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss).
* El SAT requiere hora 00:00:00; cualquier otra hora causa * El SAT requiere hora 00:00:00; cualquier otra hora causa
* "Fecha final invalida" / "Fecha inicial invalida". * "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 { function formatDateForSat(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0'); const fmt = new Intl.DateTimeFormat('es-MX', {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`; 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

@@ -1,40 +1,113 @@
import type { Page, Locator, Frame, Response } from 'playwright'; import type { Page, Locator, Frame, Response, BrowserContext } from 'playwright';
import type { CsfLoginSession } from './sat-csf-login.js'; import type { CsfLoginSession } from './sat-csf-login.js';
async function tryFetchPdfFromUrl(page: Page, url: string): Promise<Buffer | null> { async function tryFetchPdfFromUrl(frame: Frame, url: string): Promise<Buffer | null> {
if (!url || url === 'about:blank') return null;
// Blob / data URI → fetchear dentro del navegador para respetar cookies/sesión
if (url.startsWith('blob:') || url.startsWith('data:')) { if (url.startsWith('blob:') || url.startsWith('data:')) {
try {
const page = frame.page();
const arr = await page.evaluate(async (u) => { const arr = await page.evaluate(async (u) => {
const r = await fetch(u); const r = await fetch(u);
const buf = await r.arrayBuffer(); const buf = await r.arrayBuffer();
return Array.from(new Uint8Array(buf)); return Array.from(new Uint8Array(buf));
}, url); }, url);
return Buffer.from(arr); const buf = Buffer.from(arr);
return buf.subarray(0, 5).toString().startsWith('%PDF-') ? buf : null;
} catch {
return null;
} }
}
// URL http(s) relativa o absoluta → fetchear desde el frame para mantener sesión
if (url.startsWith('http')) { if (url.startsWith('http')) {
const response = await page.context().request.get(url); try {
const response = await frame.page().context().request.get(url);
if (!response.ok()) return null; if (!response.ok()) return null;
return Buffer.from(await response.body()); const ct = response.headers()['content-type'] ?? '';
if (!ct.includes('application/pdf') && !url.toLowerCase().includes('.pdf')) return null;
const buf = Buffer.from(await response.body());
return buf.subarray(0, 5).toString().startsWith('%PDF-') ? buf : null;
} catch {
return null;
} }
}
return null; return null;
} }
/** async function findPdfInFrames(appPage: Page, deadlineMs: number): Promise<Buffer | null> {
* Busca "Generar Constancia" en cualquiera de los frames del appPage (vive const deadline = Date.now() + deadlineMs;
* típicamente en un iframe JSF legacy: rfcampc.siat.sat.gob.mx/PTSC/...).
* Intenta 3 rutas: download event, popup con viewer, response interception.
*/
export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
const { context, appPage } = session;
let interceptedPdf: Buffer | null = null; while (Date.now() < deadline) {
const responseListener = async (response: Response) => { const frames = appPage.frames();
const ct = response.headers()['content-type'] ?? '';
if (ct.includes('application/pdf')) { for (const frame of frames) {
try { interceptedPdf = Buffer.from(await response.body()); } catch { /* ok */ } try {
const frameUrl = frame.url();
// 1. Frame cuya URL sea directamente un PDF
if (
frameUrl.toLowerCase().includes('.pdf') ||
frameUrl.includes('application/pdf')
) {
const body = await frame.content().catch(() => null);
if (!body) continue;
// content() de un PDF no es util; intentar fetch por URL
const pdf = await tryFetchPdfFromUrl(frame, frameUrl);
if (pdf) return pdf;
} }
};
context.on('response', responseListener);
// 2. <embed type="application/pdf">
const embed = frame.locator('embed[type="application/pdf"]').first();
if ((await embed.count()) > 0) {
const src = await embed.getAttribute('src');
if (src) {
const pdf = await tryFetchPdfFromUrl(frame, src);
if (pdf) return pdf;
}
}
// 3. <iframe> cuyo src apunte al visor/constancia (IdcSiat, .pdf, etc.)
const iframes = await frame.locator('iframe').all();
for (const iframe of iframes) {
const src = await iframe.getAttribute('src');
if (src) {
const lower = src.toLowerCase();
if (
lower.includes('.pdf') ||
lower.includes('idcsiat') ||
lower.includes('reimpresion') ||
lower.includes('consultatramite')
) {
const pdf = await tryFetchPdfFromUrl(frame, src);
if (pdf) return pdf;
}
}
}
// 4. <a download> o link con data/blob generado por jsPDF
const downloadLinks = await frame.locator('a[download], a[href*="data:"], a[href*="blob:"]').all();
for (const link of downloadLinks) {
const href = await link.getAttribute('href');
if (href) {
const pdf = await tryFetchPdfFromUrl(frame, href);
if (pdf) return pdf;
}
}
} catch {
// Frame puede estar navegando; ignorar y continuar
}
}
await appPage.waitForTimeout(1000);
}
return null;
}
async function findGenerarButton(appPage: Page, timeoutMs: number): Promise<Locator | null> {
const GENERAR_SELECTORS = [ const GENERAR_SELECTORS = [
'button:has-text("Generar Constancia")', 'button:has-text("Generar Constancia")',
'button:has-text("Generar constancia")', 'button:has-text("Generar constancia")',
@@ -44,77 +117,109 @@ export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
'a:has-text("Generar constancia")', 'a:has-text("Generar constancia")',
].join(', '); ].join(', ');
let generarLocator: Locator | null = null; const deadline = Date.now() + timeoutMs;
let foundFrame: Frame | null = null;
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) { while (Date.now() < deadline) {
for (const frame of appPage.frames()) { for (const frame of appPage.frames()) {
const loc = frame.locator(GENERAR_SELECTORS).first(); const loc = frame.locator(GENERAR_SELECTORS).first();
const count = await loc.count().catch(() => 0); const count = await loc.count().catch(() => 0);
if (count > 0 && await loc.isVisible().catch(() => false)) { if (count > 0 && (await loc.isVisible().catch(() => false))) {
generarLocator = loc; return loc;
foundFrame = frame;
break;
} }
} }
if (generarLocator) break;
await appPage.waitForTimeout(1000); await appPage.waitForTimeout(1000);
} }
return null;
}
if (!generarLocator || !foundFrame) { /**
context.off('response', responseListener); * Busca "Generar Constancia" en cualquiera de los frames del appPage,
* hace click, y extrae el PDF desde el visor/iframe que genera el SAT.
*
* El portal SAT actual (2025-2026) genera la CSF dentro de un iframe JSF
* legacy (rfcampc.siat.sat.gob.mx/PTSC/.../ConsultaTramite.jsf). El PDF
* no siempre se descarga como evento de download ni abre popup; a veces
* se renderiza en un <embed> o en un iframe cuyo src devuelve el PDF.
*/
export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
const { context, appPage } = session;
let interceptedPdf: Buffer | null = null;
const responseListener = async (response: Response) => {
const ct = response.headers()['content-type'] ?? '';
if (ct.includes('application/pdf')) {
try {
interceptedPdf = Buffer.from(await response.body());
} catch {
/* ok */
}
}
};
context.on('response', responseListener);
try {
const generarLocator = await findGenerarButton(appPage, 90_000);
if (!generarLocator) {
throw new Error('Botón "Generar Constancia" no encontrado en ningún frame del portal SAT (tras 90s)'); throw new Error('Botón "Generar Constancia" no encontrado en ningún frame del portal SAT (tras 90s)');
} }
await generarLocator.scrollIntoViewIfNeeded(); await generarLocator.scrollIntoViewIfNeeded();
await appPage.waitForTimeout(500); await appPage.waitForTimeout(500);
const popupPromise = context.waitForEvent('page', { timeout: 15_000 }).catch(() => null); // Algunos botones del SAT usan JSF/ajax; un click simple a veces no basta.
const downloadPromise = appPage.waitForEvent('download', { timeout: 15_000 }).catch(() => null); // Hacemos click normal y, como fallback, dispatchEvent si no hay reacción.
await generarLocator.click(); await generarLocator.click();
await appPage.waitForTimeout(2000);
// Intentar extraer el PDF del iframe/visor
let pdf = await findPdfInFrames(appPage, 60_000);
if (pdf) return pdf;
// Si aún no hay PDF, algunos flujos abren popup clásico
const popupPromise = context.waitForEvent('page', { timeout: 10_000 }).catch(() => null);
const downloadPromise = appPage.waitForEvent('download', { timeout: 10_000 }).catch(() => null);
// Reintentar click por si el primero no disparó el handler
const stillVisible = await generarLocator.isVisible().catch(() => false);
if (stillVisible) {
await generarLocator.dispatchEvent('click');
}
const [popup, download] = await Promise.all([popupPromise, downloadPromise]); const [popup, download] = await Promise.all([popupPromise, downloadPromise]);
try {
// Path 1: download event
if (download) { if (download) {
const stream = await download.createReadStream(); const stream = await download.createReadStream();
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(chunk as Buffer); for await (const chunk of stream) chunks.push(chunk as Buffer);
const pdf = Buffer.concat(chunks); const downloaded = Buffer.concat(chunks);
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) { if (downloaded.subarray(0, 5).toString().startsWith('%PDF-')) return downloaded;
throw new Error('El archivo descargado no es un PDF válido');
}
return pdf;
} }
// Path 2: viewer popup
if (popup) { if (popup) {
await popup.waitForLoadState('domcontentloaded').catch(() => undefined); await popup.waitForLoadState('domcontentloaded').catch(() => undefined);
await popup.waitForTimeout(2000); await popup.waitForTimeout(2000);
let pdf = await tryFetchPdfFromUrl(popup, popup.url()).catch(() => null); pdf = await findPdfInFrames(popup, 20_000);
if (!pdf) { if (pdf) return pdf;
const embedSrc = await popup.locator('embed[type="application/pdf"], iframe').first().getAttribute('src').catch(() => null); const embedSrc = await popup.locator('embed[type="application/pdf"], iframe').first().getAttribute('src').catch(() => null);
if (embedSrc) { if (embedSrc) {
const absolute = new URL(embedSrc, popup.url()).toString(); const absolute = new URL(embedSrc, popup.url()).toString();
pdf = await tryFetchPdfFromUrl(popup, absolute).catch(() => null); pdf = await tryFetchPdfFromUrl(popup.mainFrame(), absolute).catch(() => null);
} if (pdf) return pdf;
}
if (!pdf && interceptedPdf) pdf = interceptedPdf;
if (!pdf || pdf.length === 0) throw new Error('El visor abrió pero no se pudo extraer el PDF');
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer extraído no es un PDF válido');
return pdf;
} }
// Path 3: inline response (no popup, no download) if (interceptedPdf) return interceptedPdf;
await appPage.waitForTimeout(3000); throw new Error('El visor abrió pero no se pudo extraer el PDF');
if (interceptedPdf) {
const pdf = interceptedPdf as Buffer;
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer interceptado no es un PDF válido');
return pdf;
} }
throw new Error('Click en "Generar Constancia" no produjo descarga, popup ni respuesta PDF');
// Último intento: esperar un poco más a que el iframe termine de cargar
await appPage.waitForTimeout(5000);
pdf = await findPdfInFrames(appPage, 20_000);
if (pdf) return pdf;
if (interceptedPdf) return interceptedPdf;
throw new Error('Click en "Generar Constancia" no produjo un PDF descargable ni visible');
} finally { } finally {
context.off('response', responseListener); context.off('response', responseListener);
} }

View File

@@ -8,6 +8,7 @@ import {
verifySatRequest, verifySatRequest,
downloadSatPackage, downloadSatPackage,
type FielData, type FielData,
type ProxyInfo,
} from './sat-client.service.js'; } from './sat-client.service.js';
import { processPackage, processMetadataPackage, extractXmlsFromZip, type CfdiParsed, type CfdiMetadata } from './sat-parser.service.js'; import { processPackage, processMetadataPackage, extractXmlsFromZip, type CfdiParsed, type CfdiMetadata } from './sat-parser.service.js';
import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../../utils/saldo.js'; import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../../utils/saldo.js';
@@ -17,10 +18,24 @@ import type { Pool } from 'pg';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
const POLL_INTERVAL_MS = 60000; // 60 segundos const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutos entre verificaciones
const MAX_POLL_ATTEMPTS = 500; // ~8 horas máximo para syncs iniciales grandes 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 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. * Política de retry por tipo de sync.
* - `retryAtHours[i]` = horas DESDE startedAt para el retry i+1. * - `retryAtHours[i]` = horas DESDE startedAt para el retry i+1.
@@ -49,6 +64,12 @@ const RETRY_POLICIES: Record<'daily' | 'custom' | 'initial' | 'incremental', {
incremental: { maxRetries: 0, retryAtHours: [] }, 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 }) { function getRetryPolicy(job: { type: SatSyncType; isCustomRange: boolean }) {
if (job.type === 'initial' && job.isCustomRange) return RETRY_POLICIES.custom; if (job.type === 'initial' && job.isCustomRange) return RETRY_POLICIES.custom;
return RETRY_POLICIES[job.type]; return RETRY_POLICIES[job.type];
@@ -71,6 +92,7 @@ function computeNextRetryAt(
interface SyncContext { interface SyncContext {
fielData: FielData; fielData: FielData;
service: Service; service: Service;
proxyInfo: ProxyInfo | null;
rfc: string; rfc: string;
tenantId: string; tenantId: string;
databaseName: string; databaseName: string;
@@ -78,6 +100,33 @@ interface SyncContext {
getPool: () => Promise<Pool>; getPool: () => Promise<Pool>;
} }
/**
* Registra un error de bloqueo/devolución del SAT junto con el proxy usado.
* Permite reportes diarios de errores por IP.
*/
async function recordSatProxyError(
jobId: string,
ctx: SyncContext,
errorCode: string | null,
stage: string,
message: string,
): Promise<void> {
try {
const proxyUsed = ctx.proxyInfo ? `${ctx.proxyInfo.host}:${ctx.proxyInfo.port}` : null;
await prisma.satProxyError.create({
data: {
jobId,
proxyUsed,
errorCode,
stage,
message,
},
});
} catch (err: any) {
console.error('[SAT] Error guardando sat_proxy_error:', err.message);
}
}
/** /**
* Actualiza el progreso de un job * Actualiza el progreso de un job
*/ */
@@ -97,6 +146,7 @@ async function updateJobProgress(
completedAt: Date; completedAt: Date;
retryCount: number; retryCount: number;
nextRetryAt: Date; nextRetryAt: Date;
proxyUsed: string | null;
}> }>
): Promise<void> { ): Promise<void> {
await prisma.satSyncJob.update({ await prisma.satSyncJob.update({
@@ -598,6 +648,7 @@ async function requestAndDownload(
fechaFin: Date, fechaFin: Date,
tipoCfdi: CfdiSyncType, tipoCfdi: CfdiSyncType,
requestType: 'cfdi' | 'metadata', requestType: 'cfdi' | 'metadata',
isDaily = false,
): Promise<{ packageContents: string[]; totalCfdis: number }> { ): Promise<{ packageContents: string[]; totalCfdis: number }> {
const label = `${tipoCfdi}/${requestType}`; const label = `${tipoCfdi}/${requestType}`;
const kindKey = makeRequestKindKey(fechaInicio, fechaFin, tipoCfdi, requestType); const kindKey = makeRequestKindKey(fechaInicio, fechaFin, tipoCfdi, requestType);
@@ -650,6 +701,10 @@ async function requestAndDownload(
// Estados terminales inválidos → descartar y crear nuevo // Estados terminales inválidos → descartar y crear nuevo
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') { 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`); console.log(`[SAT] Request reusado en estado ${verifyResult.status}, creando nuevo`);
requestId = null; requestId = null;
verifyResult = undefined; verifyResult = undefined;
@@ -670,10 +725,22 @@ async function requestAndDownload(
const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi, requestType); const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi, requestType);
if (!queryResult.success) { if (!queryResult.success) {
if (queryResult.statusCode === '5004') { if (queryResult.statusCode === '5004' || queryResult.statusCode === 'exhausted' || isAgotadas(queryResult.message)) {
console.log(`[SAT] No se encontraron CFDIs (${label})`); console.log(`[SAT] Sin CFDIs, quota agotada o solicitudes agotadas (${label}): ${queryResult.message}`);
return { packageContents: [], totalCfdis: 0 }; return { packageContents: [], totalCfdis: 0 };
} }
if (/error no controlado/i.test(queryResult.message || '')) {
await recordSatProxyError(jobId, ctx, queryResult.statusCode || '404', stageIdForTimeout(label), queryResult.message || 'Error no controlado');
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);
}
await recordSatProxyError(jobId, ctx, queryResult.statusCode || null, stageIdForTimeout(label), queryResult.message || 'Error SAT');
throw new Error(`Error SAT (${label}): ${queryResult.message}`); throw new Error(`Error SAT (${label}): ${queryResult.message}`);
} }
@@ -685,23 +752,29 @@ async function requestAndDownload(
// Polling — si el reuse ya devolvió `ready`, salta el loop directamente. // Polling — si el reuse ya devolvió `ready`, salta el loop directamente.
if (!verifyResult || verifyResult.status !== 'ready') { if (!verifyResult || verifyResult.status !== 'ready') {
const maxAttempts = isDaily ? DAILY_MAX_POLL_ATTEMPTS : MAX_POLL_ATTEMPTS;
let attempts = 0; let attempts = 0;
while (attempts < MAX_POLL_ATTEMPTS) { while (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
attempts++; attempts++;
verifyResult = await verifySatRequest(ctx.service, requestId); verifyResult = await verifySatRequest(ctx.service, requestId);
console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts})`); console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts}/${maxAttempts})`);
if (verifyResult.status === 'ready') break; if (verifyResult.status === 'ready') break;
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') { 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 };
}
await recordSatProxyError(jobId, ctx, verifyResult.status, stageIdForTimeout(label), verifyResult.message || `Solicitud ${verifyResult.status}`);
throw new Error(`Solicitud fallida (${label}): ${verifyResult.message}`); throw new Error(`Solicitud fallida (${label}): ${verifyResult.message}`);
} }
} }
} }
if (!verifyResult || verifyResult.status !== 'ready') { if (!verifyResult || verifyResult.status !== 'ready') {
throw new Error(`Timeout esperando respuesta del SAT (${label})`); throw new SatSyncTimeoutError(stageIdForTimeout(label), `Timeout esperando respuesta del SAT (${label})`);
} }
const packageContents: string[] = []; const packageContents: string[] = [];
@@ -741,17 +814,20 @@ async function processDateRange(
fechaInicio: Date, fechaInicio: Date,
fechaFin: Date, fechaFin: Date,
tipoCfdi: CfdiSyncType, tipoCfdi: CfdiSyncType,
skipJobUpdate = false skipJobUpdate = false,
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> { throwOnError = false,
isDaily = false
): Promise<{ found: number; downloaded: number; inserted: number; updated: number; errors: { message: string }[] }> {
let totalFound = 0; let totalFound = 0;
let totalDownloaded = 0; let totalDownloaded = 0;
let totalInserted = 0; let totalInserted = 0;
let totalUpdated = 0; let totalUpdated = 0;
const errors: { message: string }[] = [];
// Solo XMLs de vigentes (datos completos) // Solo XMLs de vigentes (datos completos)
try { try {
const { packageContents, totalCfdis } = await requestAndDownload( const { packageContents, totalCfdis } = await requestAndDownload(
ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'cfdi' ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'cfdi', isDaily
); );
totalFound += totalCfdis; totalFound += totalCfdis;
@@ -766,6 +842,8 @@ async function processDateRange(
} }
} catch (error: any) { } catch (error: any) {
console.error(`[SAT] Error en XMLs ${tipoCfdi}: ${error.message}`); 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) { if (!skipJobUpdate) {
@@ -782,6 +860,7 @@ async function processDateRange(
downloaded: totalDownloaded, downloaded: totalDownloaded,
inserted: totalInserted, inserted: totalInserted,
updated: totalUpdated, updated: totalUpdated,
errors,
}; };
} }
@@ -794,14 +873,17 @@ async function processMetadataRange(
jobId: string, jobId: string,
fechaInicio: Date, fechaInicio: Date,
fechaFin: Date, fechaFin: Date,
tipoCfdi: CfdiSyncType tipoCfdi: CfdiSyncType,
): Promise<{ inserted: number; updated: number }> { throwOnError = false,
isDaily = false
): Promise<{ inserted: number; updated: number; errors: { message: string }[] }> {
let totalInserted = 0; let totalInserted = 0;
let totalUpdated = 0; let totalUpdated = 0;
const errors: { message: string }[] = [];
try { try {
const { packageContents } = await requestAndDownload( const { packageContents } = await requestAndDownload(
ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'metadata' ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'metadata', isDaily
); );
for (const content of packageContents) { for (const content of packageContents) {
@@ -814,15 +896,20 @@ async function processMetadataRange(
} }
} catch (error: any) { } catch (error: any) {
console.error(`[SAT] Error en metadata ${tipoCfdi}: ${error.message}`); console.error(`[SAT] Error en metadata ${tipoCfdi}: ${error.message}`);
if (throwOnError) throw error;
errors.push({ message: error.message || `Error desconocido en metadata ${tipoCfdi}` });
} }
return { inserted: totalInserted, updated: totalUpdated }; return { inserted: totalInserted, updated: totalUpdated, errors };
} }
/** /**
* Determina el tamaño de bloque óptimo consultando metadata del rango completo. * 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 6 meses
* > 15,000 CFDIs → bloques de 2 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( async function determineChunkMonths(
ctx: SyncContext, ctx: SyncContext,
@@ -851,17 +938,20 @@ async function determineChunkMonths(
} }
const THRESHOLD = 15_000; const THRESHOLD = 15_000;
const probeChunks = generateChunks(fechaInicio, fechaFin, 12);
let totalCfdis = 0; let totalCfdis = 0;
for (const tipo of ['emitidos', 'recibidos'] as const) { for (const tipo of ['emitidos', 'recibidos'] as const) {
for (const { start, end } of probeChunks) {
try { try {
const { totalCfdis: count } = await requestAndDownload( const { totalCfdis: count } = await requestAndDownload(
ctx, jobId, fechaInicio, fechaFin, tipo, 'metadata' ctx, jobId, start, end, tipo, 'metadata'
); );
totalCfdis += count; totalCfdis += count;
console.log(`[SAT] Sondeo metadata ${tipo}: ${count} CFDIs en rango completo`); console.log(`[SAT] Sondeo metadata ${tipo} ${start.toISOString().slice(0, 10)}${end.toISOString().slice(0, 10)}: ${count} CFDIs`);
} catch (error: any) { } catch (error: any) {
console.log(`[SAT] No se pudo sondear metadata ${tipo}: ${error.message}`); console.log(`[SAT] No se pudo sondear metadata ${tipo} ${start.toISOString().slice(0, 10)}${end.toISOString().slice(0, 10)}: ${error.message}`);
}
} }
} }
@@ -898,16 +988,18 @@ async function processInitialSync(
customDateFrom?: Date, customDateFrom?: Date,
customDateTo?: Date customDateTo?: Date
): Promise<void> { ): Promise<void> {
const ahora = new Date(); // El SAT rechaza fechas futuras; por defecto usamos ayer como fecha final.
// Exactamente 6 años atrás desde hoy (mismo día del mes), no inicio de mes. // 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. // El SAT rechaza "mayor a 6 años" si usamos el día 1 del mes hace 6 años.
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), ahora.getDate()); const inicioHistorico = customDateFrom || new Date(fechaFin.getFullYear() - YEARS_TO_SYNC, fechaFin.getMonth(), fechaFin.getDate());
const fechaFin = customDateTo || ahora;
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs // Paso 1: Sondeo — determinar tamaño de bloque para XMLs
const chunkMonths = await determineChunkMonths(ctx, jobId, inicioHistorico, fechaFin); const chunkMonths = await determineChunkMonths(ctx, jobId, inicioHistorico, fechaFin);
const xmlChunks = generateChunks(inicioHistorico, fechaFin, chunkMonths); const xmlChunks = generateChunks(inicioHistorico, fechaFin, chunkMonths);
const metaChunks = generateChunks(inicioHistorico, fechaFin, 36); // bloques de 3 años 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)`); console.log(`[SAT] Sincronización: ${xmlChunks.length} bloques XML (${chunkMonths}m) + ${metaChunks.length} bloques metadata (36m)`);
@@ -1092,7 +1184,9 @@ async function processCustomRangeSync(
const INCREMENTAL_WINDOW_HOURS = 8; const INCREMENTAL_WINDOW_HOURS = 8;
async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise<void> { async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise<void> {
const ahora = new Date(); // Retrocedemos 2h respecto a ahora para evitar que el SAT vea una fecha final
// futura / demasiado reciente (rechazo "Fecha final invalida").
const ahora = new Date(Date.now() - 2 * 60 * 60 * 1000);
const desde = new Date(ahora.getTime() - INCREMENTAL_WINDOW_HOURS * 60 * 60 * 1000); const desde = new Date(ahora.getTime() - INCREMENTAL_WINDOW_HOURS * 60 * 60 * 1000);
let totalFound = 0; let totalFound = 0;
@@ -1103,25 +1197,17 @@ async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise<
console.log(`[SAT] Incremental: ${desde.toISOString()}${ahora.toISOString()} (${INCREMENTAL_WINDOW_HOURS}h)`); console.log(`[SAT] Incremental: ${desde.toISOString()}${ahora.toISOString()} (${INCREMENTAL_WINDOW_HOURS}h)`);
for (const tipo of ['emitidos', 'recibidos'] as const) { for (const tipo of ['emitidos', 'recibidos'] as const) {
try {
const result = await processDateRange(ctx, jobId, desde, ahora, tipo); const result = await processDateRange(ctx, jobId, desde, ahora, tipo);
totalFound += result.found; totalFound += result.found;
totalDownloaded += result.downloaded; totalDownloaded += result.downloaded;
totalInserted += result.inserted; totalInserted += result.inserted;
totalUpdated += result.updated; totalUpdated += result.updated;
} catch (error: any) {
console.error(`[SAT] Error incremental XMLs ${tipo}:`, error.message);
}
} }
for (const tipo of ['emitidos', 'recibidos'] as const) { for (const tipo of ['emitidos', 'recibidos'] as const) {
try {
const { inserted, updated } = await processMetadataRange(ctx, jobId, desde, ahora, tipo); const { inserted, updated } = await processMetadataRange(ctx, jobId, desde, ahora, tipo);
totalInserted += inserted; totalInserted += inserted;
totalUpdated += updated; totalUpdated += updated;
} catch (error: any) {
console.error(`[SAT] Error incremental metadata ${tipo}:`, error.message);
}
} }
await updateJobProgress(jobId, { await updateJobProgress(jobId, {
@@ -1132,51 +1218,310 @@ async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise<
}); });
} }
async function processDailySync(ctx: SyncContext, jobId: string): Promise<void> { /**
const ahora = new Date(); * Error usado para señalar que una etapa del sync diario excedió el tiempo
* de espera al SAT. Lleva el identificador de la etapa para poder retomar
* desde el mismo punto en los retries programados.
*/
class SatSyncTimeoutError extends Error {
constructor(
public readonly stageId: string,
message: string
) {
super(message);
this.name = 'SatSyncTimeoutError';
}
}
/**
* Rechazo transitorio del SAT (p. ej. "Error no controlado", típico de
* throttling cuando se lanzan muchas solicitudes en ráfaga). A diferencia de
* "solicitudes agotadas", SÍ vale la pena reintentarlo: se comporta como un
* timeout — se estaciona la etapa y los retries de 9 AM / 4 PM la retoman.
*/
class SatTransientError extends Error {
constructor(
public readonly stageId: string,
message: string
) {
super(message);
this.name = 'SatTransientError';
}
}
/**
* El daily terminó sus etapas XML y parte de las de metadata, pero uno o más
* chunks de metadata aún no tienen paquetes listos. No es un fallo del SAT:
* el requestId ya quedó persistido y los retries de 9 AM / 4 PM lo retoman.
* Se trata como "pending" (no consume el job entero) en vez de abortar.
*/
class SatMetadataPendingError extends Error {
constructor(
public readonly stageId: string,
public readonly pendingStages: string[],
message: string,
) {
super(message);
this.name = 'SatMetadataPendingError';
}
}
/**
* Detecta la respuesta del SAT "Se han agotado las solicitudes de por vida":
* se agotaron las solicitudes máximas para ese rango de fecha. No es
* transitorio — no vale la pena seguir intentando ni recrear la solicitud;
* hay que cancelarla y omitir ese rango.
*/
function isAgotadas(message?: string | null): boolean {
return !!message && message.toLowerCase().includes('agotad');
}
/**
* Genera un stageId a partir de la etiqueta de una solicitud SAT.
* Usado cuando requestAndDownload detecta timeout y no conoce el stage exacto.
*/
function stageIdForTimeout(label: string): string {
// label tiene forma "tipoCfdi/requestType" (ej. "emitidos/cfdi" o "recibidos/metadata")
const [tipo, requestType] = label.split('/');
if (requestType === 'cfdi') return `xml-${tipo}-7d`;
return `metadata-${tipo}-chunk`;
}
function isSundayInCDMX(now: Date = new Date()): boolean {
return new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Mexico_City',
weekday: 'short',
}).format(now) === 'Sun';
}
async function processDailySync(
ctx: SyncContext,
jobId: string,
resumeFromStage?: string
): Promise<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 inicioAño = new Date(ahora.getFullYear(), 0, 1);
const hace7Dias = new Date(ahora.getTime() - 7 * 24 * 60 * 60 * 1000); const hace7Dias = new Date(ahora.getTime() - 7 * 24 * 60 * 60 * 1000);
const ejecutarMetadataHistorica = isSundayInCDMX();
let totalFound = 0; let totalFound = 0;
let totalDownloaded = 0; let totalDownloaded = 0;
let totalInserted = 0; let totalInserted = 0;
let totalUpdated = 0; let totalUpdated = 0;
// Paso 1: XMLs de los últimos 7 días (CFDIs nuevos) interface DailyStage {
console.log(`[SAT] Daily: XMLs desde ${hace7Dias.toISOString().slice(0, 10)}${ahora.toISOString().slice(0, 10)}`); id: string;
label: string;
isMetadata: boolean;
run: () => Promise<void>;
}
for (const tipo of ['emitidos', 'recibidos'] as const) { const makeStage = (
try { id: string,
const result = await processDateRange(ctx, jobId, hace7Dias, ahora, tipo); 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; totalFound += result.found;
totalDownloaded += result.downloaded; totalDownloaded += result.downloaded;
totalInserted += result.inserted; totalInserted += result.inserted;
totalUpdated += result.updated; totalUpdated += result.updated;
} catch (error: any) { if (result.errors.length > 0) {
console.error(`[SAT] Error XMLs ${tipo} (7 días):`, error.message); 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 });
} }
}),
];
// Paso 2: Metadata del ciclo fiscal actual (enero → hoy) // La metadata histórica (desde inicio de año) consume muchas solicitudes al SAT
// Captura cancelaciones y cambios de status del año completo // y no cambia día a día. Solo la ejecutamos los domingos para reducir carga.
console.log(`[SAT] Daily: Metadata desde ${inicioAño.toISOString().slice(0, 10)}${ahora.toISOString().slice(0, 10)}`); if (ejecutarMetadataHistorica) {
const metaChunks = generateChunks(inicioAño, ahora, 3);
for (const tipo of ['emitidos', 'recibidos'] as const) { // Recorrer de más nuevo a más viejo: si un bloque antiguo da timeout, los
try { // recientes (más relevantes) ya quedaron procesados y no bloquean el avance.
const { inserted, updated } = await processMetadataRange(ctx, jobId, inicioAño, ahora, tipo); 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; totalInserted += inserted;
totalUpdated += updated; 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) { } catch (error: any) {
console.error(`[SAT] Error metadata ${tipo} (ciclo fiscal):`, error.message); const retryableErr = error instanceof SatSyncTimeoutError || error instanceof SatTransientError
? error
: (error.message?.includes('Timeout') ? new SatSyncTimeoutError(stage.id, error.message) : null);
// Errores transitorios (timeout, metadata aún no lista) se estacionan para
// reintento; no se pierden requestIds ya creados.
if (retryableErr && stage.isMetadata) {
console.warn(`[SAT Daily] Etapa ${stage.id} sin paquetes listos; se estaciona y se continúa con las demás.`);
pendingStages.push(stage.id);
continue;
}
if (retryableErr) {
throw retryableErr;
}
// Errores no transitorios (404 Error no controlado, etc.) no abortan el
// daily. Se registran para diagnóstico y se continúa con las demás etapas.
console.error(`[SAT Daily] Etapa ${stage.id} falló (no transitorio): ${error.message}`);
nonFatalErrors.push({ stage: stage.id, message: error.message });
continue;
} }
} }
const errorMessage = nonFatalErrors.length > 0
? JSON.stringify({ completedWithWarnings: true, errors: nonFatalErrors })
: undefined;
await updateJobProgress(jobId, { await updateJobProgress(jobId, {
cfdisFound: totalFound, cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded, cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted, cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated, 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 };
} }
/** /**
@@ -1211,7 +1556,7 @@ export async function startSync(
password: decryptedFiel.password, password: decryptedFiel.password,
}; };
const service = createSatService(fielData); const { service, proxyInfo } = createSatService(fielData);
const tenant = await prisma.tenant.findUnique({ const tenant = await prisma.tenant.findUnique({
where: { id: tenantId }, where: { id: tenantId },
@@ -1252,12 +1597,14 @@ export async function startSync(
dateTo: dateTo || now, dateTo: dateTo || now,
startedAt: now, startedAt: now,
isCustomRange, isCustomRange,
proxyUsed: proxyInfo ? `${proxyInfo.host}:${proxyInfo.port}` : null,
}, },
}); });
const ctx: SyncContext = { const ctx: SyncContext = {
fielData, fielData,
service, service,
proxyInfo,
rfc: decryptedFiel.rfc, rfc: decryptedFiel.rfc,
tenantId, tenantId,
databaseName: tenant.databaseName, databaseName: tenant.databaseName,
@@ -1288,25 +1635,50 @@ export async function startSync(
} catch (error: any) { } catch (error: any) {
console.error(`[SAT] Error en sincronización ${job.id}:`, error); console.error(`[SAT] Error en sincronización ${job.id}:`, error);
const isTimeout = error.message?.includes('Timeout'); const isMetadataPending = error instanceof SatMetadataPendingError;
const isSatTimeout = error instanceof SatSyncTimeoutError;
const isTransient = error instanceof SatTransientError;
const isTimeout = isSatTimeout || isMetadataPending || isTransient || error.message?.includes('Timeout');
const currentRetries = job.retryCount || 0; const currentRetries = job.retryCount || 0;
const policy = getRetryPolicy(job); const policy = getRetryPolicy(job);
const nextRetryNumber = currentRetries + 1; const nextRetryNumber = currentRetries + 1;
const nextRetry = isTimeout && nextRetryNumber <= policy.maxRetries // Para daily permitimos hasta MAX_DAILY_RETRY_ATTEMPTS intentos totales.
? computeNextRetryAt(job.startedAt!, nextRetryNumber, policy) // 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; : null;
if (nextRetry) { // Para timeouts/rechazos transitorios del daily, persistimos la etapa para retomar en retries programados.
const progressErrorMessage = isMetadataPending
? JSON.stringify({ stage: error.stageId, pendingStages: error.pendingStages, message: error.message })
: isSatTimeout || isTransient
? JSON.stringify({ stage: error.stageId, message: error.message })
: undefined;
if (hasAttemptsLeft) {
const retryLabel = nextRetry
? nextRetry.toLocaleString('es-MX')
: 'próxima ventana 9 AM / 4 PM CDMX';
await updateJobProgress(job.id, { await updateJobProgress(job.id, {
status: 'pending', status: 'pending',
errorMessage: `Timeout (intento ${nextRetryNumber}/${policy.maxRetries}). Reintento programado para ${nextRetry.toLocaleString('es-MX')}.`, errorMessage: progressErrorMessage ?? `Timeout (intento ${nextRetryNumber}/${maxAttempts}). Reintento programado para ${retryLabel}.`,
retryCount: nextRetryNumber, retryCount: nextRetryNumber,
nextRetryAt: nextRetry, nextRetryAt: nextRetry ?? null as any,
}); });
console.log(`[SAT] Job ${job.id} programado para reintento ${nextRetryNumber}/${policy.maxRetries} a las ${nextRetry.toLocaleString('es-MX')}`); console.log(`[SAT] Job ${job.id} programado para reintento ${nextRetryNumber}/${maxAttempts} (${retryLabel})`);
} else { } else {
// Sin reintentos restantes, error no-timeout, o policy con maxRetries=0 (incremental) // Sin reintentos restantes, error no-timeout, o policy con maxRetries=0 (incremental)
const finalMsg = isTimeout const finalMsg = isMetadataPending
? progressErrorMessage
: isSatTimeout
? progressErrorMessage
: isTimeout
? policy.maxRetries === 0 ? policy.maxRetries === 0
? 'Timeout en sync incremental — sin reintentos por política. Próximo cron incremental cubrirá el gap.' ? '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.' : 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.'
@@ -1384,7 +1756,7 @@ export async function retryTimedOutJobs(): Promise<void> {
continue; continue;
} }
const service = createSatService({ const { service, proxyInfo } = createSatService({
cerContent: decryptedFiel.cerContent, cerContent: decryptedFiel.cerContent,
keyContent: decryptedFiel.keyContent, keyContent: decryptedFiel.keyContent,
password: decryptedFiel.password, password: decryptedFiel.password,
@@ -1397,6 +1769,7 @@ export async function retryTimedOutJobs(): Promise<void> {
password: decryptedFiel.password, password: decryptedFiel.password,
}, },
service, service,
proxyInfo,
rfc: decryptedFiel.rfc, rfc: decryptedFiel.rfc,
tenantId: job.tenantId, tenantId: job.tenantId,
databaseName: job.tenant.databaseName, databaseName: job.tenant.databaseName,
@@ -1404,7 +1777,23 @@ export async function retryTimedOutJobs(): Promise<void> {
getPool: () => tenantDb.getPool(job.tenantId, job.tenant.databaseName), getPool: () => tenantDb.getPool(job.tenantId, job.tenant.databaseName),
}; };
await updateJobProgress(job.id, { status: 'running', errorMessage: null as any }); // B: resetear startedAt al inicio de ESTE intento para que el watchdog
// mida el intento actual (y no mate un retry legítimo por el startedAt
// original del job). La política de retries se ancla a createdAt.
await updateJobProgress(job.id, { status: 'running', errorMessage: null as any, startedAt: new Date(), proxyUsed: proxyInfo ? `${proxyInfo.host}:${proxyInfo.port}` : null });
// 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 // Re-ejecutar según tipo original
try { try {
@@ -1413,7 +1802,7 @@ export async function retryTimedOutJobs(): Promise<void> {
} else if (job.type === 'incremental') { } else if (job.type === 'incremental') {
await processIncrementalSync(ctx, job.id); await processIncrementalSync(ctx, job.id);
} else { } else {
await processDailySync(ctx, job.id); await processDailySync(ctx, job.id, resumeFromStage);
} }
await updateJobProgress(job.id, { await updateJobProgress(job.id, {
@@ -1426,24 +1815,44 @@ export async function retryTimedOutJobs(): Promise<void> {
} catch (retryError: any) { } catch (retryError: any) {
console.error(`[SAT Retry] Job ${job.id} falló de nuevo:`, retryError.message); console.error(`[SAT Retry] Job ${job.id} falló de nuevo:`, retryError.message);
const isTimeout = retryError.message?.includes('Timeout'); const isMetadataPending = retryError instanceof SatMetadataPendingError;
const isSatTimeout = retryError instanceof SatSyncTimeoutError;
const isTransient = retryError instanceof SatTransientError;
const isTimeout = isSatTimeout || isMetadataPending || isTransient || retryError.message?.includes('Timeout');
const policy = getRetryPolicy(job); const policy = getRetryPolicy(job);
const nextRetryNumber = job.retryCount + 1; const nextRetryNumber = job.retryCount + 1;
const nextRetry = isTimeout && nextRetryNumber <= policy.maxRetries // Para daily permitimos hasta MAX_DAILY_RETRY_ATTEMPTS intentos totales.
? computeNextRetryAt(job.startedAt!, nextRetryNumber, policy) 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; : null;
if (nextRetry) { const progressErrorMessage = isMetadataPending
? JSON.stringify({ stage: retryError.stageId, pendingStages: retryError.pendingStages, message: retryError.message })
: isSatTimeout || isTransient
? JSON.stringify({ stage: retryError.stageId, message: retryError.message })
: undefined;
if (hasAttemptsLeft) {
const retryLabel = nextRetry
? nextRetry.toLocaleString('es-MX')
: 'próxima ventana 9 AM / 4 PM CDMX';
await updateJobProgress(job.id, { await updateJobProgress(job.id, {
status: 'pending', status: 'pending',
errorMessage: `Timeout (intento ${nextRetryNumber}/${policy.maxRetries}). Reintento programado para ${nextRetry.toLocaleString('es-MX')}.`, errorMessage: progressErrorMessage ?? `Timeout (intento ${nextRetryNumber}/${maxAttempts}). Reintento programado para ${retryLabel}.`,
retryCount: nextRetryNumber, retryCount: nextRetryNumber,
nextRetryAt: nextRetry, nextRetryAt: nextRetry ?? null as any,
}); });
} else { } else {
await updateJobProgress(job.id, { await updateJobProgress(job.id, {
status: 'failed', status: 'failed',
errorMessage: isTimeout errorMessage: isMetadataPending
? progressErrorMessage
: isSatTimeout
? progressErrorMessage
: isTimeout
? 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.' ? 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.'
: retryError.message, : retryError.message,
completedAt: new Date(), completedAt: new Date(),
@@ -1461,6 +1870,154 @@ export async function retryTimedOutJobs(): Promise<void> {
} }
} }
/**
* 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, proxyInfo } = createSatService({
cerContent: decryptedFiel.cerContent,
keyContent: decryptedFiel.keyContent,
password: decryptedFiel.password,
});
const ctx: SyncContext = {
fielData: {
cerContent: decryptedFiel.cerContent,
keyContent: decryptedFiel.keyContent,
password: decryptedFiel.password,
},
service,
proxyInfo,
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 * Obtiene el estado actual de sincronización de un tenant
*/ */

View File

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

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell'; import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui'; import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { useEventos, useCreateEvento, useUpdateEvento, useDeleteEvento } from '@/lib/hooks/use-calendario'; import { useEventos, useCreateEvento, useUpdateEvento, useDeleteEvento } from '@/lib/hooks/use-calendario';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { import {
@@ -42,6 +42,8 @@ interface RecordatorioForm {
fechaLimite: string; fechaLimite: string;
notas: string; notas: string;
privado: boolean; privado: boolean;
recurrencia: 'unica' | 'mensual' | 'bimestral' | 'trimestral' | 'anual';
fechaFin: string;
} }
const emptyForm: RecordatorioForm = { const emptyForm: RecordatorioForm = {
@@ -50,6 +52,8 @@ const emptyForm: RecordatorioForm = {
fechaLimite: '', fechaLimite: '',
notas: '', notas: '',
privado: false, privado: false,
recurrencia: 'unica',
fechaFin: '',
}; };
export default function CalendarioPage() { export default function CalendarioPage() {
@@ -100,6 +104,8 @@ export default function CalendarioPage() {
fechaLimite: evento.fechaLimite, fechaLimite: evento.fechaLimite,
notas: evento.notas || '', notas: evento.notas || '',
privado: (evento as any).privado ?? false, privado: (evento as any).privado ?? false,
recurrencia: (evento.recurrencia as RecordatorioForm['recurrencia']) || 'unica',
fechaFin: '', // La fecha fin no se edita desde el calendario; se mantiene la original
}); });
setShowForm(true); setShowForm(true);
}; };
@@ -113,15 +119,19 @@ export default function CalendarioPage() {
data: { titulo: form.titulo, descripcion: form.descripcion, fechaLimite: form.fechaLimite, notas: form.notas, privado: form.privado } as any, data: { titulo: form.titulo, descripcion: form.descripcion, fechaLimite: form.fechaLimite, notas: form.notas, privado: form.privado } as any,
}); });
} else { } else {
await createEvento.mutateAsync({ const payload: any = {
titulo: form.titulo, titulo: form.titulo,
descripcion: form.descripcion, descripcion: form.descripcion,
tipo: 'custom', tipo: 'custom',
fechaLimite: form.fechaLimite, fechaLimite: form.fechaLimite,
recurrencia: 'unica', recurrencia: form.recurrencia,
notas: form.notas, notas: form.notas,
privado: form.privado, privado: form.privado,
} as any); };
if (form.recurrencia !== 'unica' && form.fechaFin) {
payload.fechaFin = form.fechaFin;
}
await createEvento.mutateAsync(payload);
} }
setShowForm(false); setShowForm(false);
setForm(emptyForm); setForm(emptyForm);
@@ -131,10 +141,15 @@ export default function CalendarioPage() {
} }
}; };
const handleDelete = async (id: number) => { const handleDelete = async (evento: EventoFiscal) => {
if (!confirm('¿Eliminar este recordatorio?')) return; if (!evento.id) return;
const esPeriodico = evento.recurrencia && evento.recurrencia !== 'unica';
const mensaje = esPeriodico
? 'Esto cancelará todas las ocurrencias futuras de esta serie. ¿Continuar?'
: '¿Eliminar este recordatorio?';
if (!confirm(mensaje)) return;
try { try {
await deleteEvento.mutateAsync(id); await deleteEvento.mutateAsync(evento.id);
} catch { } catch {
alert('Error al eliminar'); alert('Error al eliminar');
} }
@@ -206,6 +221,44 @@ export default function CalendarioPage() {
/> />
</div> </div>
</div> </div>
{!editingId && (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="recurrencia">Recurrencia</Label>
<Select
value={form.recurrencia}
onValueChange={(v) => setForm({ ...form, recurrencia: v as RecordatorioForm['recurrencia'] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unica">Única</SelectItem>
<SelectItem value="mensual">Mensual</SelectItem>
<SelectItem value="bimestral">Bimestral</SelectItem>
<SelectItem value="trimestral">Trimestral</SelectItem>
<SelectItem value="anual">Anual</SelectItem>
</SelectContent>
</Select>
</div>
{form.recurrencia !== 'unica' && (
<div className="space-y-2">
<Label htmlFor="fechaFin">Fecha fin (opcional)</Label>
<Input
id="fechaFin"
type="date"
value={form.fechaFin}
onChange={e => setForm({ ...form, fechaFin: e.target.value })}
/>
</div>
)}
</div>
)}
{editingId && form.recurrencia !== 'unica' && (
<div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
Este es un recordatorio periódico. Los cambios se aplicarán a todas las ocurrencias futuras.
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="descripcion">Descripción (opcional)</Label> <Label htmlFor="descripcion">Descripción (opcional)</Label>
<Input <Input
@@ -417,7 +470,7 @@ export default function CalendarioPage() {
</Button> </Button>
<Button <Button
variant="ghost" size="icon" className="h-7 w-7 text-destructive" variant="ghost" size="icon" className="h-7 w-7 text-destructive"
onClick={() => evento.id && handleDelete(evento.id)} onClick={() => handleDelete(evento)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>

View File

@@ -322,41 +322,65 @@ export default function CfdiPage() {
const [activeTab, setActiveTab] = useState<'cfdis' | 'conceptos'>('cfdis'); const [activeTab, setActiveTab] = useState<'cfdis' | 'conceptos'>('cfdis');
// Filtros locales de la pestaña Conceptos (no compartidos con CFDIs). // Filtros locales de la pestaña Conceptos (no compartidos con CFDIs).
// Popovers en headers UUID, Clave, Descripción + ordenamiento por importe. // Popovers en headers UUID, Clave, Descripción + ordenamiento por importe.
const [conceptosFilters, setConceptosFilters] = useState<{ // Valores que el usuario escribe en los inputs de los popovers
const [conceptosDraftFilters, setConceptosDraftFilters] = useState<{
uuidLike: string; uuidLike: string;
claveProdServ: string; claveProdServ: string;
descripcionConcepto: string; descripcionConcepto: string;
noIdentificacion: string; noIdentificacion: string;
}>({ uuidLike: '', claveProdServ: '', descripcionConcepto: '', noIdentificacion: '' });
// Filtros realmente aplicados a la query (solo cambian al dar Aplicar o Limpiar)
const [appliedConceptosFilters, setAppliedConceptosFilters] = useState<{
uuidLike: string;
claveProdServ: string;
descripcionConcepto: string;
noIdentificacion: string;
}>({ uuidLike: '', claveProdServ: '', descripcionConcepto: '', noIdentificacion: '' });
const [conceptosSort, setConceptosSort] = useState<{
orderBy?: 'fecha' | 'importe'; orderBy?: 'fecha' | 'importe';
orderDir?: 'asc' | 'desc'; orderDir?: 'asc' | 'desc';
}>({ uuidLike: '', claveProdServ: '', descripcionConcepto: '', noIdentificacion: '' }); }>({});
const [conceptosOpenFilter, setConceptosOpenFilter] = useState<'uuid' | 'clave' | 'descripcion' | 'noIdentificacion' | null>(null); const [conceptosOpenFilter, setConceptosOpenFilter] = useState<'uuid' | 'clave' | 'descripcion' | 'noIdentificacion' | null>(null);
const conceptosQuery = useQuery({ const conceptosQuery = useQuery({
queryKey: ['cfdi-conceptos', filters, selectedContribuyenteId, conceptosFilters], queryKey: ['cfdi-conceptos', filters, selectedContribuyenteId, appliedConceptosFilters, conceptosSort],
queryFn: () => getConceptosList({ queryFn: () => getConceptosList({
...filters, ...filters,
contribuyenteId: selectedContribuyenteId || undefined, contribuyenteId: selectedContribuyenteId || undefined,
uuidLike: conceptosFilters.uuidLike || undefined, uuidLike: appliedConceptosFilters.uuidLike || undefined,
claveProdServ: conceptosFilters.claveProdServ || undefined, claveProdServ: appliedConceptosFilters.claveProdServ || undefined,
descripcionConcepto: conceptosFilters.descripcionConcepto || undefined, descripcionConcepto: appliedConceptosFilters.descripcionConcepto || undefined,
noIdentificacion: conceptosFilters.noIdentificacion || undefined, noIdentificacion: appliedConceptosFilters.noIdentificacion || undefined,
orderBy: conceptosFilters.orderBy, orderBy: conceptosSort.orderBy,
orderDir: conceptosFilters.orderDir, orderDir: conceptosSort.orderDir,
}), }),
enabled: activeTab === 'conceptos', enabled: activeTab === 'conceptos',
}); });
const toggleImporteSort = () => { const toggleImporteSort = () => {
setConceptosFilters(prev => { setConceptosSort(prev => {
// null → asc → desc → null (o ciclo simple asc ↔ desc si prefieres)
const isImporte = prev.orderBy === 'importe'; const isImporte = prev.orderBy === 'importe';
if (!isImporte) return { ...prev, orderBy: 'importe', orderDir: 'desc' }; if (!isImporte) return { orderBy: 'importe', orderDir: 'desc' };
if (prev.orderDir === 'desc') return { ...prev, orderBy: 'importe', orderDir: 'asc' }; if (prev.orderDir === 'desc') return { orderBy: 'importe', orderDir: 'asc' };
return { ...prev, orderBy: undefined, orderDir: undefined }; return {};
}); });
setFilters(f => ({ ...f, page: 1 })); setFilters(f => ({ ...f, page: 1 }));
}; };
const applyConceptosFilters = () => {
setAppliedConceptosFilters({ ...conceptosDraftFilters });
setFilters(f => ({ ...f, page: 1 }));
setConceptosOpenFilter(null);
};
const clearConceptosFilter = (field: keyof typeof conceptosDraftFilters) => {
const newDraft = { ...conceptosDraftFilters, [field]: '' };
setConceptosDraftFilters(newDraft);
setAppliedConceptosFilters(newDraft);
setFilters(f => ({ ...f, page: 1 }));
setConceptosOpenFilter(null);
};
const createCfdi = useCreateCfdi(); const createCfdi = useCreateCfdi();
const deleteCfdi = useDeleteCfdi(); const deleteCfdi = useDeleteCfdi();
@@ -480,12 +504,12 @@ export default function CfdiPage() {
const fullResponse = await getConceptosList({ const fullResponse = await getConceptosList({
...filters, ...filters,
contribuyenteId: selectedContribuyenteId || undefined, contribuyenteId: selectedContribuyenteId || undefined,
uuidLike: conceptosFilters.uuidLike || undefined, uuidLike: appliedConceptosFilters.uuidLike || undefined,
claveProdServ: conceptosFilters.claveProdServ || undefined, claveProdServ: appliedConceptosFilters.claveProdServ || undefined,
descripcionConcepto: conceptosFilters.descripcionConcepto || undefined, descripcionConcepto: appliedConceptosFilters.descripcionConcepto || undefined,
noIdentificacion: conceptosFilters.noIdentificacion || undefined, noIdentificacion: appliedConceptosFilters.noIdentificacion || undefined,
orderBy: conceptosFilters.orderBy, orderBy: conceptosSort.orderBy,
orderDir: conceptosFilters.orderDir, orderDir: conceptosSort.orderDir,
page: 1, page: 1,
limit: EXPORT_MAX, limit: EXPORT_MAX,
}); });
@@ -1598,17 +1622,17 @@ export default function CfdiPage() {
UUID UUID
<Popover open={conceptosOpenFilter === 'uuid'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'uuid' : null)}> <Popover open={conceptosOpenFilter === 'uuid'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'uuid' : null)}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.uuidLike ? 'text-primary' : ''}`}> <button className={`p-1 rounded hover:bg-muted ${appliedConceptosFilters.uuidLike ? 'text-primary' : ''}`}>
<Filter className="h-3.5 w-3.5" /> <Filter className="h-3.5 w-3.5" />
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-64" align="start"> <PopoverContent className="w-64" align="start">
<div className="space-y-3"> <div className="space-y-3">
<h4 className="font-medium text-sm">Filtrar por UUID</h4> <h4 className="font-medium text-sm">Filtrar por UUID</h4>
<Input className="h-8 text-sm font-mono" placeholder="Fragmento del UUID..." value={conceptosFilters.uuidLike} onChange={(e) => setConceptosFilters({ ...conceptosFilters, uuidLike: e.target.value })} /> <Input className="h-8 text-sm font-mono" placeholder="Fragmento del UUID..." value={conceptosDraftFilters.uuidLike} onChange={(e) => setConceptosDraftFilters({ ...conceptosDraftFilters, uuidLike: e.target.value })} />
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button> <Button size="sm" className="flex-1" onClick={applyConceptosFilters}>Aplicar</Button>
{conceptosFilters.uuidLike && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, uuidLike: '' }); setFilters({ ...filters, page: 1 }); }}>Limpiar</Button>} {appliedConceptosFilters.uuidLike && <Button size="sm" variant="outline" onClick={() => clearConceptosFilter('uuidLike')}>Limpiar</Button>}
</div> </div>
</div> </div>
</PopoverContent> </PopoverContent>
@@ -1620,17 +1644,17 @@ export default function CfdiPage() {
Clave Clave
<Popover open={conceptosOpenFilter === 'clave'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'clave' : null)}> <Popover open={conceptosOpenFilter === 'clave'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'clave' : null)}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.claveProdServ ? 'text-primary' : ''}`}> <button className={`p-1 rounded hover:bg-muted ${appliedConceptosFilters.claveProdServ ? 'text-primary' : ''}`}>
<Filter className="h-3.5 w-3.5" /> <Filter className="h-3.5 w-3.5" />
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-64" align="start"> <PopoverContent className="w-64" align="start">
<div className="space-y-3"> <div className="space-y-3">
<h4 className="font-medium text-sm">Filtrar por Clave SAT</h4> <h4 className="font-medium text-sm">Filtrar por Clave SAT</h4>
<Input className="h-8 text-sm font-mono" placeholder="Ej: 81112502" value={conceptosFilters.claveProdServ} onChange={(e) => setConceptosFilters({ ...conceptosFilters, claveProdServ: e.target.value })} /> <Input className="h-8 text-sm font-mono" placeholder="Ej: 81112502" value={conceptosDraftFilters.claveProdServ} onChange={(e) => setConceptosDraftFilters({ ...conceptosDraftFilters, claveProdServ: e.target.value })} />
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button> <Button size="sm" className="flex-1" onClick={applyConceptosFilters}>Aplicar</Button>
{conceptosFilters.claveProdServ && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, claveProdServ: '' }); setFilters({ ...filters, page: 1 }); }}>Limpiar</Button>} {appliedConceptosFilters.claveProdServ && <Button size="sm" variant="outline" onClick={() => clearConceptosFilter('claveProdServ')}>Limpiar</Button>}
</div> </div>
</div> </div>
</PopoverContent> </PopoverContent>
@@ -1642,17 +1666,17 @@ export default function CfdiPage() {
Descripción Descripción
<Popover open={conceptosOpenFilter === 'descripcion'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'descripcion' : null)}> <Popover open={conceptosOpenFilter === 'descripcion'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'descripcion' : null)}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.descripcionConcepto ? 'text-primary' : ''}`}> <button className={`p-1 rounded hover:bg-muted ${appliedConceptosFilters.descripcionConcepto ? 'text-primary' : ''}`}>
<Filter className="h-3.5 w-3.5" /> <Filter className="h-3.5 w-3.5" />
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-72" align="start"> <PopoverContent className="w-72" align="start">
<div className="space-y-3"> <div className="space-y-3">
<h4 className="font-medium text-sm">Filtrar por descripción</h4> <h4 className="font-medium text-sm">Filtrar por descripción</h4>
<Input className="h-8 text-sm" placeholder="Texto contenido en la descripción..." value={conceptosFilters.descripcionConcepto} onChange={(e) => setConceptosFilters({ ...conceptosFilters, descripcionConcepto: e.target.value })} /> <Input className="h-8 text-sm" placeholder="Texto contenido en la descripción..." value={conceptosDraftFilters.descripcionConcepto} onChange={(e) => setConceptosDraftFilters({ ...conceptosDraftFilters, descripcionConcepto: e.target.value })} />
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button> <Button size="sm" className="flex-1" onClick={applyConceptosFilters}>Aplicar</Button>
{conceptosFilters.descripcionConcepto && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, descripcionConcepto: '' }); setFilters({ ...filters, page: 1 }); }}>Limpiar</Button>} {appliedConceptosFilters.descripcionConcepto && <Button size="sm" variant="outline" onClick={() => clearConceptosFilter('descripcionConcepto')}>Limpiar</Button>}
</div> </div>
</div> </div>
</PopoverContent> </PopoverContent>
@@ -1664,17 +1688,17 @@ export default function CfdiPage() {
No. Identificación No. Identificación
<Popover open={conceptosOpenFilter === 'noIdentificacion'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'noIdentificacion' : null)}> <Popover open={conceptosOpenFilter === 'noIdentificacion'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'noIdentificacion' : null)}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.noIdentificacion ? 'text-primary' : ''}`}> <button className={`p-1 rounded hover:bg-muted ${appliedConceptosFilters.noIdentificacion ? 'text-primary' : ''}`}>
<Filter className="h-3.5 w-3.5" /> <Filter className="h-3.5 w-3.5" />
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-64" align="start"> <PopoverContent className="w-64" align="start">
<div className="space-y-3"> <div className="space-y-3">
<h4 className="font-medium text-sm">Filtrar por No. Identificación</h4> <h4 className="font-medium text-sm">Filtrar por No. Identificación</h4>
<Input className="h-8 text-sm font-mono" placeholder="Ej: PROD-001" value={conceptosFilters.noIdentificacion} onChange={(e) => setConceptosFilters({ ...conceptosFilters, noIdentificacion: e.target.value })} /> <Input className="h-8 text-sm font-mono" placeholder="Ej: PROD-001" value={conceptosDraftFilters.noIdentificacion} onChange={(e) => setConceptosDraftFilters({ ...conceptosDraftFilters, noIdentificacion: e.target.value })} />
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button> <Button size="sm" className="flex-1" onClick={applyConceptosFilters}>Aplicar</Button>
{conceptosFilters.noIdentificacion && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, noIdentificacion: '' }); setFilters({ ...filters, page: 1 }); }}>Limpiar</Button>} {appliedConceptosFilters.noIdentificacion && <Button size="sm" variant="outline" onClick={() => clearConceptosFilter('noIdentificacion')}>Limpiar</Button>}
</div> </div>
</div> </div>
</PopoverContent> </PopoverContent>
@@ -1694,8 +1718,8 @@ export default function CfdiPage() {
title="Ordenar por importe" title="Ordenar por importe"
> >
Importe Importe
{conceptosFilters.orderBy === 'importe' ? ( {conceptosSort.orderBy === 'importe' ? (
<span className="text-primary">{conceptosFilters.orderDir === 'asc' ? '▲' : '▼'}</span> <span className="text-primary">{conceptosSort.orderDir === 'asc' ? '▲' : '▼'}</span>
) : ( ) : (
<span className="text-muted-foreground/40"></span> <span className="text-muted-foreground/40"></span>
)} )}

View File

@@ -161,6 +161,7 @@ export default function CsdConfigPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [creatingOrg, setCreatingOrg] = useState(false);
const [cerFile, setCerFile] = useState<string>(''); const [cerFile, setCerFile] = useState<string>('');
const [keyFile, setKeyFile] = useState<string>(''); const [keyFile, setKeyFile] = useState<string>('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -178,16 +179,28 @@ export default function CsdConfigPage() {
}; };
const handleCreateOrg = async () => { const handleCreateOrg = async () => {
if (creatingOrg) return;
setCreatingOrg(true);
setMessage(null);
try { try {
const cfg = { timeout: 60000 };
if (selectedContribuyenteId) { if (selectedContribuyenteId) {
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/org`); await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/org`, undefined, cfg);
} else { } else {
await apiClient.post('/facturacion/org'); await apiClient.post('/facturacion/org', undefined, cfg);
} }
queryClient.invalidateQueries({ queryKey: ['facturapi-org-contrib'] }); queryClient.invalidateQueries({ queryKey: ['facturapi-org-contrib'] });
setMessage({ type: 'success', text: 'Organización creada en Facturapi' }); setMessage({ type: 'success', text: 'Organización creada en Facturapi' });
} catch (err: any) { } 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() {
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No hay organización configurada para este tenant. No hay organización configurada para este tenant.
</p> </p>
<Button onClick={handleCreateOrg}>Crear Organización</Button> <Button onClick={handleCreateOrg} disabled={creatingOrg}>
{creatingOrg ? 'Creando… (puede tardar unos segundos)' : 'Crear Organización'}
</Button>
</div> </div>
) : ( ) : (
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">

View File

@@ -534,9 +534,9 @@ export default function PlanesDespachoPage() {
</CardHeader> </CardHeader>
<CardContent className="flex flex-col flex-1 gap-4"> <CardContent className="flex flex-col flex-1 gap-4">
<div className="text-center"> <div className="text-center">
<div className="text-3xl font-bold">$25,850</div> <div className="text-3xl font-bold">$30,850</div>
<p className="text-sm text-muted-foreground">por año (IVA incluido)</p> <p className="text-sm text-muted-foreground">por año (IVA incluido)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC adicional sobre 100</p> <p className="text-xs text-muted-foreground mt-1">+ $25/mes por cada RFC adicional sobre 100</p>
</div> </div>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div> <div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div>
@@ -563,9 +563,9 @@ export default function PlanesDespachoPage() {
</CardHeader> </CardHeader>
<CardContent className="flex flex-col flex-1 gap-4"> <CardContent className="flex flex-col flex-1 gap-4">
<div className="text-center"> <div className="text-center">
<div className="text-3xl font-bold">$43,000</div> <div className="text-3xl font-bold">$68,850</div>
<p className="text-sm text-muted-foreground">por año (IVA incluido)</p> <p className="text-sm text-muted-foreground">por año (IVA incluido)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC adicional sobre 100</p> <p className="text-xs text-muted-foreground mt-1">+ $60/mes por cada RFC adicional sobre 100</p>
</div> </div>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div> <div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div>

View File

@@ -305,6 +305,7 @@ export default function PreciosSuscripcionPage() {
</p> </p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
<strong>SAT Inc</strong> habilita 3 syncs SAT extra al día (11:00, 15:00, 19:00) además del daily de las 03:00. <strong>SAT Inc</strong> habilita 3 syncs SAT extra al día (11:00, 15:00, 19:00) además del daily de las 03:00.
Disponible en <strong>Business Cloud</strong> y <strong>Mi Empresa Plus</strong>; no incluido en <strong>Business Control</strong>.
Ventana de 8h por sync, deduplicado por UUID. Latencia típica de un CFDI ~1-2h en horario laboral Ventana de 8h por sync, deduplicado por UUID. Latencia típica de un CFDI ~1-2h en horario laboral
vs ~24h con solo el daily. vs ~24h con solo el daily.
</p> </p>

View File

@@ -57,6 +57,9 @@ export default function ContribuyentesPage() {
...form, ...form,
supervisorUserId: assignSelf ? user?.id : undefined, supervisorUserId: assignSelf ? user?.id : undefined,
}); });
if (created.reactivated) {
alert(`El contribuyente ${form.rfc.toUpperCase()} fue reactivado. Se recuperó su historial (CFDIs, FIEL, tareas, etc.).`);
}
// Overage Business Cloud: si el 4º+ RFC disparó un nuevo addon, abre // Overage Business Cloud: si el 4º+ RFC disparó un nuevo addon, abre
// MercadoPago para autorizar el cobro recurrente mensual de $45/RFC. // MercadoPago para autorizar el cobro recurrente mensual de $45/RFC.
if (created.overage?.action === 'created' && created.overage.paymentUrl) { if (created.overage?.action === 'created' && created.overage.paymentUrl) {
@@ -71,7 +74,7 @@ export default function ContribuyentesPage() {
}; };
const handleDeactivate = async (id: string, rfc: string) => { const handleDeactivate = async (id: string, rfc: string) => {
if (!confirm(`¿Desactivar contribuyente ${rfc}?`)) return; if (!confirm(`¿Desactivar contribuyente ${rfc}?\n\nPuedes volver a activarlo más adelante agregando el mismo RFC. Se conservarán sus CFDIs y historial.`)) return;
try { try {
const result = await deactivateMut.mutateAsync(id); const result = await deactivateMut.mutateAsync(id);
if (result.overage?.action === 'cancelled') { if (result.overage?.action === 'cancelled') {

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Card, CardContent } from '@horux/shared-ui'; import { Card, CardContent } from '@horux/shared-ui';
@@ -10,7 +11,7 @@ import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useContribuyenteStore } from '@/stores/contribuyente-store'; import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-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 { interface Asignado {
contribuyenteId: string; 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']); const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
export default function MisAsignadosPage() { export default function MisAsignadosPage() {
const [filtroCliente, setFiltroCliente] = useState('');
const [filtroCartera, setFiltroCartera] = useState('');
const role = useAuthStore(s => s.user?.role); const role = useAuthStore(s => s.user?.role);
const platformRoles = useAuthStore(s => s.user?.platformRoles); const platformRoles = useAuthStore(s => s.user?.platformRoles);
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false; const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
@@ -64,6 +68,19 @@ export default function MisAsignadosPage() {
const items = data ?? []; 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 ( return (
<> <>
<Header title="Despacho — Mis asignados"><PeriodoSelector /></Header> <Header title="Despacho — Mis asignados"><PeriodoSelector /></Header>
@@ -84,6 +101,39 @@ export default function MisAsignadosPage() {
) : ( ) : (
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b bg-muted/30">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
value={filtroCliente}
onChange={(e) => 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"
/>
</div>
<div className="relative sm:w-64">
<FolderOpen className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<select
value={filtroCartera}
onChange={(e) => setFiltroCartera(e.target.value)}
className="w-full appearance-none rounded-md border border-input bg-background pl-9 pr-8 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Todas las carteras</option>
<option value="__sin_cartera__">Sin cartera</option>
{carterasUnicas.filter(c => c !== 'Sin cartera').map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
</div>
</div>
{itemsFiltrados.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">
No hay resultados para los filtros seleccionados.
</div>
) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="border-b bg-muted/50"> <thead className="border-b bg-muted/50">
<tr> <tr>
@@ -102,7 +152,7 @@ export default function MisAsignadosPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{items.map(it => { {itemsFiltrados.map(it => {
const total = const total =
it.obligacionesPendientes + it.obligacionesAtrasadas + it.obligacionesCompletadas + it.obligacionesPendientes + it.obligacionesAtrasadas + it.obligacionesCompletadas +
it.tareasPendientes + it.tareasAtrasadas + it.tareasCompletadas; it.tareasPendientes + it.tareasAtrasadas + it.tareasCompletadas;
@@ -193,6 +243,7 @@ export default function MisAsignadosPage() {
})} })}
</tbody> </tbody>
</table> </table>
)}
</CardContent> </CardContent>
</Card> </Card>
)} )}

View File

@@ -9,7 +9,7 @@ import { addClienteAcceso } from '@/lib/api/contribuyentes';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { Users, UserPlus, Trash2, Shield, Eye, Calculator, UserCheck, UserCog, Building2, FolderOpen, KeyRound } from 'lucide-react'; import { Users, UserPlus, Trash2, Shield, Eye, Calculator, UserCheck, UserCog, Building2, FolderOpen, KeyRound, Pencil } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui';
import Link from 'next/link'; import Link from 'next/link';
import { cn } from '@horux/shared-ui'; import { cn } from '@horux/shared-ui';
@@ -79,7 +79,7 @@ export default function UsuariosPage() {
const isDespacho = isDespachoTenant(currentUser?.tenantRfc); const isDespacho = isDespachoTenant(currentUser?.tenantRfc);
const inviteRoles = isDespacho const inviteRoles = isDespacho
? (currentUser?.role === 'supervisor' ? (currentUser?.role === 'supervisor'
? despachoInviteRoles.filter(r => r.value === 'cliente') ? despachoInviteRoles.filter(r => r.value === 'cliente' || r.value === 'auxiliar')
: despachoInviteRoles) : despachoInviteRoles)
: legacyInviteRoles; : legacyInviteRoles;
const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor'; const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor';
@@ -106,6 +106,13 @@ export default function UsuariosPage() {
const [currentSupervisorNombre, setCurrentSupervisorNombre] = useState<string>(''); const [currentSupervisorNombre, setCurrentSupervisorNombre] = useState<string>('');
// Edit user modal (owner only)
const [editingUser, setEditingUser] = useState<{ id: string; nombre: string; role: Role; email: string } | null>(null);
const [editForm, setEditForm] = useState<{ nombre: string; role: Role }>({ nombre: '', role: 'auxiliar' });
const [savingUser, setSavingUser] = useState(false);
const isOwner = currentUser?.role === 'owner';
const openEditSupervisor = async (userId: string, nombre: string) => { const openEditSupervisor = async (userId: string, nombre: string) => {
try { try {
const res = await apiClient.get<{ supervisorUserId: string | null; supervisorNombre: string | null }>(`/usuarios/${userId}/supervisor`); const res = await apiClient.get<{ supervisorUserId: string | null; supervisorNombre: string | null }>(`/usuarios/${userId}/supervisor`);
@@ -132,6 +139,24 @@ export default function UsuariosPage() {
} }
}; };
const openEditUser = (usuario: { id: string; nombre: string; role: Role; email: string }) => {
setEditingUser(usuario);
setEditForm({ nombre: usuario.nombre, role: usuario.role });
};
const handleSaveUser = async () => {
if (!editingUser) return;
setSavingUser(true);
try {
await updateUsuario.mutateAsync({ id: editingUser.id, data: editForm });
setEditingUser(null);
} catch (error: any) {
alert(error.response?.data?.message || 'Error al guardar usuario');
} finally {
setSavingUser(false);
}
};
const openEditAccesos = async (userId: string, nombre: string) => { const openEditAccesos = async (userId: string, nombre: string) => {
try { try {
const res = await apiClient.get<{ data: string[] }>(`/usuarios/${userId}/accesos`); const res = await apiClient.get<{ data: string[] }>(`/usuarios/${userId}/accesos`);
@@ -270,7 +295,16 @@ export default function UsuariosPage() {
<Label htmlFor="role">Rol</Label> <Label htmlFor="role">Rol</Label>
<Select <Select
value={inviteForm.role} value={inviteForm.role}
onValueChange={(v) => { setInviteForm({ ...inviteForm, role: v as UserInvite['role'], supervisorUserId: undefined }); if (v !== 'cliente') setSelectedRfcIds([]); }} onValueChange={(v) => {
const isAuxiliar = v === 'auxiliar';
const isSupervisor = currentUser?.role === 'supervisor';
setInviteForm({
...inviteForm,
role: v as UserInvite['role'],
supervisorUserId: isAuxiliar && isSupervisor ? currentUser?.id : undefined,
});
if (v !== 'cliente') setSelectedRfcIds([]);
}}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -393,6 +427,18 @@ export default function UsuariosPage() {
<RoleIcon className="h-4 w-4" /> <RoleIcon className="h-4 w-4" />
<span className="text-sm">{roleInfo.label}</span> <span className="text-sm">{roleInfo.label}</span>
</div> </div>
{isOwner && (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => openEditUser(usuario)}
title="Editar nombre y rol"
>
<Pencil className="h-4 w-4 mr-1" /> Editar
</Button>
</div>
)}
{isAdmin && !isCurrentUser && ( {isAdmin && !isCurrentUser && (
<div className="flex gap-1"> <div className="flex gap-1">
{isDespacho && usuario.role === 'cliente' && ( {isDespacho && usuario.role === 'cliente' && (
@@ -526,6 +572,67 @@ export default function UsuariosPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)} )}
{/* Edit User Modal */}
{editingUser && (
<Dialog open onOpenChange={(open) => { if (!open) setEditingUser(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Editar usuario</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="edit-email">Email</Label>
<Input id="edit-email" value={editingUser.email} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="edit-nombre">Nombre</Label>
<Input
id="edit-nombre"
value={editForm.nombre}
onChange={e => setEditForm({ ...editForm, nombre: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-role">Rol</Label>
{editingUser.id === currentUser?.id ? (
<div className="text-sm border rounded-md px-3 py-2 bg-muted text-muted-foreground">
{getRoleInfo(editForm.role, isDespacho).label}
</div>
) : (
<Select
value={editForm.role}
onValueChange={(v) => setEditForm({ ...editForm, role: v as Role })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(isDespacho
? (['owner', 'supervisor', 'auxiliar', 'cliente'] as Role[])
: (['owner', 'cfo', 'contador', 'visor', 'auxiliar'] as Role[])
).map((r) => (
<SelectItem key={r} value={r}>
{getRoleInfo(r, isDespacho).label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{editingUser.id === currentUser?.id && (
<p className="text-xs text-muted-foreground">No puedes cambiar tu propio rol.</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingUser(null)}>Cancelar</Button>
<Button onClick={handleSaveUser} disabled={savingUser}>
{savingUser ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</DashboardShell> </DashboardShell>
); );
} }

View File

@@ -48,6 +48,11 @@ export function ContribuyenteSelector() {
const selected = contribuyentes.find((c) => c.id === selectedContribuyenteId); 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 ( return (
<div className="contribuyente-selector relative"> <div className="contribuyente-selector relative">
<button <button
@@ -91,7 +96,7 @@ export function ContribuyenteSelector() {
)} )}
{/* Lista de contribuyentes */} {/* Lista de contribuyentes */}
{contribuyentes.map((c) => ( {contribuyentesOrdenados.map((c) => (
<button <button
key={c.id} key={c.id}
onClick={() => { setSelectedContribuyente(c.id, c.rfc, c.nombre); setOpen(false); }} onClick={() => { setSelectedContribuyente(c.id, c.rfc, c.nombre); setOpen(false); }}

View File

@@ -35,7 +35,7 @@ export interface OverageAdjustResult {
reason?: string; reason?: string;
} }
export type ContribuyenteWithOverage = Contribuyente & { overage?: OverageAdjustResult }; export type ContribuyenteWithOverage = Contribuyente & { overage?: OverageAdjustResult; reactivated?: boolean };
export async function getContribuyentes(): Promise<{ data: Contribuyente[] }> { export async function getContribuyentes(): Promise<{ data: Contribuyente[] }> {
const { data } = await apiClient.get('/contribuyentes'); const { data } = await apiClient.get('/contribuyentes');

View File

@@ -331,7 +331,565 @@ La tabla `cat_clave_prod_serv` de la BD central estaba vacía; el catálogo nunc
| `apps/web/lib/api/catalogos.ts` | `searchClaveProdServ` acepta `AbortSignal` | | `apps/web/lib/api/catalogos.ts` | `searchClaveProdServ` acepta `AbortSignal` |
| `apps/web/app/(dashboard)/facturacion/page.tsx` | `handleSearchProduct` con `AbortController`, try/catch y `autoComplete="off"` | | `apps/web/app/(dashboard)/facturacion/page.tsx` | `handleSearchProduct` con `AbortController`, try/catch y `autoComplete="off"` |
## Deploy ## 16. Fix: CSF retry, backoff, delays entre tenants y timeouts aumentados
**Fecha:** 2026-06-01
### Cambios
- Se agregó **retry con backoff** en la descarga de Constancias de Situación Fiscal.
- Se agregaron **delays entre tenants** para no saturar el portal del SAT.
- Se aumentaron los timeouts del proceso de constancia.
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/services/constancia.service.ts` | Retry con backoff y manejo de errores |
| `apps/api/src/services/sat/sat-csf-login.ts` | Timeouts y delays ajustados |
| `apps/api/src/jobs/sat-sync.job.ts` | Delay entre ejecuciones de CSF por tenant |
---
## 17. Fix: múltiples fixes de producción en SAT, pagos y admin
**Fecha:** 2026-06-10
### Cambios
- Ajustes en el pool de conexiones a tenants (`database.ts`).
- Webhooks de MercadoPago más robustos ante eventos inesperados.
- Mejoras en `admin-clientes.service.ts` e `invoicing.service.ts`.
- Sweep de jobs SAT stale ajustado.
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/config/database.ts` | Configuración de pool |
| `apps/api/src/controllers/webhook.controller.ts` | Robustez en webhooks MP |
| `apps/api/src/jobs/sat-sync.job.ts` | Fixes en recovery y scheduling |
| `apps/api/src/services/admin-clientes.service.ts` | Fixes de admin |
| `apps/api/src/services/payment/invoicing.service.ts` | Fixes de facturación |
| `apps/api/src/services/sat/sat-client.service.ts` | Fixes de cliente SAT |
| `apps/api/src/services/sat/sweep-stale-jobs.service.ts` | Ajuste de stale jobs |
---
## 18. Feat: scorecards de notas de crédito en dashboard
**Fecha:** 2026-06-13
### Cambios
- Se agregaron scorecards de **notas de crédito emitidas** y **recibidas**.
- Se reordenaron las tarjetas del dashboard.
- Se ajustó el cálculo de **utilidad neta** considerando notas de crédito.
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/services/dashboard.service.ts` | Nuevos campos de notas de crédito |
| `apps/web/app/(dashboard)/dashboard/page.tsx` | Scorecards y utilidad ajustada |
| `packages/shared/src/types/dashboard.ts` | Tipos actualizados |
---
## 19. Feat: cron de recuperación SAT diario a las 10:00 AM
**Fecha:** 2026-06-14
### Cambio
Se agregó un cron diario a las 10:00 AM (`America/Mexico_City`) que ejecuta el **recovery sync** para contribuyentes con FIEL activa y sync incompleto.
### Archivo modificado
| Archivo | Cambio |
|---|---|
| `apps/api/src/jobs/sat-sync.job.ts` | Nuevo `runRecoverySyncJob` y scheduling |
---
## 20. Fix: pagar plan actual en trial_expired y planes > $10k
**Fecha:** 2026-06-16
### Problema
- Los tenants con suscripción `trial_expired` no podían pagar el plan actual.
- Planes Business Control/Enterprise superiores a $10,000 no podían usar preapproval de MercadoPago.
### Solución
- Se permite pagar el plan actual incluso en estado `trial_expired`.
- Planes caros usan una **Preference one-off anual** de MercadoPago en lugar de preapproval.
- Se agregó el campo `mpPreferenceId` a `subscriptions` para trackear la preference.
- El webhook de MP maneja tanto preapproval como preference.
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/controllers/despacho.controller.ts` | Lógica de pago de plan actual |
| `apps/api/src/controllers/subscription.controller.ts` | Soporte de preference |
| `apps/api/src/controllers/webhook.controller.ts` | Webhook para preference |
| `apps/api/src/services/payment/mercadopago.service.ts` | Creación de preference anual |
| `apps/api/src/services/payment/subscription.service.ts` | Pago de plan actual trial_expired |
| `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx` | UI para pagar plan actual vencido |
| `apps/api/prisma/schema.prisma` | Campo `mpPreferenceId` |
---
## 21. Feat: configuración de notificaciones por rol
**Fecha:** 2026-06-17
### Cambio
Se reemplazó el sistema de preferencias de notificación por contribuyente por uno por **rol**:
- `owner`, `supervisor`, `auxiliar` y `cliente` pueden activar/desactivar tipos de correo.
- Tipos: `documento_subido`, `weekly_update`, `subscription_expiring`, `recordatorio_fiscal`, `alertas_nuevas`, `recordatorio_proximo`.
- La tabla `notification_role_preferences` almacena la configuración por tenant.
### Archivos creados
| Archivo | Cambio |
|---|---|
| `apps/api/src/migrations/tenant/051_notification_role_preferences.sql` | Tabla de preferencias por rol |
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/services/notification-preferences.service.ts` | Nuevo modelo por rol |
| `apps/api/src/controllers/notification-preferences.controller.ts` | CRUD por rol |
| `apps/api/src/services/notifications.service.ts` | Filtrado por rol |
| `apps/api/src/services/notify-upload.service.ts` | Usa preferencias por rol |
| `apps/api/src/services/payment/subscription.service.ts` | Usa preferencias por rol |
| `apps/api/src/jobs/weekly-update.job.ts` | Filtrado por rol |
| `apps/web/app/(dashboard)/configuracion/notificaciones/page.tsx` | UI de configuración |
---
## 22. Fix: quitar badge "Próximamente" de notificaciones ya existentes
**Fecha:** 2026-06-16
### Cambio
Se quitó el badge "Próximamente" de los tipos de notificación que ya estaban implementados.
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/services/notification-preferences.service.ts` | Marcado de implementados |
| `apps/web/app/(dashboard)/configuracion/notificaciones/page.tsx` | Badges actualizados |
---
## 23. Feat: drill-down en pestaña nueva, rol Vendedor y scripts demo
**Fecha:** 2026-06-22
### Cambios
- El **drill-down de impuestos** ahora se abre en una pestaña nueva (`/drill-down`).
- Se agregó el rol **Vendedor** con acceso limitado a invitaciones trial y dashboard.
- Se crearon scripts de soporte para demos:
- `add-demo-cfdis.ts`
- `add-demo-notas-credito.ts`
- `create-vendedor-fernando.ts`
### Archivos creados
| Archivo | Cambio |
|---|---|
| `apps/api/scripts/add-demo-cfdis.ts` | CFDIs de demo |
| `apps/api/scripts/add-demo-notas-credito.ts` | Notas de crédito de demo |
| `apps/api/scripts/create-vendedor-fernando.ts` | Crea usuario vendedor de demo |
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/web/app/(dashboard)/drill-down/page.tsx` | Página de drill-down |
| `apps/web/app/(dashboard)/impuestos/page.tsx` | Link a drill-down en pestaña nueva |
| `apps/web/app/(dashboard)/dashboard/page.tsx` | Integración con drill-down |
| `apps/web/app/(dashboard)/admin/staff/page.tsx` | Rol Vendedor en staff |
| `apps/web/components/layouts/sidebar.tsx` | Menú para Vendedor |
| `packages/shared-ui/src/charts/kpi-card.tsx` | Soporte de link externo |
---
## 24. Fix: evita logout al cambiar de tenant
**Fecha:** 2026-06-22
### Problema
Al cambiar de empresa con el selector de tenants, múltiples requests en vuelo con el token viejo intentaban refrescar simultáneamente, causando logout o 401.
### Solución
- Se agregó un **mutex de refresh token** en el interceptor de Axios.
- Se marca el estado `isSwitching` en `membership-switcher` y `mis-empresas` para evitar clicks múltiples.
- El backend invalida el refresh token anterior al hacer switch.
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/web/lib/api/client.ts` | Mutex de refresh |
| `apps/web/components/membership-switcher.tsx` | Flag `isSwitching` |
| `apps/web/app/(dashboard)/mis-empresas/page.tsx` | Flag `isSwitching` |
| `apps/api/src/services/auth.service.ts` | Invalidación de refresh token anterior |
---
## 25. Fix: Vendedor accede a invitaciones trial
**Fecha:** 2026-06-22
### Cambio
El rol **Vendedor** puede ver y compartir invitaciones **trial**, pero no puede invitar clientes directamente.
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/controllers/client-invitations.controller.ts` | Permisos para vendedor |
| `apps/api/src/controllers/tenants.controller.ts` | Permisos para vendedor |
| `apps/web/app/(dashboard)/admin/staff/page.tsx` | UI de permisos |
| `apps/web/components/layouts/sidebar.tsx` | Menú visible para vendedor |
---
## 26. Feat: monitor de sincronización SAT
**Fecha:** 2026-06-22
### Cambio
Se agregó un cron de monitoreo de sincronizaciones SAT (cada 2 horas por defecto). Detecta:
- Jobs fallidos en las últimas N horas.
- Jobs pending sin atender.
- Jobs running atascados.
- Contribuyentes con FIEL activa pero sin sync inicial.
Envía un correo de alerta a `SAT_ALERT_EMAIL` (o `ADMIN_EMAIL` si no está configurado).
### Variables de entorno
| Variable | Default | Descripción |
|---|---|---|
| `SAT_ALERT_EMAIL` | `ADMIN_EMAIL` | Destino de alertas SAT |
| `SAT_MONITOR_SCHEDULE` | `0 */2 * * *` | Cron del monitor |
| `SAT_STUCK_RUNNING_HOURS` | `2` | Horas para considerar un job running como atorado |
| `SAT_FAILED_LOOKBACK_HOURS` | `24` | Ventana hacia atrás para reportar jobs fallidos |
### Archivos creados
| Archivo | Cambio |
|---|---|
| `apps/api/src/jobs/sat-sync-monitor.job.ts` | Lógica del monitor |
| `apps/api/src/services/email/templates/sat-sync-alert.ts` | Template de alerta |
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/index.ts` | Arranca el monitor |
| `apps/api/src/config/env.ts` | Validación de variables |
| `apps/api/.env.example` | Documentación de variables |
| `apps/api/src/services/email/email.service.ts` | `sendSatSyncAlert` |
---
## 27. Feat: reactivación de contribuyentes y limpieza al desactivar
**Fecha:** 2026-06-22
### Cambios
- Al crear un contribuyente cuyo RFC ya existía desactivado, se **reactiva** la entidad, se actualizan sus datos y se recupera su historial (CFDIs, FIEL, tareas, etc.).
- Al desactivar un contribuyente, se limpia de `cartera_entidades`, `cliente_accesos` y se desactiva su FIEL para que no siga sincronizándose.
- El timeout del proceso de constancia se aumentó a **5 minutos**.
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/services/contribuyente.service.ts` | Reactivación y limpieza al desactivar |
| `apps/api/src/controllers/contribuyente.controller.ts` | Retorna `reactivated` y status 200/201 |
| `apps/api/src/services/constancia.service.ts` | Timeout 5 min |
| `apps/web/app/(dashboard)/contribuyentes/page.tsx` | Alerta al reactivar |
| `apps/web/lib/api/contribuyentes.ts` | Tipo `reactivated` |
---
## 28. Fix: scraper de Constancia de Situación Fiscal más robusto
**Fecha:** 2026-06-22
### Cambio
Se reescribió el scraper de CSF para soportar los múltiples flujos del portal SAT:
- Búsqueda de PDF en frames, iframes, embeds, links `blob:`/`data:` y popups.
- Validación de que el buffer descargado comienza con `%PDF-`.
- Fallback de click por `dispatchEvent` si el primer click no dispara el handler JSF.
### Archivo modificado
| Archivo | Cambio |
|---|---|
| `apps/api/src/services/sat/sat-csf-scraper.ts` | Reescritura del scraper |
## 29. Actualización de precios: Business Control, Enterprise, RFCs adicionales y MSI
**Fecha:** 2026-06-25
### Cambio
Se actualizaron los precios de los planes anuales de despacho y la política de cobro por RFC adicional.
### Planes anuales
| Plan | Precio anterior | Nuevo precio (primer año / renovación) |
|---|---|---|
| **Business Control** (`business_control`) | $25,850 | **$30,850** |
| **Enterprise** (`business_cloud`) | $43,000 | **$68,850** |
Los cambios se aplican en la tabla `despacho_plan_prices` y afectan **nuevas suscripciones** y **renovaciones futuras** generadas por el sistema.
### RFCs adicionales (overage)
El precio mensual por cada contribuyente extra (más allá de los 100 incluidos) ahora depende del plan:
| Plan | Precio por RFC adicional |
|---|---|
| **Business Control** | $25/mes |
| **Enterprise** | $60/mes |
Se modificó `adjustDespachoOverage` para leer el precio desde el plan de la suscripción activa en lugar de usar un valor único de catálogo.
### Meses sin intereses (MSI)
Se habilitó el pago a **hasta 12 meses** en el checkout de MercadoPago para:
- Pago anual de Business Control / Enterprise (`createSubscriptionPreference`).
- Prorrateos de upgrade entre planes anuales (`createProrationPreference`).
> **Nota:** para que las cuotas aparezcan **sin intereses**, es necesario contar con promociones MSI activas en la cuenta de vendedor de MercadoPago. Sin promociones, el checkout mostrará meses con intereses.
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/services/payment/addon.service.ts` | Precio de overage por plan ($25 / $60) |
| `apps/api/src/services/payment/mercadopago.service.ts` | `payment_methods.installments: 12` en preferences anuales y prorrateos |
| `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx` | Tarjetas de precios mostradas en UI: Business Control $30,850 y Enterprise $68,850; overage $25/$60 |
| `apps/api/prisma/seed` (implícito) | Valores actualizados en `despacho_plan_prices` vía SQL |
### Deploy
Build y reload de API para limpiar el cache de `despacho_plan_prices`:
```bash
cd /root/HoruxDespachosNuevo
pnpm --filter api build
pm2 reload horux-api
pm2 reload horux-web
```
**Estado:** ✅ Exitoso
---
## 30. Supervisor puede invitar Clientes y Auxiliares
**Fecha:** 2026-06-25
### Cambio
Antes los supervisores solo podían invitar usuarios con rol **Cliente**. Ahora también pueden invitar **Auxiliares**, siempre asignados a su propia supervisión.
### Backend
| Archivo | Cambio |
|---|---|
| `apps/api/src/controllers/usuarios.controller.ts` | Permite a `supervisor` invitar `cliente` y `auxiliar`; al invitar un auxiliar se asigna a sí mismo por defecto y no permite asignar a otro supervisor |
| `apps/api/src/routes/cartera.routes.ts` | `/carteras/supervisores` ahora acepta `owner` y `supervisor` |
| `apps/api/src/controllers/cartera.controller.ts` | `getSupervisores` filtra para que un supervisor solo se vea a sí mismo en el dropdown |
### Frontend
| Archivo | Cambio |
|---|---|
| `apps/web/app/(dashboard)/usuarios/page.tsx` | El dropdown de roles para supervisor incluye **Auxiliar**; al seleccionar auxiliar se preselecciona al supervisor logueado como responsable |
### Deploy
```bash
cd /root/HoruxDespachosNuevo
pnpm --filter api build
pnpm --filter web build
pm2 reload horux-api
pm2 reload horux-web
```
**Estado:** ✅ Exitoso
---
## 31. Owner puede editar nombre y rol de usuarios
**Fecha:** 2026-06-25
### Cambio
En la página **Configuración Usuarios**, el **owner** ahora puede editar el **nombre** y el **rol** de cualquier usuario del tenant desde un modal.
### Detalles
- Se agregó el botón **Editar** en cada fila de usuario (solo visible para `owner`).
- El modal permite cambiar:
- **Nombre**
- **Rol** (los roles disponibles dependen de si es tenant despacho o legacy)
- Un owner **no puede cambiar su propio rol** (solo su nombre).
- El backend ya tenía el endpoint `PATCH /usuarios/:id`; se ajustó la UI para exponerlo.
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/web/app/(dashboard)/usuarios/page.tsx` | Botón Editar, modal de edición, validación de rol propio |
### Deploy
```bash
cd /root/HoruxDespachosNuevo
pnpm --filter web build
pm2 reload horux-web
```
**Estado:** ✅ Exitoso
---
## 32. Recordatorios periódicos en calendario
**Fecha:** 2026-06-25
### Cambio
Los recordatorios custom del calendario ahora pueden ser **periódicos**: mensual, bimestral, trimestral o anual. Se mantienen activos indefinidamente hasta que el usuario cancele la serie.
### Modelo
Se reutiliza la tabla `recordatorios` con metadatos de serie:
- **Maestro**: `serie_id IS NULL` y `recurrencia <> 'unica'`.
- **Instancias**: filas con `serie_id = maestro.id`, una por cada ocurrencia generada.
Esto permite completar una instancia sin afectar las demás, y el cron de emails de recordatorios próximos sigue funcionando sin cambios mayores.
### Funcionalidad
- Al crear un recordatorio periódico se generan instancias para un horizonte de 24 meses.
- Al **editar** una instancia se actualizan el maestro y **todas las ocurrencias futuras no completadas**.
- Al **eliminar** una instancia periódica se **cancela toda la serie**: se desactiva el maestro y se borran las instancias futuras no completadas.
- Un cron diario a las 6:00 AM extiende automáticamente las series activas para mantener el horizonte futuro.
### Frecuencias soportadas
- `mensual`
- `bimestral`
- `trimestral`
- `anual`
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/migrations/tenant/056_recordatorios_periodicos.sql` | Nuevas columnas `recurrencia`, `serie_id`, `activo`, `fecha_inicio`, `fecha_fin` e índices |
| `apps/api/src/services/recordatorios.service.ts` | Lógica de creación, edición, eliminación y regeneración de series periódicas |
| `apps/api/src/services/notifications.service.ts` | Excluir maestros del envío de recordatorios próximos |
| `apps/api/src/controllers/calendario.controller.ts` | Schemas aceptan `recurrencia` y `fechaFin` |
| `apps/api/src/jobs/recordatorios-periodicos.job.ts` | Nuevo cron de extensión de series |
| `apps/api/src/index.ts` | Registro del nuevo cron |
| `apps/web/app/(dashboard)/calendario/page.tsx` | Selector de recurrencia, fecha fin, indicador de serie y confirmación al cancelar |
### Deploy
```bash
cd /root/HoruxDespachosNuevo
pnpm --filter api build
pnpm --filter web build
pnpm --filter @horux/api db:migrate-tenants
pm2 reload horux-api
pm2 reload horux-web
```
**Estado:** ✅ Exitoso
---
## 33. Filtros de Conceptos con Aplicar/Limpiar
**Fecha:** 2026-06-30
### Cambio
En **CFDIs Conceptos**, los filtros de encabezado de tabla (UUID, Clave SAT, Descripción, No. Identificación) ahora se aplican únicamente al hacer clic en **Aplicar**, en lugar de disparar la búsqueda en cada cambio de teclado.
### Antes
Los inputs usaban `onChange` para actualizar `conceptosFilters`, lo que provocaba que `useQuery` re-lanzara la petición por cada letra escrita.
### Ahora
- Se separó el estado en dos:
- `conceptosDraftFilters`: valores que el usuario escribe en los popovers.
- `appliedConceptosFilters`: valores realmente aplicados a la query.
- **Aplicar**: copia el draft al estado aplicado, resetea paginación y cierra el popover.
- **Limpiar**: borra ese campo, aplica el cambio y resetea paginación.
- El ordenamiento por importe sigue funcionando con un clic en el encabezado.
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/web/app/(dashboard)/cfdi/page.tsx` | Separación de draft/applied filters y nuevos handlers `applyConceptosFilters` / `clearConceptosFilter` |
| `apps/api/src/controllers/cfdi.controller.ts` | El endpoint `/cfdi/conceptos` ahora recibe y pasa `noIdentificacion` al servicio |
### Deploy
```bash
cd /root/HoruxDespachosNuevo
pnpm --filter api build
pnpm --filter web build
pm2 reload horux-api
pm2 reload horux-web
```
**Estado:** ✅ Exitoso
---
## 34. Filtro de fechas en CFDIs usa fecha de emisión
**Fecha:** 2026-06-30
### Cambio
El filtro de fechas en el listado y export de **CFDIs** ahora filtra por **`fecha_emision`** en lugar de `fecha_efectiva`.
### Antes
El where clause usaba:
```sql
COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN fechaInicio AND fechaFin
```
Esto provocaba que facturas emitidas en meses anteriores pero pagadas/ejercidas en el rango seleccionado aparecieran en el resultado (por ejemplo, facturas de febrero con `fecha_efectiva` en junio).
### Ahora
El filtro usa directamente:
```sql
fecha_emision::date BETWEEN fechaInicio AND fechaFin
```
El export Excel hereda el mismo filtro, así que tabla y Excel son consistentes.
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/services/cfdi.service.ts` | Filtros de fecha en `getCfdis`, `getConceptosList` y `downloadXmlsZip` usan `fecha_emision::date` |
### Deploy
```bash
cd /root/HoruxDespachosNuevo
pnpm --filter api build
pnpm --filter web build
pm2 reload horux-api
pm2 reload horux-web
```
**Estado:** ✅ Exitoso
---
## Deploy histórico
### Preparación
1. Asegurarse de que `apps/api/.env` tenga las nuevas variables (tienen defaults, pero conviene declararlas):
```bash
SAT_ALERT_EMAIL=carlos@horuxfin.com
SAT_MONITOR_SCHEDULE=0 */2 * * *
SAT_STUCK_RUNNING_HOURS=2
SAT_FAILED_LOOKBACK_HOURS=24
```
2. Si hay scripts de debug antiguos en `apps/api/scripts/`, limpiar los que no sean necesarios antes de commitear.
### Comandos
```bash ```bash
cd /root/HoruxDespachosNuevo cd /root/HoruxDespachosNuevo

View File

@@ -1,298 +1,312 @@
# Implementación de Sincronización SAT # Sincronización SAT — Implementación y Operación
## Resumen Documentación viva del sistema de sincronización de CFDIs con el SAT para Horux Despachos / Horux 360.
Sistema de sincronización automática de CFDIs con el SAT (Servicio de Administración Tributaria de México) para Horux360. ## 1. Resumen
## Componentes Implementados El sistema descarga periódicamente XMLs y metadata de CFDIs emitidos y recibidos desde el servicio web de descarga masiva del SAT (`@nodecfdi/sat-ws-descarga-masiva`), usando la FIEL de cada contribuyente o del tenant (modo legacy Horux 360).
### 1. Backend (API) Los datos se almacenan en la base de datos del tenant correspondiente.
#### Servicios ## 2. Arquitectura
| Archivo | Descripción | ### Backend
|---------|-------------|
| `src/services/fiel.service.ts` | Gestión de credenciales FIEL (e.firma) |
| `src/services/sat/sat-client.service.ts` | Cliente para el servicio web del SAT |
| `src/services/sat/sat.service.ts` | Lógica principal de sincronización |
| `src/services/sat/sat-crypto.service.ts` | Encriptación AES-256-GCM para credenciales |
| `src/services/sat/sat-parser.service.ts` | Parser de XMLs de CFDI |
#### Controladores | Archivo | Responsabilidad |
|---------|-----------------|
| `apps/api/src/services/sat/sat.service.ts` | Lógica principal de sincronización, políticas de reintento, polling |
| `apps/api/src/services/sat/sat-client.service.ts` | Cliente del SAT, `query`, `verify`, `download` |
| `apps/api/src/services/sat/sat-parser.service.ts` | Parseo de XMLs y metadata |
| `apps/api/src/services/sat/proxy.service.ts` | Pool rotativo de proxies SAT |
| `apps/api/src/services/sat/sat-crypto.service.ts` | Encriptación AES-256-GCM de credenciales FIEL |
| `apps/api/src/services/fiel.service.ts` | FIEL a nivel tenant (legacy) |
| `apps/api/src/services/contribuyente-fiel.service.ts` | FIEL por contribuyente (modelo despacho) |
| `apps/api/src/controllers/sat.controller.ts` | Endpoints HTTP |
| `apps/api/src/jobs/sat-sync.job.ts` | Crons de sincronización |
| `apps/api/src/jobs/sat-sync-monitor.job.ts` | Watchdog de jobs atorados/fallidos |
| Archivo | Descripción | ### Frontend
|---------|-------------|
| `src/controllers/fiel.controller.ts` | Endpoints para gestión de FIEL |
| `src/controllers/sat.controller.ts` | Endpoints para sincronización SAT |
#### Job Programado | Archivo | Responsabilidad |
|---------|-----------------|
| `apps/web/components/sat/FielUploadModal.tsx` | Subir FIEL |
| `apps/web/components/sat/SyncStatus.tsx` | Estado y selector de fechas |
| `apps/web/components/sat/SyncHistory.tsx` | Historial de sincronizaciones |
| `apps/web/app/(dashboard)/configuracion/sat/page.tsx` | Página de configuración SAT |
| Archivo | Descripción | ## 3. Modelo de datos
|---------|-------------|
| `src/jobs/sat-sync.job.ts` | Cron job para sincronización diaria (3:00 AM) |
### 2. Frontend (Web) ### Tabla global `public.sat_sync_jobs`
#### Componentes
| Archivo | Descripción |
|---------|-------------|
| `components/sat/FielUploadModal.tsx` | Modal para subir certificado y llave FIEL |
| `components/sat/SyncStatus.tsx` | Estado de sincronización con selector de fechas |
| `components/sat/SyncHistory.tsx` | Historial de sincronizaciones |
#### Página
| Archivo | Descripción |
|---------|-------------|
| `app/(dashboard)/configuracion/sat/page.tsx` | Página de configuración SAT |
### 3. Base de Datos
#### Tabla Principal (schema public)
```sql ```sql
-- sat_sync_jobs: Almacena los trabajos de sincronización
CREATE TABLE sat_sync_jobs ( CREATE TABLE sat_sync_jobs (
id UUID PRIMARY KEY, id TEXT PRIMARY KEY,
tenant_id UUID NOT NULL, tenant_id TEXT NOT NULL REFERENCES tenants(id),
type VARCHAR(20) NOT NULL, -- 'initial' | 'daily' contribuyente_id TEXT, -- NULL = modo legacy Horux 360
status VARCHAR(20) NOT NULL, -- 'pending' | 'running' | 'completed' | 'failed' type "SatSyncType" NOT NULL, -- 'initial' | 'daily' | 'incremental'
date_from TIMESTAMP NOT NULL, status "SatSyncStatus" NOT NULL, -- 'pending' | 'running' | 'completed' | 'failed'
date_to TIMESTAMP NOT NULL, date_from DATE NOT NULL,
cfdi_type VARCHAR(20), date_to DATE NOT NULL,
sat_request_id VARCHAR(100), cfdi_type "CfdiSyncType", -- 'emitidos' | 'recibidos' (no siempre usado)
sat_request_id VARCHAR(50), -- legacy, preferir sat_request_ids
sat_package_ids TEXT[], sat_package_ids TEXT[],
cfdis_found INTEGER DEFAULT 0, cfdis_found INTEGER NOT NULL DEFAULT 0,
cfdis_downloaded INTEGER DEFAULT 0, cfdis_downloaded INTEGER NOT NULL DEFAULT 0,
cfdis_inserted INTEGER DEFAULT 0, cfdis_inserted INTEGER NOT NULL DEFAULT 0,
cfdis_updated INTEGER DEFAULT 0, cfdis_updated INTEGER NOT NULL DEFAULT 0,
progress_percent INTEGER DEFAULT 0, progress_percent INTEGER NOT NULL DEFAULT 0,
error_message TEXT, error_message TEXT,
started_at TIMESTAMP, started_at TIMESTAMP(3),
completed_at TIMESTAMP, completed_at TIMESTAMP(3),
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
retry_count INTEGER DEFAULT 0 retry_count INTEGER NOT NULL DEFAULT 0,
); next_retry_at TIMESTAMP(3),
is_custom_range BOOLEAN NOT NULL DEFAULT false,
-- fiel_credentials: Almacena las credenciales FIEL encriptadas sat_request_ids JSONB NOT NULL DEFAULT '{}'
CREATE TABLE fiel_credentials (
id UUID PRIMARY KEY,
tenant_id UUID UNIQUE NOT NULL,
rfc VARCHAR(13) NOT NULL,
cer_data BYTEA NOT NULL,
key_data BYTEA NOT NULL,
key_password_encrypted BYTEA NOT NULL,
encryption_iv BYTEA NOT NULL,
encryption_tag BYTEA NOT NULL,
serial_number VARCHAR(100),
valid_from TIMESTAMP NOT NULL,
valid_until TIMESTAMP NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
); );
``` ```
#### Columnas agregadas a tabla cfdis (por tenant)
```sql
ALTER TABLE tenant_xxx.cfdis ADD COLUMN xml_original TEXT;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN last_sat_sync TIMESTAMP;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN sat_sync_job_id UUID;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN source VARCHAR(20) DEFAULT 'manual';
```
## Dependencias
```json
{
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
"@nodecfdi/credentials": "^2.0.0",
"@nodecfdi/cfdi-core": "^1.0.1"
}
```
## Flujo de Sincronización
```
1. Usuario configura FIEL (certificado .cer + llave .key + contraseña)
2. Sistema valida y encripta credenciales (AES-256-GCM)
3. Usuario inicia sincronización (manual o automática 3:00 AM)
4. Sistema desencripta FIEL y crea cliente SAT
5. Por cada mes en el rango:
a. Solicitar CFDIs emitidos al SAT
b. Esperar respuesta (polling cada 30s)
c. Descargar paquetes ZIP
d. Extraer y parsear XMLs
e. Guardar en BD del tenant
f. Repetir para CFDIs recibidos
6. Marcar job como completado
```
## API Endpoints
### FIEL ### FIEL
| Método | Ruta | Descripción | - **Legacy:** `public.fiel_credentials` (una por tenant).
|--------|------|-------------| - **Por contribuyente:** `fiel_contribuyente` dentro de la base del tenant.
| GET | `/api/fiel/status` | Estado de la FIEL configurada |
| POST | `/api/fiel/upload` | Subir nueva FIEL |
| DELETE | `/api/fiel` | Eliminar FIEL |
### Sincronización SAT El sistema intenta primero la FIEL del contribuyente; si no existe, cae a la FIEL del tenant.
| Método | Ruta | Descripción | ## 4. Tipos de sincronización
|--------|------|-------------|
| POST | `/api/sat/sync` | Iniciar sincronización |
| GET | `/api/sat/sync/status` | Estado actual |
| GET | `/api/sat/sync/history` | Historial de syncs |
| GET | `/api/sat/sync/:id` | Detalle de un job |
| POST | `/api/sat/sync/:id/retry` | Reintentar job fallido |
### Parámetros de sincronización | Tipo | Descripción | Cuándo corre |
|------|-------------|--------------|
| `initial` | Primer sync de un contribuyente/tenant. Descarga XMLs + metadata en bloques. | Manual o cuando no hay un `initial` completado |
| `daily` | Sync diaria de los últimos 7 días de XMLs + metadata histórica solo los domingos | Cron 610 AM CDMX y retries 9 AM / 4 PM |
| `incremental` | Ventana de las últimas 8 horas | Cron 11 AM, 3 PM, 7 PM CDMX (Enterprise) |
| Custom range | `daily` con `dateFrom`/`dateTo` explícitos, llamado por el UI | Manual |
```typescript ## 5. Cronograma de jobs
interface StartSyncRequest {
type?: 'initial' | 'daily'; // default: 'daily'
dateFrom?: string; // ISO date, ej: "2025-01-01T00:00:00"
dateTo?: string; // ISO date, ej: "2025-12-31T23:59:59"
}
```
## Configuración Definidos en `apps/api/src/jobs/sat-sync.job.ts`:
### Variables de entorno | Job | Expresión | Horario CDMX | Propósito |
|-----|-----------|--------------|-----------|
| SAT Cron | `0 6-10 * * *` | 6:0010:00 AM | Daily sync, ~20% de tenants por hora, hasta `SAT_CONCURRENT_CONTRIBUYENTES` paralelos |
| Recovery Cron | `0 10 * * *` | 10:00 AM | Recuperar jobs `running` atorados |
| Daily Retry | `0 9 * * *` y `0 16 * * *` | 9:00 AM y 4:00 PM | Reintentar daily fallidos |
| Incremental Enterprise | `0 11,15,19 * * *` | 11 AM, 3 PM, 7 PM | Sync incremental |
| SAT Watchdog | `0 */2 * * *` | Cada 2 horas | Marcar jobs `running` sin heartbeat como failed |
| SAT Monitor | `0 */2 * * *` | Cada 2 horas | Alertar por email de jobs fallidos |
| SAT Proxy Report | `0 8 * * *` | 8:00 AM | Reporte diario de errores SAT por proxy |
```env ## 6. Flujo de sincronización
# Clave para encriptar credenciales FIEL (32 bytes hex)
FIEL_ENCRYPTION_KEY=tu_clave_de_32_bytes_en_hexadecimal
# Zona horaria para el cron ### `processDailySync`
TZ=America/Mexico_City
```
### Límites del SAT 1. Fecha final = ayer a medio día UTC (`getYesterdayEnd()`).
2. Ejecuta XMLs emitidos y recibidos de los últimos 7 días.
3. **Metadata histórica (desde inicio de año) solo si es domingo en CDMX.**
4. Errores 404 se registran pero **no abortan** el daily.
5. Errores transitorios (timeout) se reintentan según política.
- **Antigüedad máxima**: 6 años ### `processInitialSync` / custom range
- **Solicitudes por día**: Limitadas (se reinicia cada 24h)
- **Tamaño de paquete**: Variable
## Errores Comunes del SAT 1. Divide el rango en bloques de 3 o 6 meses para XMLs (según volumen estimado).
2. Descarga XMLs emitidos y recibidos por bloque.
3. Descarga metadata del rango completo.
4. Pausa de 5 segundos entre bloques.
| Código | Mensaje | Solución | ### `processIncrementalSync`
|--------|---------|----------|
| 5000 | Solicitud Aceptada | OK - esperar verificación |
| 5002 | Límite de solicitudes agotado | Esperar 24 horas |
| 5004 | No se encontraron CFDIs | Normal si no hay facturas en el rango |
| 5005 | Solicitud duplicada | Ya existe una solicitud pendiente |
| - | Información mayor a 6 años | Ajustar rango de fechas |
| - | No se permite descarga de cancelados | Facturas canceladas no disponibles |
## Seguridad 1. Ventana de 8 horas: `ahora - 10h` a `ahora - 2h`.
2. Descarga XMLs + metadata de emitidos y recibidos.
1. **Encriptación de credenciales**: AES-256-GCM con IV único ## 7. Proxies SAT (rotación por IP)
2. **Almacenamiento seguro**: Certificado, llave y contraseña encriptados
3. **Autenticación**: JWT con tenantId embebido
4. **Aislamiento**: Cada tenant tiene su propio schema en PostgreSQL
## Servicios Systemd Para mitigar el bloqueo `404 Error no controlado` causado por cuota de solicitudes desde una sola IP pública, el sistema soporta un pool de proxies HTTP/HTTPS rotativos.
### Configuración
Variables en `apps/api/.env`:
```bash ```bash
# API Backend # Lista de proxies separados por coma. Soporta autenticación básica.
systemctl status horux-api SAT_PROXY_LIST=http://user:pass@host1:port,http://user:pass@host2:port
# Web Frontend # Estrategia de rotación: round-robin | random (default: round-robin)
systemctl status horux-web SAT_PROXY_STRATEGY=round-robin
# Si true, cuando todos los proxies fallan se intenta con la IP directa del servidor.
SAT_PROXY_FALLBACK_DIRECT=true
# Máximo de contribuyentes sincronizados en paralelo por el scheduler (default: 10).
SAT_CONCURRENT_CONTRIBUYENTES=10
``` ```
## Comandos Útiles ### Componentes
- **`ProxyManager`** (`apps/api/src/services/sat/proxy.service.ts`): parsea `SAT_PROXY_LIST`, rota proxies y crea `HttpsProxyAgent`.
- **`sat-client.service.ts`**: usa el agente del proxy en cada petición SOAP al SAT.
### Reporte diario de errores por proxy
Cada error de bloqueo/devolución del SAT se guarda en `public.sat_proxy_errors` con el proxy usado.
Un cron a las **8:00 AM CDMX** envía un email a `ADMIN_EMAIL` con:
- Total de errores en las últimas 24 horas.
- Tabla agrupada por `proxy` + `error_code`.
Archivos:
- `apps/api/src/jobs/sat-proxy-report.job.ts` — cron y consulta.
- `apps/api/src/services/email/templates/sat-proxy-report.ts` — template del email.
- `apps/api/src/services/email/email.service.ts``sendSatProxyReport`.
### Comportamiento
- Cada llamada a `getNextProxy()` devuelve el siguiente proxy del pool (round-robin).
- Cuando el scheduler lanza 10 contribuyentes en paralelo, cada uno tiende a usar una IP distinta.
- Si un proxy devuelve error de conexión, el cliente SAT puede caer a la IP directa según `SAT_PROXY_FALLBACK_DIRECT`.
### Recomendaciones operativas
- Tamaño mínimo del pool: **1 proxy por cada 5 RFCs** que se sincronicen en paralelo.
- Con `SAT_CONCURRENT_CONTRIBUYENTES=10`, un pool de 10 proxies da una IP por RFC en el peor caso.
- Monitorear logs por `[ProxyManager] Usando proxy: ...` y por 404 persistentes en una misma IP.
## 8. Polling y límites
Después de crear una solicitud (`query`) al SAT, el sistema verifica el estado periódicamente (`verify`).
Constantes en `sat.service.ts`:
```ts
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutos entre verificaciones
const MAX_POLL_ATTEMPTS = 9; // 9 intentos máximo por solicitud
const DAILY_MAX_POLL_ATTEMPTS = 9; // igual para daily
const YEARS_TO_SYNC = 6;
```
Esto da un máximo de **~45 minutos por solicitud** (9 × 5 min).
Cada solicitud al SAT tiene su propio polling; los intentos no se comparten entre solicitudes.
## 9. Políticas de reintentos
```ts
const RETRY_POLICIES = {
daily: { maxRetries: 2, retryAtHours: [6, 12] },
custom: { maxRetries: 2, retryAtHours: [6, 12] },
initial: { maxRetries: 3, retryAtHours: [6, 12, 24] },
incremental: { maxRetries: 0, retryAtHours: [] },
};
const MAX_DAILY_RETRY_ATTEMPTS = 5; // original + 2 automáticos + 2 crons fijos
```
Los reintentos automáticos se programan desde `createdAt` del job. Los reintentos por cron fijo (9 AM / 4 PM) se manejan en `continuePendingDailyRequests`.
## 10. Manejo de errores
### Errores no fatales (se registran, no abortan en daily)
- `404 Error no controlado` en daily se guarda en `errorMessage` como `completedWithWarnings`.
- En syncs custom/initial los 404 se capturan por bloque pero el job puede continuar.
### Errores transitorios (reintentan)
- Timeout del polling (`SatSyncTimeoutError`).
- `SatTransientError` (ej. rechazo transitorio del SAT).
- Metadata aún no lista (`SatMetadataPendingError`).
### Errores fatales (job falla)
- FIEL inválida o vencida.
- Errores que no son transitorios y no están en la lista de no fatales.
## 11. Errores comunes del SAT
| Código/Mensaje | Significado | Acción |
|----------------|-------------|--------|
| `5000` / "Solicitud Aceptada" | OK, hay que esperar | Polling normal |
| `5002` / "Solicitudes agotadas de por vida" | Cuota de solicitudes agotada | Omitir rango, esperar 24h |
| `5004` / "No se encontró la información" | Sin CFDIs en el rango | Continuar |
| `5005` / Duplicada | Solicitud duplicada | Reusar requestId existente |
| `404` / "Error no controlado" | Bloqueo/cuota del SAT | Registrar y continuar en daily; reintentar en otros syncs |
| "Fecha final invalida" | Fecha futura o mal formada | Usar `getYesterdayEnd()` |
| "El certificado no es válido" | FIEL rechazada por el SAT | Revisar vigencia/contraseña de FIEL |
## 12. Monitoreo y comandos útiles
### Estado del API
```bash ```bash
# Ver logs de sincronización SAT pm2 status horux-api
journalctl -u horux-api -f | grep "\[SAT\]" pm2 logs horux-api --lines 50 --nostream
# Estado de jobs
psql -U postgres -d horux360 -c "SELECT * FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 5;"
# CFDIs sincronizados por tenant
psql -U postgres -d horux360 -c "SELECT COUNT(*) FROM tenant_xxx.cfdis WHERE source = 'sat';"
``` ```
## Changelog ### Jobs recientes
### 2026-01-25
- Implementación inicial de sincronización SAT
- Integración con librería @nodecfdi/sat-ws-descarga-masiva
- Soporte para fechas personalizadas en sincronización
- Corrección de cast UUID en queries SQL
- Agregadas columnas faltantes a tabla cfdis
- UI para selección de periodo personalizado
- Cambio de servicio web a modo producción (next start)
## Estado Actual (2026-01-25)
### Completado
- [x] Servicio de encriptación de credenciales FIEL
- [x] Integración con @nodecfdi/sat-ws-descarga-masiva
- [x] Parser de XMLs de CFDI
- [x] UI para subir FIEL
- [x] UI para ver estado de sincronización
- [x] UI para seleccionar periodo personalizado
- [x] Cron job para sincronización diaria (3:00 AM)
- [x] Soporte para fechas personalizadas
- [x] Corrección de cast UUID en queries
- [x] Columnas adicionales en tabla cfdis de todos los tenants
### Pendiente por probar
El SAT bloqueó las solicitudes por exceso de pruebas. **Esperar 24 horas** y luego:
1. Ir a **Configuración > SAT**
2. Clic en **"Periodo personalizado"**
3. Seleccionar: **2025-01-01** a **2025-12-31**
4. Clic en **"Sincronizar periodo"**
### Tenant de prueba
- **RFC**: HTS240708LJA
- **Schema**: `tenant_cas2408138w2`
- **Nota**: Los CFDIs "recibidos" de este tenant están cancelados (SAT no permite descargarlos)
### Comandos para verificar después de 24h
```bash ```bash
# Ver estado del sync export DATABASE_URL="postgresql://postgres:PASSWORD@localhost:5432/horux360"
PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \ psql "$DATABASE_URL" -c "
"SELECT status, cfdis_found, cfdis_downloaded, cfdis_inserted FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 1;" SELECT id, type, status, contribuyente_id,
cfdis_found, cfdis_downloaded, cfdis_inserted, cfdis_updated,
# Ver logs en tiempo real error_message, created_at, completed_at
journalctl -u horux-api -f | grep "\[SAT\]" FROM sat_sync_jobs
ORDER BY created_at DESC
# Contar CFDIs sincronizados LIMIT 20;
PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \ "
"SELECT COUNT(*) as total FROM tenant_cas2408138w2.cfdis WHERE source = 'sat';"
``` ```
### Problemas conocidos ### Jobs fallidos o atorados
1. **"Se han agotado las solicitudes de por vida"**: Límite de SAT alcanzado, esperar 24h ```bash
2. **"No se permite la descarga de xml que se encuentren cancelados"**: Normal para facturas canceladas psql "$DATABASE_URL" -c "
3. **"Información mayor a 6 años"**: SAT solo permite descargar últimos 6 años SELECT id, type, status, tenant_id, contribuyente_id, error_message, created_at, started_at
FROM sat_sync_jobs
WHERE status IN ('failed', 'running')
ORDER BY created_at DESC;
"
```
## Próximos Pasos ### Contribuyentes de un tenant
- [ ] Probar sincronización completa después de 24h ```bash
- [ ] Verificar que los CFDIs se guarden correctamente # Reemplazar horux_<tenant_db> por la base del tenant
- [ ] Implementar reintentos automáticos para errores temporales psql "$DATABASE_URL" -c "
- [ ] Notificaciones por email al completar sincronización SELECT c.entidad_id, c.rfc, e.nombre
- [ ] Dashboard con estadísticas de CFDIs por periodo FROM contribuyentes c
- [ ] Soporte para filtros adicionales (RFC emisor/receptor, tipo de comprobante) JOIN entidades_gestionadas e ON c.entidad_id = e.id;
"
```
## 13. Changelog reciente
### 2026-08-03
- **Proxies SAT rotativos**: integración de `ProxyManager` y pool de proxies HTTP/HTTPS para evitar bloqueo por IP del SAT.
- **Concurrencia por contribuyente**: el scheduler daily e incremental procesa hasta `SAT_CONCURRENT_CONTRIBUYENTES=10` RFCs en paralelo, sin importar a cuántos tenants pertenezcan.
- **Reporte diario de errores por proxy**: cron a las 8 AM CDMX que envía a `ADMIN_EMAIL` un resumen de errores SAT agrupados por proxy/IP.
- Tabla `public.sat_proxy_errors` para trazabilidad de bloqueos por IP.
- Variables de entorno: `SAT_PROXY_LIST`, `SAT_PROXY_STRATEGY`, `SAT_PROXY_FALLBACK_DIRECT`, `SAT_CONCURRENT_CONTRIBUYENTES`.
### 2026-07-31
- Metadata histórica en daily solo se ejecuta los domingos.
- Errores 404 en daily no abortan el proceso; se registran en `error_message`.
- Polling reducido a **9 intentos máximos cada 5 minutos** por solicitud.
- Daily retry fijo a las 9:00 AM y 4:00 PM CDMX.
- Incremental Enterprise a las 11:00 AM, 3:00 PM y 7:00 PM CDMX.
## 14. Problemas conocidos
1. **Bloqueo `404 Error no controlado` del SAT**: Aparece cuando se hacen muchas consultas desde la misma IP. Mitigación: proxies rotativos (`SAT_PROXY_LIST`) + concurrencia controlada por contribuyente + metadata histórica solo domingos.
2. **FIEL inválida**: Algunos tenants/contribuyentes tienen FIEL rechazada por el SAT. Requiere revisar/renovar FIEL.
3. **Jobs `initial` atorados en `running`**: Pueden quedar si el proceso se reinicia; el recovery cron y el watchdog los limpian.
## 15. Próximos pasos
- [x] Implementar proxies rotativos para evitar bloqueo por IP del SAT.
- [x] Reporte diario de errores por proxy.
- [ ] Monitorear tasa de éxito tras proxies + concurrencia por contribuyente.
- [ ] Revisar/renovar FIELs inválidas reportadas por el monitor.
- [ ] Evaluar ampliar ventana horaria del daily (610 AM) si el volumen de RFCs supera el throughput con 10 paralelos.

View File

@@ -22,5 +22,10 @@
"packageManager": "pnpm@9.0.0", "packageManager": "pnpm@9.0.0",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
},
"pnpm": {
"patchedDependencies": {
"@nodecfdi/sat-ws-descarga-masiva@2.0.0": "patches/@nodecfdi__sat-ws-descarga-masiva@2.0.0.patch"
}
} }
} }

View File

@@ -0,0 +1,43 @@
diff --git a/build/index.js b/build/index.js
index bf7a6aafce966c4ab44ba3abb240578cc68779d6..df01102262bbe6d544a5a1b4453977be8a826d0d 100644
--- a/build/index.js
+++ b/build/index.js
@@ -266,7 +266,13 @@ var ServiceConsumer = class _ServiceConsumer {
} catch (error) {
const webError = error;
exception = webError;
- response = webError.getResponse();
+ if (typeof webError.getResponse !== "function") {
+ console.error("[SAT Library] Error no-WebClientException capturado en ServiceConsumer.execute:", error);
+ const fallbackResponse = new CResponse(0, String(error && error.message ? error.message : error), {});
+ response = fallbackResponse;
+ } else {
+ response = webError.getResponse();
+ }
}
this.checkErrors(request, response, exception);
return response.getBody();
@@ -2660,10 +2666,12 @@ var HttpsWebClient = class {
_fireRequestClosure;
_fireResponseClosure;
_timeout;
- constructor(onFireRequest, onFireResponse, timeout) {
+ _agent;
+ constructor(onFireRequest, onFireResponse, timeout, agent = void 0) {
this._fireRequestClosure = onFireRequest;
this._fireResponseClosure = onFireResponse;
this._timeout = timeout;
+ this._agent = agent;
}
fireRequest(request) {
if (this._fireRequestClosure) {
@@ -2679,7 +2687,8 @@ var HttpsWebClient = class {
const options = {
method: request.getMethod(),
headers: request.getHeaders(),
- timeout: this._timeout ?? request.getTimeout() ?? void 0
+ timeout: this._timeout ?? request.getTimeout() ?? void 0,
+ agent: this._agent
};
return new Promise((resolve, reject) => {
let clientRequest;

42
pnpm-lock.yaml generated
View File

@@ -4,6 +4,11 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
patchedDependencies:
'@nodecfdi/sat-ws-descarga-masiva@2.0.0':
hash: i4ncoh7xgprkdron5l2ech4ifm
path: patches/@nodecfdi__sat-ws-descarga-masiva@2.0.0.patch
importers: importers:
.: .:
@@ -34,7 +39,7 @@ importers:
version: 3.2.0(luxon@3.7.2) version: 3.2.0(luxon@3.7.2)
'@nodecfdi/sat-ws-descarga-masiva': '@nodecfdi/sat-ws-descarga-masiva':
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2) version: 2.0.0(patch_hash=i4ncoh7xgprkdron5l2ech4ifm)(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)
'@prisma/client': '@prisma/client':
specifier: ^5.22.0 specifier: ^5.22.0
version: 5.22.0(prisma@5.22.0) version: 5.22.0(prisma@5.22.0)
@@ -65,6 +70,9 @@ importers:
helmet: helmet:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.1.0 version: 8.1.0
https-proxy-agent:
specifier: ^7.0.6
version: 7.0.6
jsonwebtoken: jsonwebtoken:
specifier: ^9.0.2 specifier: ^9.0.2
version: 9.0.3 version: 9.0.3
@@ -1373,6 +1381,10 @@ packages:
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
engines: {node: '>=12.0'} engines: {node: '>=12.0'}
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
any-promise@1.3.0: any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
@@ -1658,6 +1670,15 @@ packages:
supports-color: supports-color:
optional: true optional: true
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
decimal.js-light@2.5.1: decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
@@ -1926,6 +1947,10 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
iconv-lite@0.4.24: iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3108,7 +3133,7 @@ snapshots:
dependencies: dependencies:
luxon: 3.7.2 luxon: 3.7.2
'@nodecfdi/sat-ws-descarga-masiva@2.0.0(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)': '@nodecfdi/sat-ws-descarga-masiva@2.0.0(patch_hash=i4ncoh7xgprkdron5l2ech4ifm)(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)':
dependencies: dependencies:
'@nodecfdi/cfdi-core': 1.0.1 '@nodecfdi/cfdi-core': 1.0.1
'@nodecfdi/credentials': 3.2.0(luxon@3.7.2) '@nodecfdi/credentials': 3.2.0(luxon@3.7.2)
@@ -3762,6 +3787,8 @@ snapshots:
adm-zip@0.5.16: {} adm-zip@0.5.16: {}
agent-base@7.1.4: {}
any-promise@1.3.0: {} any-promise@1.3.0: {}
anymatch@3.1.3: anymatch@3.1.3:
@@ -4071,6 +4098,10 @@ snapshots:
dependencies: dependencies:
ms: 2.0.0 ms: 2.0.0
debug@4.4.3:
dependencies:
ms: 2.1.3
decimal.js-light@2.5.1: {} decimal.js-light@2.5.1: {}
delayed-stream@1.0.0: {} delayed-stream@1.0.0: {}
@@ -4393,6 +4424,13 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 toidentifier: 1.0.1
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
debug: 4.4.3
transitivePeerDependencies:
- supports-color
iconv-lite@0.4.24: iconv-lite@0.4.24:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2