From 1a8cc92a22fe3b4bdf3cbd1b71cd4d7f032eeba5 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Wed, 6 May 2026 15:29:14 -0500 Subject: [PATCH] up --- main.go | 3 + migrations/migrate.go | 3 + pkg/models/saas_api_config.go | 122 +++++++++++ pkg/services/saas_dispatch_service.go | 208 +++++++++++++++++++ resources/views/saas_api.html | 233 ++++++++++++++++++++++ resources/views/saas_dispatch_logs.html | 96 +++++++++ rest/controllers/api/bold_controller.go | 2 + rest/controllers/api/dlocal_controller.go | 2 + rest/controllers/saas_api_controller.go | 221 ++++++++++++++++++++ rest/routes/user.go | 18 ++ 10 files changed, 908 insertions(+) create mode 100644 pkg/models/saas_api_config.go create mode 100644 pkg/services/saas_dispatch_service.go create mode 100644 resources/views/saas_api.html create mode 100644 resources/views/saas_dispatch_logs.html create mode 100644 rest/controllers/saas_api_controller.go diff --git a/main.go b/main.go index 4b76902..6c08841 100755 --- a/main.go +++ b/main.go @@ -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() diff --git a/migrations/migrate.go b/migrations/migrate.go index ea88b43..47d95dd 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -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) } diff --git a/pkg/models/saas_api_config.go b/pkg/models/saas_api_config.go new file mode 100644 index 0000000..c6192e7 --- /dev/null +++ b/pkg/models/saas_api_config.go @@ -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 +} diff --git a/pkg/services/saas_dispatch_service.go b/pkg/services/saas_dispatch_service.go new file mode 100644 index 0000000..af22625 --- /dev/null +++ b/pkg/services/saas_dispatch_service.go @@ -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] + "…" +} diff --git a/resources/views/saas_api.html b/resources/views/saas_api.html new file mode 100644 index 0000000..e378f27 --- /dev/null +++ b/resources/views/saas_api.html @@ -0,0 +1,233 @@ + +
+
+

Integraciones SaaS

+

+ Configura los endpoints externos que recibirán la notificación cuando se confirme un pago. + La vinculación funciona así: Contrato → Servicios → SaasProducto → Integración +

+ +
+ Ver logs de despacho → + +
+ + +
+ + + + + + + + + + + + + + + +
NombreSaaSEndpointMétodoEstado
+
+ + +
+ Total: +
+ + + +
+
+
+ + + +
+ + diff --git a/resources/views/saas_dispatch_logs.html b/resources/views/saas_dispatch_logs.html new file mode 100644 index 0000000..df09f2a --- /dev/null +++ b/resources/views/saas_dispatch_logs.html @@ -0,0 +1,96 @@ + +
+
+ +

Logs de despacho

+

Historial de notificaciones enviadas a SaaS externos al confirmar pagos.

+ + +
+ + + + + + + + + + + + + + + + + +
FechaContratoEmailIntegraciónFuenteHTTPEstadoRespuesta
+
+ + +
Cargando…
+ + +
+ Total: +
+ + + +
+
+
+
+ + diff --git a/rest/controllers/api/bold_controller.go b/rest/controllers/api/bold_controller.go index b11437d..4f1167e 100644 --- a/rest/controllers/api/bold_controller.go +++ b/rest/controllers/api/bold_controller.go @@ -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") } } } diff --git a/rest/controllers/api/dlocal_controller.go b/rest/controllers/api/dlocal_controller.go index 394c93e..bb61110 100644 --- a/rest/controllers/api/dlocal_controller.go +++ b/rest/controllers/api/dlocal_controller.go @@ -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) } } } diff --git a/rest/controllers/saas_api_controller.go b/rest/controllers/saas_api_controller.go new file mode 100644 index 0000000..e716a80 --- /dev/null +++ b/rest/controllers/saas_api_controller.go @@ -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, + }) +} diff --git a/rest/routes/user.go b/rest/routes/user.go index ccd34a7..16c6c57 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -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) }