up
This commit is contained in:
@@ -67,6 +67,7 @@ func Migrate() {
|
|||||||
// Integraciones SaaS (dispatcher de pagos)
|
// Integraciones SaaS (dispatcher de pagos)
|
||||||
&models.SaasApiConfig{},
|
&models.SaasApiConfig{},
|
||||||
&models.SaasDispatchLog{},
|
&models.SaasDispatchLog{},
|
||||||
|
&models.SaasWebhookInLog{},
|
||||||
// Documentos de clientes
|
// Documentos de clientes
|
||||||
&models.ClienteDocumento{},
|
&models.ClienteDocumento{},
|
||||||
// Telegram
|
// Telegram
|
||||||
|
|||||||
@@ -20,14 +20,33 @@ type SaasApiConfig struct {
|
|||||||
ApiKeyHeader string `json:"api_key_header" gorm:"column:api_key_header"` // ej: "X-API-Key"
|
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
|
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.
|
// 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"`
|
PayloadTemplate string `json:"payload_template" gorm:"column:payload_template;type:text"`
|
||||||
TimeoutSeg int `json:"timeout_seg" gorm:"column:timeout_seg;default:10"`
|
TimeoutSeg int `json:"timeout_seg" gorm:"column:timeout_seg;default:10"`
|
||||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
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" }
|
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 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// SaasDispatchLog registra cada intento de notificación a un SaaS externo.
|
// 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
|
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 ──────────────────────────────────────────────────
|
// ─── Queries SaasDispatchLog ──────────────────────────────────────────────────
|
||||||
|
|
||||||
func SaveSaasDispatchLog(entry *SaasDispatchLog) error {
|
func SaveSaasDispatchLog(entry *SaasDispatchLog) error {
|
||||||
|
|||||||
@@ -16,14 +16,15 @@ import (
|
|||||||
|
|
||||||
// DispatchPayload contiene las variables disponibles en el PayloadTemplate.
|
// DispatchPayload contiene las variables disponibles en el PayloadTemplate.
|
||||||
type DispatchPayload struct {
|
type DispatchPayload struct {
|
||||||
ContratoID uint
|
ContratoID uint
|
||||||
Referencia string
|
Referencia string
|
||||||
Email string
|
Email string
|
||||||
Monto float64
|
Monto float64
|
||||||
Moneda string
|
Moneda string
|
||||||
SaasID uint
|
SaasID uint
|
||||||
SaasSlug string
|
SaasSlug string
|
||||||
Fuente string // dlocal | bold | manual
|
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
|
// DispatchSaasPaymentNotification notifica a todos los SaaS externos activos
|
||||||
@@ -89,14 +90,15 @@ func DispatchSaasPaymentNotification(contratoID uint, payerEmail, fuente string,
|
|||||||
for _, cfg := range configs {
|
for _, cfg := range configs {
|
||||||
saas := saasMap[cfg.SaasID]
|
saas := saasMap[cfg.SaasID]
|
||||||
payload := DispatchPayload{
|
payload := DispatchPayload{
|
||||||
ContratoID: contratoID,
|
ContratoID: contratoID,
|
||||||
Referencia: referencia,
|
Referencia: referencia,
|
||||||
Email: payerEmail,
|
Email: payerEmail,
|
||||||
Monto: monto,
|
Monto: monto,
|
||||||
Moneda: moneda,
|
Moneda: moneda,
|
||||||
SaasID: saas.ID,
|
SaasID: saas.ID,
|
||||||
SaasSlug: saas.Slug,
|
SaasSlug: saas.Slug,
|
||||||
Fuente: fuente,
|
Fuente: fuente,
|
||||||
|
WebhookToken: cfg.WebhookToken,
|
||||||
}
|
}
|
||||||
dispatchOne(cfg, payload)
|
dispatchOne(cfg, payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@
|
|||||||
Variables disponibles:
|
Variables disponibles:
|
||||||
<code>{{.ContratoID}}</code> <code>{{.Referencia}}</code> <code>{{.Email}}</code>
|
<code>{{.ContratoID}}</code> <code>{{.Referencia}}</code> <code>{{.Email}}</code>
|
||||||
<code>{{.Monto}}</code> <code>{{.Moneda}}</code> <code>{{.SaasID}}</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Activo -->
|
<!-- Activo -->
|
||||||
@@ -216,6 +216,17 @@
|
|||||||
<input type="checkbox" id="saasapi-activo" x-model="form.activo" class="w-4 h-4">
|
<input type="checkbox" id="saasapi-activo" x-model="form.activo" class="w-4 h-4">
|
||||||
<label for="saasapi-activo" class="text-sm">Activo</label>
|
<label for="saasapi-activo" class="text-sm">Activo</label>
|
||||||
</div>
|
</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 -->
|
<!-- Error -->
|
||||||
<p x-show="error" x-text="error" class="text-red-500 text-sm"></p>
|
<p x-show="error" x-text="error" class="text-red-500 text-sm"></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,6 +286,7 @@ function saasApiApp() {
|
|||||||
payload_template: item.payload_template || '',
|
payload_template: item.payload_template || '',
|
||||||
timeout_seg: item.timeout_seg || 10,
|
timeout_seg: item.timeout_seg || 10,
|
||||||
activo: item.activo,
|
activo: item.activo,
|
||||||
|
webhook_token: item.webhook_token || '',
|
||||||
};
|
};
|
||||||
this.error = '';
|
this.error = '';
|
||||||
this.showModal = true;
|
this.showModal = true;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -9,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/helpers"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,6 +107,7 @@ func CreateSaasApiConfig(c *fiber.Ctx) error {
|
|||||||
PayloadTemplate: req.PayloadTemplate,
|
PayloadTemplate: req.PayloadTemplate,
|
||||||
TimeoutSeg: timeout,
|
TimeoutSeg: timeout,
|
||||||
Activo: req.Activo,
|
Activo: req.Activo,
|
||||||
|
WebhookToken: helpers.RandomString(32),
|
||||||
}
|
}
|
||||||
if err := models.CreateSaasApiConfig(&item); err != nil {
|
if err := models.CreateSaasApiConfig(&item); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
@@ -298,3 +303,55 @@ func GetSaasDispatchLogs(c *fiber.Ctx) error {
|
|||||||
"limit": limit,
|
"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)
|
web.Post("/webhooks/bold", apiControllers.BoldWebhook)
|
||||||
// dLocal requiere respuesta HTTP 200 inmediata.
|
// dLocal requiere respuesta HTTP 200 inmediata.
|
||||||
web.Post("/webhooks/dlocal", apiControllers.DlocalWebhook)
|
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 ───────────────────────────────────
|
// ─── Página de confirmación de pago ───────────────────────────────────
|
||||||
web.Get("/pago-exitoso", apiControllers.PagoExitosoPage)
|
web.Get("/pago-exitoso", apiControllers.PagoExitosoPage)
|
||||||
|
|||||||
Reference in New Issue
Block a user