up
This commit is contained in:
@@ -67,6 +67,7 @@ func Migrate() {
|
||||
// Integraciones SaaS (dispatcher de pagos)
|
||||
&models.SaasApiConfig{},
|
||||
&models.SaasDispatchLog{},
|
||||
&models.SaasWebhookInLog{},
|
||||
// Documentos de clientes
|
||||
&models.ClienteDocumento{},
|
||||
// Telegram
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user