Files
soft_usite/pkg/services/saas_dispatch_service.go
T
2026-05-16 21:41:44 -05:00

211 lines
6.1 KiB
Go

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] + "…"
}