up
This commit is contained in:
@@ -58,6 +58,9 @@ func main() {
|
||||
&models.SaasProducto{},
|
||||
&models.DocCategoria{},
|
||||
&models.DocPagina{},
|
||||
// Integraciones SaaS (dispatcher de pagos)
|
||||
&models.SaasApiConfig{},
|
||||
&models.SaasDispatchLog{},
|
||||
)
|
||||
// Seed automático (idempotente) de módulos del sistema
|
||||
migrations.SeedRenovaciones()
|
||||
|
||||
@@ -64,6 +64,9 @@ func Migrate() {
|
||||
&models.SaasProducto{},
|
||||
&models.DocCategoria{},
|
||||
&models.DocPagina{},
|
||||
// Integraciones SaaS (dispatcher de pagos)
|
||||
&models.SaasApiConfig{},
|
||||
&models.SaasDispatchLog{},
|
||||
); err != nil {
|
||||
log.Fatalf("Error during main migration: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SaasApiConfig almacena la configuración del endpoint externo al que se notifica
|
||||
// cuando un pago es confirmado para un producto SaaS.
|
||||
// La vinculación es: Contrato → Servicios (m2m) → servicio_id ↔ SaasProducto.ServicioID → SaasApiConfig.SaasID
|
||||
type SaasApiConfig struct {
|
||||
gorm.Model
|
||||
SaasID uint `json:"saas_id" gorm:"column:saas_id;not null;index"`
|
||||
SaasProducto SaasProducto `json:"saas_producto" gorm:"foreignKey:SaasID"`
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;not null"` // etiqueta amigable
|
||||
// Pasarela que dispara este callback: dlocal | bold | ambas (default)
|
||||
Pasarela string `json:"pasarela" gorm:"column:pasarela;default:'ambas'"`
|
||||
EndpointURL string `json:"endpoint_url" gorm:"column:endpoint_url;type:text;not null"`
|
||||
Metodo string `json:"metodo" gorm:"column:metodo;default:'POST'"` // POST|PUT|GET
|
||||
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}}
|
||||
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"`
|
||||
}
|
||||
|
||||
func (SaasApiConfig) TableName() string { return "saas_api_configs" }
|
||||
|
||||
// ─── SaasDispatchLog ──────────────────────────────────────────────────────────
|
||||
|
||||
// SaasDispatchLog registra cada intento de notificación a un SaaS externo.
|
||||
type SaasDispatchLog struct {
|
||||
gorm.Model
|
||||
SaasApiConfigID uint `json:"saas_api_config_id" gorm:"column:saas_api_config_id;index"`
|
||||
SaasApiConfig SaasApiConfig `json:"saas_api_config" gorm:"foreignKey:SaasApiConfigID"`
|
||||
ContratoID uint `json:"contrato_id" gorm:"column:contrato_id;index"`
|
||||
Referencia string `json:"referencia" gorm:"column:referencia"`
|
||||
PayerEmail string `json:"payer_email" gorm:"column:payer_email"`
|
||||
Fuente string `json:"fuente" gorm:"column:fuente"` // dlocal | bold | manual
|
||||
HttpStatus int `json:"http_status" gorm:"column:http_status"`
|
||||
Respuesta string `json:"respuesta" gorm:"column:respuesta;type:text"`
|
||||
ErrorMsg string `json:"error_msg" gorm:"column:error_msg;type:text"`
|
||||
Estado string `json:"estado" gorm:"column:estado"` // success | failed
|
||||
Intentos int `json:"intentos" gorm:"column:intentos;default:1"`
|
||||
}
|
||||
|
||||
func (SaasDispatchLog) TableName() string { return "saas_dispatch_logs" }
|
||||
|
||||
// ─── Queries SaasApiConfig ────────────────────────────────────────────────────
|
||||
|
||||
func GetAllSaasApiConfigs(limit, offset int) ([]SaasApiConfig, int64, error) {
|
||||
var items []SaasApiConfig
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&SaasApiConfig{}).Preload("SaasProducto")
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func GetSaasApiConfigByID(id uint) (*SaasApiConfig, error) {
|
||||
var item SaasApiConfig
|
||||
if err := app.Http.Database.DB.Preload("SaasProducto").First(&item, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// GetSaasApiConfigsBySaasIDs devuelve configuraciones activas para los saas_ids dados,
|
||||
// opcionalmente filtradas por pasarela ("dlocal", "bold" o "" para todas).
|
||||
func GetSaasApiConfigsBySaasIDs(saasIDs []uint, pasarela string) ([]SaasApiConfig, error) {
|
||||
if len(saasIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
db := app.Http.Database.DB.
|
||||
Preload("SaasProducto").
|
||||
Where("saas_id IN ? AND activo = ?", saasIDs, true)
|
||||
if pasarela != "" {
|
||||
db = db.Where("pasarela = ? OR pasarela = 'ambas'", pasarela)
|
||||
}
|
||||
var items []SaasApiConfig
|
||||
if err := db.Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func CreateSaasApiConfig(item *SaasApiConfig) error {
|
||||
return app.Http.Database.DB.Create(item).Error
|
||||
}
|
||||
|
||||
func UpdateSaasApiConfig(item *SaasApiConfig) error {
|
||||
return app.Http.Database.DB.Save(item).Error
|
||||
}
|
||||
|
||||
func DeleteSaasApiConfig(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&SaasApiConfig{}, id).Error
|
||||
}
|
||||
|
||||
// ─── Queries SaasDispatchLog ──────────────────────────────────────────────────
|
||||
|
||||
func SaveSaasDispatchLog(entry *SaasDispatchLog) error {
|
||||
return app.Http.Database.DB.Create(entry).Error
|
||||
}
|
||||
|
||||
func GetSaasDispatchLogs(limit, offset int) ([]SaasDispatchLog, int64, error) {
|
||||
var items []SaasDispatchLog
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&SaasDispatchLog{}).Preload("SaasApiConfig")
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
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] + "…"
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<!-- Vista: Integraciones SaaS — Configuración de callbacks de pago -->
|
||||
<div x-data="saasApiApp()" x-init="init()" @keydown.escape="closeModal()" class="bg-white rounded-lg shadow">
|
||||
<div class="container mx-auto p-6 w-full">
|
||||
<h1 class="text-2xl font-bold mb-1">Integraciones SaaS</h1>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
Configura los endpoints externos que recibirán la notificación cuando se confirme un pago.
|
||||
La vinculación funciona así: <span class="font-mono text-xs">Contrato → Servicios → SaasProducto → Integración</span>
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col md:flex-row md:justify-between md:items-center gap-3 mb-4">
|
||||
<a href="/app/saas-api/logs" class="text-sm text-blue-600 underline">Ver logs de despacho →</a>
|
||||
<button @click="openAdd()" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm">+ Nueva integración</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabla -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto w-full text-sm">
|
||||
<thead class="border-b border-gray-200 text-left font-semibold text-gray-600">
|
||||
<tr>
|
||||
<th class="py-2 px-4 border-b">Nombre</th>
|
||||
<th class="py-2 px-4 border-b">SaaS</th>
|
||||
<th class="py-2 px-4 border-b">Endpoint</th>
|
||||
<th class="py-2 px-4 border-b">Método</th>
|
||||
<th class="py-2 px-4 border-b">Estado</th>
|
||||
<th class="py-2 px-4 border-b w-24"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-500">
|
||||
<template x-if="items.length === 0 && !loading">
|
||||
<tr><td colspan="6" class="py-4 text-center text-gray-400">Sin registros</td></tr>
|
||||
</template>
|
||||
<template x-for="item in items" :key="item.ID">
|
||||
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||||
<td class="py-2 px-4 font-medium text-gray-800" x-text="item.nombre"></td>
|
||||
<td class="py-2 px-4 text-xs text-gray-600" x-text="item.SaasProducto ? item.SaasProducto.nombre : '-'"></td>
|
||||
<td class="py-2 px-4">
|
||||
<span class="font-mono text-xs text-blue-700 break-all" x-text="item.endpoint_url"></span>
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
<span x-text="item.metodo" class="bg-gray-100 text-gray-700 text-xs px-2 py-0.5 rounded font-mono"></span>
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
<span x-text="item.activo ? 'Activo' : 'Inactivo'"
|
||||
:class="item.activo ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
class="text-xs px-2 py-0.5 rounded-full"></span>
|
||||
</td>
|
||||
<td class="py-2 px-4 flex gap-2 justify-end">
|
||||
<button @click="openEdit(item)" title="Editar" class="text-blue-500 hover:text-blue-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="confirmDelete(item.ID)" title="Eliminar" class="text-red-400 hover:text-red-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Paginación -->
|
||||
<div class="flex justify-between items-center mt-4 text-sm text-gray-500">
|
||||
<span>Total: <b x-text="total"></b></span>
|
||||
<div class="flex gap-1">
|
||||
<button @click="load(page-1)" :disabled="page<=1" class="px-3 py-1 border rounded disabled:opacity-40">‹</button>
|
||||
<span class="px-3 py-1" x-text="'Pág. ' + page + ' / ' + totalPages"></span>
|
||||
<button @click="load(page+1)" :disabled="page>=totalPages" class="px-3 py-1 border rounded disabled:opacity-40">›</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Modal crear / editar ────────────────────────────────────────────── -->
|
||||
<div x-show="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" style="display:none">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-screen overflow-y-auto">
|
||||
<div class="flex justify-between items-center p-5 border-b">
|
||||
<h2 class="text-lg font-semibold" x-text="editMode ? 'Editar integración' : 'Nueva integración'"></h2>
|
||||
<button @click="closeModal()" class="text-gray-400 hover:text-gray-600 text-xl">✕</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- Nombre -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre / etiqueta <span class="text-red-500">*</span></label>
|
||||
<input type="text" x-model="form.nombre" class="border rounded w-full p-2 text-sm" placeholder="Ej: VCard – confirmar pago">
|
||||
</div>
|
||||
<!-- SaaS -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Producto SaaS <span class="text-red-500">*</span></label>
|
||||
<select x-model.number="form.saas_id" class="border rounded w-full p-2 text-sm">
|
||||
<option value="">— Seleccionar —</option>
|
||||
<template x-for="s in saasOpts" :key="s.ID">
|
||||
<option :value="s.ID" x-text="s.nombre"></option>
|
||||
</template>
|
||||
</select>
|
||||
<p class="text-xs text-gray-400 mt-0.5">El SaaS debe tener un Servicio vinculado para que se active al confirmar contratos.</p>
|
||||
</div>
|
||||
<!-- Endpoint URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Endpoint URL <span class="text-red-500">*</span></label>
|
||||
<input type="url" x-model="form.endpoint_url" class="border rounded w-full p-2 text-sm font-mono" placeholder="https://tu-saas.com/api/pagos/confirmar">
|
||||
</div>
|
||||
<!-- Método + Timeout -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Método HTTP</label>
|
||||
<select x-model="form.metodo" class="border rounded w-full p-2 text-sm">
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="GET">GET</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Timeout (segundos)</label>
|
||||
<input type="number" x-model.number="form.timeout_seg" min="1" max="60" class="border rounded w-full p-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<!-- API Key Header + Value -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Header de autenticación</label>
|
||||
<input type="text" x-model="form.api_key_header" class="border rounded w-full p-2 text-sm font-mono" placeholder="Ej: X-API-Key">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Valor del token / API key</label>
|
||||
<input type="password" x-model="form.api_key_value" class="border rounded w-full p-2 text-sm font-mono" placeholder="••••••••">
|
||||
<p x-show="editMode" class="text-xs text-gray-400 mt-0.5">Dejar vacío para no cambiar.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Payload Template -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Payload JSON (template)</label>
|
||||
<textarea x-model="form.payload_template" rows="5"
|
||||
class="border rounded w-full p-2 text-sm font-mono"
|
||||
placeholder='{"email":"{{.Email}}","contrato_id":{{.ContratoID}},"monto":{{.Monto}},"moneda":"{{.Moneda}}","fuente":"{{.Fuente}}"}'></textarea>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
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>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Activo -->
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
<!-- Error -->
|
||||
<p x-show="error" x-text="error" class="text-red-500 text-sm"></p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 p-5 border-t">
|
||||
<button @click="closeModal()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||||
<button @click="save()" :disabled="saving" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm disabled:opacity-50">
|
||||
<span x-show="!saving">Guardar</span>
|
||||
<span x-show="saving">Guardando…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function saasApiApp() {
|
||||
return {
|
||||
items: [], saasOpts: [],
|
||||
total: 0, totalPages: 1, page: 1, limit: 20,
|
||||
loading: false, showModal: false, editMode: false,
|
||||
saving: false, error: '',
|
||||
form: {},
|
||||
defaultForm() {
|
||||
return { saas_id: 0, nombre: '', endpoint_url: '', metodo: 'POST', api_key_header: '', api_key_value: '', payload_template: '', timeout_seg: 10, activo: true };
|
||||
},
|
||||
async init() { await this.load(1); },
|
||||
async load(p) {
|
||||
if (p < 1 || p > this.totalPages && this.totalPages > 0) return;
|
||||
this.loading = true;
|
||||
this.page = p;
|
||||
const res = await fetch(`/app/loadsaasapi?page=${p}`);
|
||||
const data = await res.json();
|
||||
this.items = data.items || [];
|
||||
this.saasOpts = data.saas || [];
|
||||
this.total = data.total;
|
||||
this.totalPages = data.totalPages;
|
||||
this.loading = false;
|
||||
},
|
||||
openAdd() {
|
||||
this.editMode = false;
|
||||
this.form = this.defaultForm();
|
||||
this.error = '';
|
||||
this.showModal = true;
|
||||
},
|
||||
openEdit(item) {
|
||||
this.editMode = true;
|
||||
this.form = {
|
||||
id: item.ID,
|
||||
saas_id: item.saas_id,
|
||||
nombre: item.nombre,
|
||||
endpoint_url: item.endpoint_url,
|
||||
metodo: item.metodo || 'POST',
|
||||
api_key_header: item.api_key_header || '',
|
||||
api_key_value: '', // no pre-llenar el secreto
|
||||
payload_template: item.payload_template || '',
|
||||
timeout_seg: item.timeout_seg || 10,
|
||||
activo: item.activo,
|
||||
};
|
||||
this.error = '';
|
||||
this.showModal = true;
|
||||
},
|
||||
closeModal() { this.showModal = false; },
|
||||
async save() {
|
||||
this.error = '';
|
||||
if (!this.form.nombre) { this.error = 'El nombre es requerido.'; return; }
|
||||
if (!this.form.saas_id) { this.error = 'Selecciona un producto SaaS.'; return; }
|
||||
if (!this.form.endpoint_url) { this.error = 'El endpoint URL es requerido.'; return; }
|
||||
this.saving = true;
|
||||
const url = this.editMode ? `/app/saas-api/${this.form.id}` : '/app/saas-api';
|
||||
const method = this.editMode ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, { method, headers: {'Content-Type':'application/json'}, body: JSON.stringify(this.form) });
|
||||
this.saving = false;
|
||||
if (!res.ok) { const d = await res.json(); this.error = d.error || 'Error al guardar.'; return; }
|
||||
this.closeModal();
|
||||
await this.load(this.page);
|
||||
},
|
||||
async confirmDelete(id) {
|
||||
if (!confirm('¿Eliminar esta integración?')) return;
|
||||
await fetch(`/app/saas-api/${id}`, { method: 'DELETE' });
|
||||
await this.load(this.page);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,96 @@
|
||||
<!-- Vista: Logs de despacho SaaS — historial de notificaciones de pago -->
|
||||
<div x-data="dispatchLogsApp()" x-init="init()" class="bg-white rounded-lg shadow">
|
||||
<div class="container mx-auto p-6 w-full">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<a href="/app/saas-api" class="text-blue-500 hover:underline text-sm">← Integraciones</a>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold mb-1">Logs de despacho</h1>
|
||||
<p class="text-sm text-gray-500 mb-4">Historial de notificaciones enviadas a SaaS externos al confirmar pagos.</p>
|
||||
|
||||
<!-- Tabla -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto w-full text-sm">
|
||||
<thead class="border-b border-gray-200 text-left font-semibold text-gray-600">
|
||||
<tr>
|
||||
<th class="py-2 px-3 border-b">Fecha</th>
|
||||
<th class="py-2 px-3 border-b">Contrato</th>
|
||||
<th class="py-2 px-3 border-b">Email</th>
|
||||
<th class="py-2 px-3 border-b">Integración</th>
|
||||
<th class="py-2 px-3 border-b">Fuente</th>
|
||||
<th class="py-2 px-3 border-b">HTTP</th>
|
||||
<th class="py-2 px-3 border-b">Estado</th>
|
||||
<th class="py-2 px-3 border-b">Respuesta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-500">
|
||||
<template x-if="items.length === 0 && !loading">
|
||||
<tr><td colspan="8" class="py-4 text-center text-gray-400">Sin registros</td></tr>
|
||||
</template>
|
||||
<template x-for="item in items" :key="item.ID">
|
||||
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||||
<td class="py-2 px-3 text-xs whitespace-nowrap" x-text="fmtDate(item.CreatedAt)"></td>
|
||||
<td class="py-2 px-3 font-mono text-xs" x-text="item.referencia || ('#' + item.contrato_id)"></td>
|
||||
<td class="py-2 px-3 text-xs" x-text="item.payer_email || '-'"></td>
|
||||
<td class="py-2 px-3 text-xs" x-text="item.SaasApiConfig ? item.SaasApiConfig.nombre : ('#' + item.saas_api_config_id)"></td>
|
||||
<td class="py-2 px-3">
|
||||
<span x-text="item.fuente"
|
||||
:class="item.fuente === 'dlocal' ? 'bg-purple-100 text-purple-700' : item.fuente === 'bold' ? 'bg-orange-100 text-orange-700' : 'bg-gray-100 text-gray-600'"
|
||||
class="text-xs px-2 py-0.5 rounded-full capitalize"></span>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-xs font-mono" x-text="item.http_status || '-'"></td>
|
||||
<td class="py-2 px-3">
|
||||
<span x-text="item.estado"
|
||||
:class="item.estado === 'success' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
class="text-xs px-2 py-0.5 rounded-full"></span>
|
||||
</td>
|
||||
<td class="py-2 px-3 max-w-xs">
|
||||
<span x-show="!item.error_msg && item.respuesta" class="font-mono text-xs text-gray-600 truncate block" x-text="item.respuesta"></span>
|
||||
<span x-show="item.error_msg" class="font-mono text-xs text-red-500 truncate block" x-text="item.error_msg"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="py-6 text-center text-gray-400 text-sm">Cargando…</div>
|
||||
|
||||
<!-- Paginación -->
|
||||
<div class="flex justify-between items-center mt-4 text-sm text-gray-500">
|
||||
<span>Total: <b x-text="total"></b></span>
|
||||
<div class="flex gap-1">
|
||||
<button @click="load(page-1)" :disabled="page<=1" class="px-3 py-1 border rounded disabled:opacity-40">‹</button>
|
||||
<span class="px-3 py-1" x-text="'Pág. ' + page + ' / ' + totalPages"></span>
|
||||
<button @click="load(page+1)" :disabled="page>=totalPages" class="px-3 py-1 border rounded disabled:opacity-40">›</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function dispatchLogsApp() {
|
||||
return {
|
||||
items: [],
|
||||
total: 0, totalPages: 1, page: 1, limit: 30,
|
||||
loading: false,
|
||||
async init() { await this.load(1); },
|
||||
async load(p) {
|
||||
if (p < 1 || (this.totalPages > 0 && p > this.totalPages)) return;
|
||||
this.loading = true;
|
||||
this.page = p;
|
||||
const res = await fetch(`/app/loadsaasdispatchlogs?page=${p}`);
|
||||
const data = await res.json();
|
||||
this.items = data.items || [];
|
||||
this.total = data.total;
|
||||
this.totalPages = data.totalPages;
|
||||
this.loading = false;
|
||||
},
|
||||
fmtDate(s) {
|
||||
if (!s) return '-';
|
||||
const d = new Date(s);
|
||||
return d.toLocaleDateString('es-CO') + ' ' + d.toLocaleTimeString('es-CO', {hour:'2-digit', minute:'2-digit'});
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -118,6 +118,8 @@ func BoldWebhook(c *fiber.Ctx) error {
|
||||
log.Printf("[BOLD] Webhook: error marcando contrato %d como pagado: %v", contratoID, err)
|
||||
} else {
|
||||
log.Printf("[BOLD] Webhook: contrato %d marcado como pagado", contratoID)
|
||||
// Notificar SaaS externos asociados al contrato (goroutine, no bloquea respuesta)
|
||||
go services.DispatchSaasPaymentNotification(contratoID, payerEmail, "bold", float64(monto), "COP")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +324,8 @@ func DlocalWebhook(c *fiber.Ctx) error {
|
||||
log.Printf("[DLOCAL] Webhook: error marcando contrato %d como pagado: %v", contratoID, err)
|
||||
} else {
|
||||
log.Printf("[DLOCAL] Webhook: contrato %d marcado como pagado", contratoID)
|
||||
// Notificar SaaS externos asociados al contrato (goroutine, no bloquea respuesta)
|
||||
go services.DispatchSaasPaymentNotification(contratoID, payerEmail, "dlocal", monto, moneda)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// ─── Panel: lista de configuraciones ─────────────────────────────────────────
|
||||
|
||||
// SaasApiIndex renderiza la vista del panel de integraciones SaaS.
|
||||
func SaasApiIndex(c *fiber.Ctx) error {
|
||||
data := fiber.Map{
|
||||
"user": c.Locals("user").(map[string]interface{}),
|
||||
"modules": c.Locals("userModules"),
|
||||
}
|
||||
if err := c.Render("saas_api", data, "layouts/main"); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSaasApiConfigs devuelve la lista paginada en JSON.
|
||||
func GetSaasApiConfigs(c *fiber.Ctx) error {
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := 20
|
||||
offset := (page - 1) * limit
|
||||
|
||||
items, total, err := models.GetAllSaasApiConfigs(limit, offset)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
saasOpts, _ := models.GetAllSaasProductosSelect()
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"saas": saasOpts,
|
||||
"total": total,
|
||||
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateSaasApiConfig crea una nueva configuración de integración.
|
||||
func CreateSaasApiConfig(c *fiber.Ctx) error {
|
||||
type Req struct {
|
||||
SaasID uint `json:"saas_id"`
|
||||
Nombre string `json:"nombre"`
|
||||
Pasarela string `json:"pasarela"`
|
||||
EndpointURL string `json:"endpoint_url"`
|
||||
Metodo string `json:"metodo"`
|
||||
ApiKeyHeader string `json:"api_key_header"`
|
||||
ApiKeyValue string `json:"api_key_value"`
|
||||
PayloadTemplate string `json:"payload_template"`
|
||||
TimeoutSeg int `json:"timeout_seg"`
|
||||
Activo bool `json:"activo"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
if req.SaasID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "saas_id es requerido"})
|
||||
}
|
||||
if strings.TrimSpace(req.Nombre) == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nombre es requerido"})
|
||||
}
|
||||
if strings.TrimSpace(req.EndpointURL) == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "endpoint_url es requerido"})
|
||||
}
|
||||
metodo := strings.ToUpper(strings.TrimSpace(req.Metodo))
|
||||
if metodo == "" {
|
||||
metodo = "POST"
|
||||
}
|
||||
pasarela := strings.ToLower(strings.TrimSpace(req.Pasarela))
|
||||
if pasarela == "" {
|
||||
pasarela = "ambas"
|
||||
}
|
||||
timeout := req.TimeoutSeg
|
||||
if timeout <= 0 {
|
||||
timeout = 10
|
||||
}
|
||||
|
||||
item := models.SaasApiConfig{
|
||||
SaasID: req.SaasID,
|
||||
Nombre: req.Nombre,
|
||||
Pasarela: pasarela,
|
||||
EndpointURL: req.EndpointURL,
|
||||
Metodo: metodo,
|
||||
ApiKeyHeader: req.ApiKeyHeader,
|
||||
ApiKeyValue: req.ApiKeyValue,
|
||||
PayloadTemplate: req.PayloadTemplate,
|
||||
TimeoutSeg: timeout,
|
||||
Activo: req.Activo,
|
||||
}
|
||||
if err := models.CreateSaasApiConfig(&item); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(item)
|
||||
}
|
||||
|
||||
// UpdateSaasApiConfig actualiza una configuración existente.
|
||||
func UpdateSaasApiConfig(c *fiber.Ctx) error {
|
||||
idParam := c.Params("id")
|
||||
id64, err := strconv.ParseUint(idParam, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
|
||||
item, err := models.GetSaasApiConfigByID(uint(id64))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "no encontrado"})
|
||||
}
|
||||
|
||||
type Req struct {
|
||||
SaasID uint `json:"saas_id"`
|
||||
Nombre string `json:"nombre"`
|
||||
Pasarela string `json:"pasarela"`
|
||||
EndpointURL string `json:"endpoint_url"`
|
||||
Metodo string `json:"metodo"`
|
||||
ApiKeyHeader string `json:"api_key_header"`
|
||||
ApiKeyValue string `json:"api_key_value"`
|
||||
PayloadTemplate string `json:"payload_template"`
|
||||
TimeoutSeg int `json:"timeout_seg"`
|
||||
Activo bool `json:"activo"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
|
||||
if req.SaasID != 0 {
|
||||
item.SaasID = req.SaasID
|
||||
}
|
||||
if strings.TrimSpace(req.Nombre) != "" {
|
||||
item.Nombre = req.Nombre
|
||||
}
|
||||
pasarela := strings.ToLower(strings.TrimSpace(req.Pasarela))
|
||||
if pasarela != "" {
|
||||
item.Pasarela = pasarela
|
||||
}
|
||||
if strings.TrimSpace(req.EndpointURL) != "" {
|
||||
item.EndpointURL = req.EndpointURL
|
||||
}
|
||||
metodo := strings.ToUpper(strings.TrimSpace(req.Metodo))
|
||||
if metodo != "" {
|
||||
item.Metodo = metodo
|
||||
}
|
||||
item.ApiKeyHeader = req.ApiKeyHeader
|
||||
// Solo actualizar api_key_value si se envió un valor (para no borrar el secreto con "")
|
||||
if req.ApiKeyValue != "" {
|
||||
item.ApiKeyValue = req.ApiKeyValue
|
||||
}
|
||||
item.PayloadTemplate = req.PayloadTemplate
|
||||
if req.TimeoutSeg > 0 {
|
||||
item.TimeoutSeg = req.TimeoutSeg
|
||||
}
|
||||
item.Activo = req.Activo
|
||||
|
||||
if err := models.UpdateSaasApiConfig(item); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(item)
|
||||
}
|
||||
|
||||
// DeleteSaasApiConfig elimina una configuración.
|
||||
func DeleteSaasApiConfig(c *fiber.Ctx) error {
|
||||
idParam := c.Params("id")
|
||||
id64, err := strconv.ParseUint(idParam, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
if err := models.DeleteSaasApiConfig(uint(id64)); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// ─── Panel: logs de despacho ──────────────────────────────────────────────────
|
||||
|
||||
// SaasDispatchLogIndex renderiza la vista de logs de despacho.
|
||||
func SaasDispatchLogIndex(c *fiber.Ctx) error {
|
||||
data := fiber.Map{
|
||||
"user": c.Locals("user").(map[string]interface{}),
|
||||
"modules": c.Locals("userModules"),
|
||||
}
|
||||
if err := c.Render("saas_dispatch_logs", data, "layouts/main"); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSaasDispatchLogs devuelve los logs de despacho paginados.
|
||||
func GetSaasDispatchLogs(c *fiber.Ctx) error {
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := 30
|
||||
offset := (page - 1) * limit
|
||||
|
||||
items, total, err := models.GetSaasDispatchLogs(limit, offset)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
@@ -170,4 +170,22 @@ func UserRoutes(app fiber.Router) {
|
||||
// ─── Documentación: lectura privada (usuario logueado) ───────────────────
|
||||
protected.Get("/docs/:saas", middlewares.MenuMiddleware, controllers.DocsPrivadoIndex)
|
||||
protected.Get("/docs/:saas/:slug", middlewares.MenuMiddleware, controllers.DocsPrivadaPagina)
|
||||
|
||||
// ─── Integraciones SaaS (dispatcher de pagos) ─────────────────────────────
|
||||
protected.Get("/saas-api", middlewares.MenuMiddleware, controllers.SaasApiIndex)
|
||||
protected.Get("/loadsaasapi", controllers.GetSaasApiConfigs)
|
||||
protected.Post("/saas-api", controllers.CreateSaasApiConfig)
|
||||
protected.Put("/saas-api/:id", controllers.UpdateSaasApiConfig)
|
||||
protected.Delete("/saas-api/:id", controllers.DeleteSaasApiConfig)
|
||||
|
||||
// Logs de despacho
|
||||
protected.Get("/saas-api/logs", middlewares.MenuMiddleware, controllers.SaasDispatchLogIndex)
|
||||
protected.Get("/loadsaasdispatchlogs", controllers.GetSaasDispatchLogs)
|
||||
|
||||
// ─── Planes dLocal (gestión desde panel protegido) ────────────────────────
|
||||
protected.Get("/dlocal/planes", apiControllers.SeePlanes)
|
||||
protected.Post("/dlocal/planes", apiControllers.CreatePlan)
|
||||
protected.Get("/dlocal/planes/:planID", apiControllers.SeePlan)
|
||||
protected.Patch("/dlocal/planes/:planID", apiControllers.UpdatedPlan)
|
||||
protected.Patch("/dlocal/planes/:planID/subscription/:subscriptionId/deactivate", apiControllers.DeactivatePlan)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user