diff --git a/migrations/migrate.go b/migrations/migrate.go index 6a0bf9a..4e53f80 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -67,6 +67,7 @@ func Migrate() { // Integraciones SaaS (dispatcher de pagos) &models.SaasApiConfig{}, &models.SaasDispatchLog{}, + &models.SaasWebhookInLog{}, // Documentos de clientes &models.ClienteDocumento{}, // Telegram diff --git a/pkg/models/saas_api_config.go b/pkg/models/saas_api_config.go index 740e141..862b118 100644 --- a/pkg/models/saas_api_config.go +++ b/pkg/models/saas_api_config.go @@ -20,14 +20,33 @@ type SaasApiConfig struct { ApiKeyHeader string `json:"api_key_header" gorm:"column:api_key_header"` // ej: "X-API-Key" ApiKeyValue string `json:"api_key_value" gorm:"column:api_key_value;type:text"` // valor secreto // PayloadTemplate es un JSON con marcadores que se reemplazarán antes de enviar. - // Variables disponibles: {{.ContratoID}} {{.Referencia}} {{.Email}} {{.Monto}} {{.Moneda}} {{.SaasID}} {{.SaasSlug}} {{.Fuente}} + // Variables disponibles: {{.ContratoID}} {{.Referencia}} {{.Email}} {{.Monto}} {{.Moneda}} {{.SaasID}} {{.SaasSlug}} {{.Fuente}} {{.WebhookToken}} PayloadTemplate string `json:"payload_template" gorm:"column:payload_template;type:text"` TimeoutSeg int `json:"timeout_seg" gorm:"column:timeout_seg;default:10"` Activo bool `json:"activo" gorm:"column:activo;default:true"` + // WebhookToken es un token único por integración que identifica las llamadas entrantes + // desde el SaaS externo vía POST /webhooks/saas-in/{token} + WebhookToken string `json:"webhook_token" gorm:"column:webhook_token;uniqueIndex;size:64"` } func (SaasApiConfig) TableName() string { return "saas_api_configs" } +// ─── SaasWebhookInLog ───────────────────────────────────────────────────────── + +// SaasWebhookInLog registra cada llamada entrante desde un SaaS externo +// identificado por su WebhookToken único. +type SaasWebhookInLog struct { + gorm.Model + SaasApiConfigID uint `json:"saas_api_config_id" gorm:"column:saas_api_config_id;index"` + Token string `json:"token" gorm:"column:token;size:64;index"` + Metodo string `json:"metodo" gorm:"column:metodo;size:10"` + IP string `json:"ip" gorm:"column:ip;size:64"` + Body string `json:"body" gorm:"column:body;type:text"` + Headers string `json:"headers" gorm:"column:headers;type:text"` +} + +func (SaasWebhookInLog) TableName() string { return "saas_webhook_in_logs" } + // ─── SaasDispatchLog ────────────────────────────────────────────────────────── // SaasDispatchLog registra cada intento de notificación a un SaaS externo. @@ -102,6 +121,22 @@ func DeleteSaasApiConfig(id uint) error { return app.Http.Database.DB.Delete(&SaasApiConfig{}, id).Error } +// GetSaasApiConfigByWebhookToken busca una integración por su token de webhook entrante. +func GetSaasApiConfigByWebhookToken(token string) (*SaasApiConfig, error) { + var item SaasApiConfig + if err := app.Http.Database.DB.Preload("SaasProducto"). + Where("webhook_token = ? AND activo = true", token). + First(&item).Error; err != nil { + return nil, err + } + return &item, nil +} + +// SaveSaasWebhookInLog persiste un log de webhook entrante. +func SaveSaasWebhookInLog(entry *SaasWebhookInLog) error { + return app.Http.Database.DB.Create(entry).Error +} + // ─── Queries SaasDispatchLog ────────────────────────────────────────────────── func SaveSaasDispatchLog(entry *SaasDispatchLog) error { diff --git a/pkg/services/saas_dispatch_service.go b/pkg/services/saas_dispatch_service.go index af22625..b1ed2de 100644 --- a/pkg/services/saas_dispatch_service.go +++ b/pkg/services/saas_dispatch_service.go @@ -16,14 +16,15 @@ import ( // 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 + 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 @@ -89,14 +90,15 @@ func DispatchSaasPaymentNotification(contratoID uint, payerEmail, fuente string, 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, + ContratoID: contratoID, + Referencia: referencia, + Email: payerEmail, + Monto: monto, + Moneda: moneda, + SaasID: saas.ID, + SaasSlug: saas.Slug, + Fuente: fuente, + WebhookToken: cfg.WebhookToken, } dispatchOne(cfg, payload) } diff --git a/resources/views/saas_api.html b/resources/views/saas_api.html index e94db8c..c021df8 100644 --- a/resources/views/saas_api.html +++ b/resources/views/saas_api.html @@ -208,7 +208,7 @@ Variables disponibles: {{.ContratoID}} {{.Referencia}} {{.Email}} {{.Monto}} {{.Moneda}} {{.SaasID}} - {{.SaasSlug}} {{.Fuente}} + {{.SaasSlug}} {{.Fuente}} {{.WebhookToken}}

@@ -216,6 +216,17 @@ + +

@@ -275,6 +286,7 @@ function saasApiApp() { payload_template: item.payload_template || '', timeout_seg: item.timeout_seg || 10, activo: item.activo, + webhook_token: item.webhook_token || '', }; this.error = ''; this.showModal = true; diff --git a/rest/controllers/saas_api_controller.go b/rest/controllers/saas_api_controller.go index 3591ed3..454bd85 100644 --- a/rest/controllers/saas_api_controller.go +++ b/rest/controllers/saas_api_controller.go @@ -1,7 +1,10 @@ package controllers import ( + "encoding/json" + "fmt" "io" + "log" "math" "net/http" "strconv" @@ -9,6 +12,7 @@ import ( "time" "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/helpers" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) @@ -103,6 +107,7 @@ func CreateSaasApiConfig(c *fiber.Ctx) error { PayloadTemplate: req.PayloadTemplate, TimeoutSeg: timeout, Activo: req.Activo, + WebhookToken: helpers.RandomString(32), } if err := models.CreateSaasApiConfig(&item); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) @@ -298,3 +303,55 @@ func GetSaasDispatchLogs(c *fiber.Ctx) error { "limit": limit, }) } + +// ─── Webhook entrante por token ─────────────────────────────────────────────── + +// SaasWebhookInHandler recibe llamadas entrantes del SaaS externo identificadas +// por el WebhookToken único de la integración. No requiere autenticación. +// Ruta pública: POST /webhooks/saas-in/:token +func SaasWebhookInHandler(c *fiber.Ctx) error { + token := strings.TrimSpace(c.Params("token")) + if token == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "token requerido"}) + } + + cfg, err := models.GetSaasApiConfigByWebhookToken(token) + if err != nil { + // Responder 200 para evitar reintentos agresivos de sistemas externos + log.Printf("[SaasWebhookIn] Token desconocido: %s", token) + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": false, "msg": "token no reconocido"}) + } + + // Capturar headers relevantes como JSON + headersMap := map[string]string{} + c.Request().Header.VisitAll(func(k, v []byte) { + key := string(k) + // Solo guardar headers informativos, excluir cookies/auth + if key != "Cookie" && key != "Authorization" { + headersMap[key] = string(v) + } + }) + headersJSON, _ := json.Marshal(headersMap) + + rawBody := string(c.Body()) + + entry := models.SaasWebhookInLog{ + SaasApiConfigID: cfg.ID, + Token: token, + Metodo: c.Method(), + IP: c.IP(), + Body: rawBody, + Headers: string(headersJSON), + } + _ = models.SaveSaasWebhookInLog(&entry) + + log.Printf("[SaasWebhookIn] Integración '%s' (ID=%d) recibió callback desde IP=%s method=%s", + cfg.Nombre, cfg.ID, c.IP(), c.Method()) + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "ok": true, + "integracion": cfg.Nombre, + "saas_api_id": cfg.ID, + "message": fmt.Sprintf("Webhook recibido para integración: %s", cfg.Nombre), + }) +} diff --git a/rest/routes/publicas.go b/rest/routes/publicas.go index f2e4a96..330f9b0 100755 --- a/rest/routes/publicas.go +++ b/rest/routes/publicas.go @@ -20,6 +20,9 @@ func RutasPublicas(web fiber.Router) { web.Post("/webhooks/bold", apiControllers.BoldWebhook) // dLocal requiere respuesta HTTP 200 inmediata. web.Post("/webhooks/dlocal", apiControllers.DlocalWebhook) + // Webhook entrante por integración SaaS (token único por config) + web.Post("/webhooks/saas-in/:token", controllers.SaasWebhookInHandler) + web.Get("/webhooks/saas-in/:token", controllers.SaasWebhookInHandler) // algunos SaaS verifican con GET // ─── Página de confirmación de pago ─────────────────────────────────── web.Get("/pago-exitoso", apiControllers.PagoExitosoPage)