package services import ( "encoding/json" "fmt" "log" "strconv" "time" "github.com/robfig/cron/v3" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) var cronScheduler *cron.Cron // IniciarCron arranca el scheduler de tareas. Llamar desde app.go o main.go. func IniciarCron() { cronScheduler = cron.New() // Vencimientos próximos — todos los días a las 8:00 AM if _, err := cronScheduler.AddFunc("0 8 * * *", ProcesarVencimientosProximos); err != nil { log.Printf("[CRON] Error registrando tarea vencimientos_proximo: %v", err) return } // Contratos ya vencidos — todos los días a las 9:00 AM if _, err := cronScheduler.AddFunc("0 9 * * *", ProcesarYaVencidos); err != nil { log.Printf("[CRON] Error registrando tarea ya_vencido: %v", err) return } // Polling Bold — cada 15 minutos para detectar pagos sin webhook if _, err := cronScheduler.AddFunc("*/15 * * * *", VerificarPagosBoldPendientes); err != nil { log.Printf("[CRON] Error registrando tarea bold_polling: %v", err) return } // Salud de servidores — cada 5 minutos if _, err := cronScheduler.AddFunc("*/5 * * * *", VerificarSaludServidores); err != nil { log.Printf("[CRON] Error registrando tarea salud_servidores: %v", err) return } cronScheduler.Start() log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min") } // DetenerCron para graceful shutdown func DetenerCron() { if cronScheduler != nil { cronScheduler.Stop() } } // ProcesarVencimientos mantiene compatibilidad para llamadas manuales func ProcesarVencimientos() { ProcesarVencimientosProximos() ProcesarYaVencidos() } // ProcesarVencimientosProximos procesa reglas de tipo "vencimiento_proximo" func ProcesarVencimientosProximos() { log.Println("[CRON] Procesando vencimientos próximos...") reglas, err := models.GetReglasByTipoEvento("vencimiento_proximo") if err != nil { log.Printf("[CRON] Error obteniendo reglas vencimiento_proximo: %v", err) return } for _, regla := range reglas { contratos, err := models.GetContratosProximosVencer(regla.DiasAntes) if err != nil { log.Printf("[CRON] Error obteniendo contratos para regla %d: %v", regla.ID, err) continue } procesarContratos(®la, contratos) } log.Println("[CRON] Vencimientos próximos finalizado") } // ProcesarYaVencidos procesa reglas de tipo "ya_vencido" func ProcesarYaVencidos() { log.Println("[CRON] Procesando contratos ya vencidos...") reglas, err := models.GetReglasByTipoEvento("ya_vencido") if err != nil { log.Printf("[CRON] Error obteniendo reglas ya_vencido: %v", err) return } if len(reglas) == 0 { return } contratos, err := models.GetContratosYaVencidos() if err != nil { log.Printf("[CRON] Error obteniendo contratos vencidos: %v", err) return } for _, regla := range reglas { procesarContratos(®la, contratos) } log.Println("[CRON] Ya vencidos finalizado") } // procesarContratos aplica filtros y envía notificaciones para una regla y lista de contratos func procesarContratos(regla *models.NotificacionRegla, contratos []models.Contrato) { if len(contratos) == 0 { return } // Filtrar por AplicaA var filtrados []models.Contrato for _, c := range contratos { switch regla.AplicaA { case "renovable": for _, s := range c.Servicios { if s.Tipo == "renovable" { filtrados = append(filtrados, c) break } } case "unico": for _, s := range c.Servicios { if s.Tipo == "unico" { filtrados = append(filtrados, c) break } } default: filtrados = append(filtrados, c) } } if len(filtrados) == 0 { return } // Agrupar por cliente porCliente := make(map[uint][]models.Contrato) for _, c := range filtrados { porCliente[c.ClienteID] = append(porCliente[c.ClienteID], c) } for clienteID, grupoContratos := range porCliente { if models.YaEnviadoHoy(clienteID, regla.ID) { log.Printf("[CRON] Ya enviado hoy a cliente %d para regla %d — saltando", clienteID, regla.ID) continue } cliente := &grupoContratos[0].Cliente if err := EnviarNotificacionGrupo(regla, cliente, grupoContratos, ""); err != nil { log.Printf("[CRON] Error enviando a cliente %d: %v", clienteID, err) } else { log.Printf("[CRON] Enviado a cliente %d (%s) — %d contrato(s)", clienteID, cliente.Email, len(grupoContratos)) } } } // VerificarPagosBoldPendientes consulta la API de Bold para cada contrato que tiene un // enlace de pago generado pero aún no confirmado. Se ejecuta cada 15 minutos via cron. // Esto compensa la ausencia de webhooks de Bold. func VerificarPagosBoldPendientes() { contratos, err := models.GetContratosConEnlacePendiente() if err != nil { log.Printf("[BOLD-POLL] Error obteniendo contratos pendientes: %v", err) return } if len(contratos) == 0 { return } boldCfg, err := models.GetBoldConfig() if err != nil { log.Printf("[BOLD-POLL] Sin configuración Bold activa: %v", err) return } log.Printf("[BOLD-POLL] Verificando %d contrato(s) con enlace pendiente...", len(contratos)) for _, c := range contratos { paid, paymentID, monto, err := CheckBoldLinkPaid(boldCfg, c.EnlacePagoLinkID) if err != nil { log.Printf("[BOLD-POLL] Error consultando link %s (contrato %d): %v", c.EnlacePagoLinkID, c.ID, err) continue } if !paid { continue } ref := fmt.Sprintf("contrato-%d", c.ID) log.Printf("[BOLD-POLL] Pago detectado para %s (payment_id=%s, monto=%d)", ref, paymentID, monto) if ok, _ := models.MarcarContratoPagado(c.ID); ok { go EnviarCorreoConfirmacionPago(c.ID, "bold") log.Printf("[BOLD-POLL] Contrato %d marcado como pagado", c.ID) } // Enriquecer callback log si existe go models.EnrichBoldCallbackLog(ref, c.Cliente.Email, monto) // Guardar en webhook log como API_CHECK para trazabilidad notifID := "api-check-" + paymentID if paymentID != "" && !models.IsBoldNotificationDuplicate(notifID) { _ = models.SaveBoldWebhookLog(models.BoldWebhookLog{ NotificationID: notifID, Tipo: "API_CHECK", PaymentID: paymentID, Referencia: ref, PayerEmail: c.Cliente.Email, Monto: monto, Procesado: true, }) } } log.Println("[BOLD-POLL] Verificación completada") } // ─── Salud de Servidores ───────────────────────────────────────────────────── // VerificarSaludServidores corre cada 5 min y genera notificaciones del sistema // cuando detecta: agente caído, recurso al límite o VPS próximo a vencer. func VerificarSaludServidores() { cfg := models.GetServidorAlertaUmbral() if !cfg.Activo { return } servidores, _, err := models.GetAllServidores(1000, 0, "") if err != nil { log.Printf("[CRON-SRV] Error obteniendo servidores: %v", err) return } for _, srv := range servidores { // 1. Agente caído (sin ping en X minutos) if srv.AgentToken != "" && srv.AgentLastSeen != nil { minutos := time.Since(*srv.AgentLastSeen).Minutes() if minutos > float64(cfg.MinutosSinPing) { titulo := fmt.Sprintf("🔴 Sin señal: %s", srv.Nombre) if !models.YaExisteAlertaServidor(titulo, 1) { crearNotifServidor(titulo, fmt.Sprintf("El agente no reporta hace %.0f minutos (umbral: %d min)", minutos, cfg.MinutosSinPing), "servidor_caido") } } } // 2. Recursos (CPU / RAM / Disco) al límite if srv.MetricasJson != "" { var m struct { CPU struct{ Porcentaje float64 `json:"porcentaje"` } `json:"cpu"` RAM struct{ Porcentaje float64 `json:"porcentaje"` } `json:"ram"` Disco struct{ Porcentaje float64 `json:"porcentaje"` } `json:"disco"` } if jsonErr := json.Unmarshal([]byte(srv.MetricasJson), &m); jsonErr == nil { checkRecurso(srv.Nombre, "CPU", m.CPU.Porcentaje, cfg.UmbralCPU) checkRecurso(srv.Nombre, "RAM", m.RAM.Porcentaje, cfg.UmbralRAM) checkRecurso(srv.Nombre, "Disco", m.Disco.Porcentaje, cfg.UmbralDisco) } } // 3. Vencimiento próximo if srv.Vencimiento != "" && cfg.DiasAnteVencimiento > 0 { vence, parseErr := time.Parse("2006-01-02", srv.Vencimiento) if parseErr == nil { dias := int(time.Until(vence).Hours() / 24) if dias >= 0 && dias <= cfg.DiasAnteVencimiento { titulo := fmt.Sprintf("📅 %s vence en %s", srv.Nombre, strconv.Itoa(dias)+" días") if dias == 0 { titulo = fmt.Sprintf("📅 %s vence HOY", srv.Nombre) } if !models.YaExisteAlertaServidor(titulo, 12) { crearNotifServidor(titulo, fmt.Sprintf("Fecha de vencimiento: %s. Renueva a tiempo.", srv.Vencimiento), "servidor_vence_pronto") } } } } } } func checkRecurso(nombre, recurso string, valor float64, umbral int) { if valor < float64(umbral) { return } titulo := fmt.Sprintf("⚠️ %s alto en %s (%.0f%%)", recurso, nombre, valor) if !models.YaExisteAlertaServidor(titulo, 1) { crearNotifServidor(titulo, fmt.Sprintf("%s al %.0f%% (umbral: %d%%)", recurso, valor, umbral), "servidor_recurso_alto") } } func crearNotifServidor(titulo, cuerpo, evento string) { // Verificar si la notificación del sistema está habilitada para este evento evtCfg := models.GetNotifConfig(evento, "admin") // Por defecto, si no hay config, crear notificación de sistema igualmente if evtCfg == nil || evtCfg.CanalSistema { _ = models.CreateSistemaNotif(&models.SistemaNotificacion{ TipoUsuario: "admin", Titulo: titulo, Cuerpo: cuerpo, Url: "/app/servidor-dashboard", Icono: "🖥️", }) } // Telegram (si está configurado para este evento) if evtCfg != nil && evtCfg.CanalTelegram { msg := fmt.Sprintf("%s\n%s", titulo, cuerpo) sendTelegramAdmin(msg) } }