package services import ( "bytes" "fmt" "io" "log" "net/http" "strings" "text/template" "time" "github.com/sujit-baniya/fiber-boilerplate/app" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) // DispatchPayload contiene las variables disponibles en el PayloadTemplate. type DispatchPayload struct { ContratoID uint Referencia string Email string Monto float64 Moneda string SaasID uint SaasSlug string Fuente string // dlocal | bold | manual WebhookToken string // token único de la integración (para incluirlo en el payload saliente) } // DispatchSaasPaymentNotification notifica a todos los SaaS externos activos // asociados al contrato. Se ejecuta en goroutine separada desde los webhooks. // // Cadena de vinculación: // // Contrato → contrato_servicios (m2m) → servicios.id // servicios.id ↔ saas_productos.servicio_id → saas_productos.id // saas_productos.id → saas_api_configs.saas_id (activo = true) func DispatchSaasPaymentNotification(contratoID uint, payerEmail, fuente string, monto float64, moneda string) { referencia := fmt.Sprintf("contrato-%d", contratoID) // 1. Cargar contrato con sus servicios contrato, err := models.GetContratoByID(contratoID) if err != nil { log.Printf("[DISPATCH] Error cargando contrato %d: %v", contratoID, err) return } if len(contrato.Servicios) == 0 { log.Printf("[DISPATCH] Contrato %d sin servicios — nada que despachar", contratoID) return } // 2. Recopilar IDs de servicios del contrato servicioIDs := make([]uint, len(contrato.Servicios)) for i, s := range contrato.Servicios { servicioIDs[i] = s.ID } // 3. Buscar SaasProductos cuyo servicio_id esté en los servicios del contrato var saasProductos []models.SaasProducto if err := app.Http.Database.DB. Where("servicio_id IN ? AND activo = ?", servicioIDs, true). Find(&saasProductos).Error; err != nil { log.Printf("[DISPATCH] Error buscando SaasProductos para contrato %d: %v", contratoID, err) return } if len(saasProductos) == 0 { log.Printf("[DISPATCH] Contrato %d: ningún SaasProducto vinculado a sus servicios", contratoID) return } // 4. Recopilar IDs de SaaS saasIDs := make([]uint, len(saasProductos)) saasMap := make(map[uint]models.SaasProducto, len(saasProductos)) for i, sp := range saasProductos { saasIDs[i] = sp.ID saasMap[sp.ID] = sp } // 5. Obtener configuraciones API activas para esos SaaS, filtrando por pasarela configs, err := models.GetSaasApiConfigsBySaasIDs(saasIDs, fuente) // fuente = "dlocal" | "bold" if err != nil { log.Printf("[DISPATCH] Error obteniendo SaasApiConfigs: %v", err) return } if len(configs) == 0 { log.Printf("[DISPATCH] Contrato %d: ninguna SaasApiConfig activa encontrada", contratoID) return } // 6. Enviar notificación a cada endpoint for _, cfg := range configs { saas := saasMap[cfg.SaasID] payload := DispatchPayload{ ContratoID: contratoID, Referencia: referencia, Email: payerEmail, Monto: monto, Moneda: moneda, SaasID: saas.ID, SaasSlug: saas.Slug, Fuente: fuente, WebhookToken: cfg.WebhookToken, } dispatchOne(cfg, payload) } } // dispatchOne realiza la llamada HTTP a un endpoint e inserta el log correspondiente. func dispatchOne(cfg models.SaasApiConfig, payload DispatchPayload) { logEntry := models.SaasDispatchLog{ SaasApiConfigID: cfg.ID, ContratoID: payload.ContratoID, Referencia: payload.Referencia, PayerEmail: payload.Email, Fuente: payload.Fuente, Intentos: 1, } // Renderizar payload template body, err := renderTemplate(cfg.PayloadTemplate, payload) if err != nil { logEntry.Estado = "failed" logEntry.ErrorMsg = fmt.Sprintf("template error: %v", err) _ = models.SaveSaasDispatchLog(&logEntry) log.Printf("[DISPATCH] Config %d — error de template: %v", cfg.ID, err) return } // Construir request metodo := strings.ToUpper(cfg.Metodo) if metodo == "" { metodo = "POST" } timeout := cfg.TimeoutSeg if timeout <= 0 { timeout = 10 } client := &http.Client{Timeout: time.Duration(timeout) * time.Second} var reqBody io.Reader if metodo != "GET" { reqBody = bytes.NewBufferString(body) } req, err := http.NewRequest(metodo, cfg.EndpointURL, reqBody) if err != nil { logEntry.Estado = "failed" logEntry.ErrorMsg = fmt.Sprintf("error creando request: %v", err) _ = models.SaveSaasDispatchLog(&logEntry) log.Printf("[DISPATCH] Config %d — error creando request: %v", cfg.ID, err) return } if metodo != "GET" { req.Header.Set("Content-Type", "application/json") } if cfg.ApiKeyHeader != "" && cfg.ApiKeyValue != "" { req.Header.Set(cfg.ApiKeyHeader, cfg.ApiKeyValue) } // Ejecutar llamada resp, err := client.Do(req) if err != nil { logEntry.Estado = "failed" logEntry.ErrorMsg = fmt.Sprintf("error HTTP: %v", err) _ = models.SaveSaasDispatchLog(&logEntry) log.Printf("[DISPATCH] Config %d — error llamando %s: %v", cfg.ID, cfg.EndpointURL, err) return } defer resp.Body.Close() respBytes, _ := io.ReadAll(resp.Body) respBody := truncateStr(string(respBytes), 2000) logEntry.HttpStatus = resp.StatusCode logEntry.Respuesta = respBody if resp.StatusCode >= 200 && resp.StatusCode < 300 { logEntry.Estado = "success" } else { logEntry.Estado = "failed" } _ = models.SaveSaasDispatchLog(&logEntry) log.Printf("[DISPATCH] Config %d → %s %s — HTTP %d", cfg.ID, metodo, cfg.EndpointURL, resp.StatusCode) } // renderTemplate aplica text/template sobre el PayloadTemplate del config. func renderTemplate(tmplStr string, data DispatchPayload) (string, error) { if tmplStr == "" { // Si no hay template, enviar payload JSON mínimo return fmt.Sprintf(`{"contrato_id":%d,"referencia":"%s","email":"%s","monto":%.2f,"moneda":"%s","fuente":"%s"}`, data.ContratoID, data.Referencia, data.Email, data.Monto, data.Moneda, data.Fuente), nil } t, err := template.New("payload").Parse(tmplStr) if err != nil { return "", err } var buf bytes.Buffer if err := t.Execute(&buf, data); err != nil { return "", err } return buf.String(), nil } func truncateStr(s string, max int) string { if len(s) <= max { return s } return s[:max] + "…" }