This commit is contained in:
Lizandro Guarnizo
2026-05-16 21:41:44 -05:00
parent 1511ca97d9
commit 5aad09f1cd
6 changed files with 128 additions and 18 deletions
+1
View File
@@ -67,6 +67,7 @@ func Migrate() {
// Integraciones SaaS (dispatcher de pagos)
&models.SaasApiConfig{},
&models.SaasDispatchLog{},
&models.SaasWebhookInLog{},
// Documentos de clientes
&models.ClienteDocumento{},
// Telegram
+36 -1
View File
@@ -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 {
+18 -16
View File
@@ -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)
}
+13 -1
View File
@@ -208,7 +208,7 @@
Variables disponibles:
<code>{{.ContratoID}}</code> <code>{{.Referencia}}</code> <code>{{.Email}}</code>
<code>{{.Monto}}</code> <code>{{.Moneda}}</code> <code>{{.SaasID}}</code>
<code>{{.SaasSlug}}</code> <code>{{.Fuente}}</code>
<code>{{.SaasSlug}}</code> <code>{{.Fuente}}</code> <code>{{.WebhookToken}}</code>
</p>
</div>
<!-- Activo -->
@@ -216,6 +216,17 @@
<input type="checkbox" id="saasapi-activo" x-model="form.activo" class="w-4 h-4">
<label for="saasapi-activo" class="text-sm">Activo</label>
</div>
<!-- URL de webhook entrante (solo en edición) -->
<template x-if="editMode && form.webhook_token">
<div class="bg-slate-50 border border-slate-200 rounded-lg p-3">
<p class="text-xs font-semibold text-slate-600 mb-1">🔗 URL de webhook entrante (para que el SaaS llame a este sistema)</p>
<div class="flex items-center gap-2">
<code class="text-xs font-mono text-blue-700 break-all flex-1" x-text="window.location.origin + '/webhooks/saas-in/' + form.webhook_token"></code>
<button type="button" @click="navigator.clipboard.writeText(window.location.origin + '/webhooks/saas-in/' + form.webhook_token)" class="text-xs text-gray-500 hover:text-gray-800 flex-shrink-0 border rounded px-2 py-0.5">Copiar</button>
</div>
<p class="text-xs text-slate-400 mt-1">Acepta POST y GET. El token identifica a esta integración de forma única.</p>
</div>
</template>
<!-- Error -->
<p x-show="error" x-text="error" class="text-red-500 text-sm"></p>
</div>
@@ -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;
+57
View File
@@ -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),
})
}
+3
View File
@@ -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)