diff --git a/migrations/migrate.go b/migrations/migrate.go
index c12e103..719ede6 100755
--- a/migrations/migrate.go
+++ b/migrations/migrate.go
@@ -53,6 +53,9 @@ func Migrate() {
// Integraciones externas
&models.HostingerConfig{},
&models.CloudflareConfig{},
+ // Pasarelas de pago
+ &models.BoldConfig{},
+ &models.BoldWebhookLog{},
); err != nil {
log.Fatalf("Error during main migration: %v", err)
}
@@ -78,6 +81,9 @@ func Migrate() {
// Insertar módulo y submódulos de Integraciones externas
SeedIntegraciones()
+ // Insertar submódulo "Pasarelas de Pago" en el módulo Integraciones
+ SeedPasarelas()
+
log.Println("Migration Completed...")
}
@@ -257,3 +263,57 @@ func SeedIntegraciones() {
log.Println("[SEED] Seed de Integraciones completado.")
}
+
+// SeedPasarelas agrega el submódulo "Pasarelas de Pago" al módulo "Integraciones"
+// y lo asigna a todos los roles. Es idempotente.
+func SeedPasarelas() {
+ db := app.Http.Database.DB
+
+ // Obtener el módulo "Integraciones" (debe existir luego de SeedIntegraciones)
+ var modulo models.Modules
+ if err := db.Where("title = ?", "Integraciones").First(&modulo).Error; err != nil {
+ log.Printf("[SEED] Módulo 'Integraciones' no encontrado para SeedPasarelas: %v", err)
+ return
+ }
+
+ entries := []struct{ title, desc, url string }{
+ {"Pasarelas de Pago", "Configuración de Bold, dLocal y otras pasarelas", "/app/pasarelas-pago"},
+ }
+
+ var insertados []models.Submodules
+ for _, e := range entries {
+ var sub models.Submodules
+ if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
+ sub = models.Submodules{
+ Title: e.title,
+ Description: e.desc,
+ Url: e.url,
+ ModuleId: modulo.ID,
+ ModifiedAt: time.Now(),
+ }
+ if err := db.Create(&sub).Error; err != nil {
+ log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
+ continue
+ }
+ log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
+ } else {
+ log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
+ }
+ insertados = append(insertados, sub)
+ }
+
+ var roles []models.Roles
+ if err := db.Find(&roles).Error; err != nil {
+ log.Printf("[SEED] Error obteniendo roles: %v", err)
+ return
+ }
+ for _, rol := range roles {
+ if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
+ log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err)
+ } else {
+ log.Printf("[SEED] Submódulo Pasarelas asignado al rol '%s'", rol.Name)
+ }
+ }
+
+ log.Println("[SEED] Seed de Pasarelas completado.")
+}
diff --git a/pkg/models/bold_config.go b/pkg/models/bold_config.go
new file mode 100644
index 0000000..15c1502
--- /dev/null
+++ b/pkg/models/bold_config.go
@@ -0,0 +1,106 @@
+package models
+
+import (
+ "github.com/sujit-baniya/fiber-boilerplate/app"
+ "gorm.io/gorm"
+)
+
+// BoldConfig almacena las credenciales de Bold (pasarela de pagos colombiana).
+// Solo un registro puede estar activo a la vez.
+type BoldConfig struct {
+ gorm.Model
+ // Claves de producción
+ ApiKeyProd string `json:"api_key_prod" gorm:"column:api_key_prod;type:text"`
+ SecretKeyProd string `json:"secret_key_prod" gorm:"column:secret_key_prod;type:text"`
+ // Claves de prueba / test
+ ApiKeyTest string `json:"api_key_test" gorm:"column:api_key_test;type:text"`
+ // En modo test la secret key es cadena vacía según la doc oficial
+ SecretKeyTest string `json:"secret_key_test" gorm:"column:secret_key_test;type:text"`
+ // Modo activo: "test" | "production"
+ Modo string `json:"modo" gorm:"column:modo;default:'test'"`
+ // URL a la que Bold redirige al usuario tras el pago
+ CallbackUrl string `json:"callback_url" gorm:"column:callback_url;type:text"`
+ // Nota interna
+ Nota string `json:"nota" gorm:"column:nota;type:text"`
+ Activo bool `json:"activo" gorm:"column:activo;default:true"`
+}
+
+func (BoldConfig) TableName() string { return "bold_config" }
+
+// GetBoldConfig retorna la configuración activa.
+func GetBoldConfig() (*BoldConfig, error) {
+ var item BoldConfig
+ if err := app.Http.Database.DB.Where("activo = ?", true).Order("id DESC").First(&item).Error; err != nil {
+ return nil, err
+ }
+ return &item, nil
+}
+
+// SaveBoldConfig desactiva la config previa y guarda la nueva (o actualiza si ya tiene ID).
+func SaveBoldConfig(s BoldConfig) error {
+ app.Http.Database.DB.Model(&BoldConfig{}).
+ Where("activo = ?", true).
+ Update("activo", false)
+ s.Activo = true
+ if s.ID > 0 {
+ return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{
+ "api_key_prod": s.ApiKeyProd,
+ "secret_key_prod": s.SecretKeyProd,
+ "api_key_test": s.ApiKeyTest,
+ "secret_key_test": s.SecretKeyTest,
+ "modo": s.Modo,
+ "callback_url": s.CallbackUrl,
+ "nota": s.Nota,
+ "activo": true,
+ }).Error
+ }
+ return app.Http.Database.DB.Create(&s).Error
+}
+
+// ─── Webhook log para idempotencia ───────────────────────────────────────────
+
+// BoldWebhookLog registra cada notificación recibida de Bold.
+// La restricción UNIQUE sobre notification_id evita procesar duplicados.
+type BoldWebhookLog struct {
+ gorm.Model
+ NotificationID string `json:"notification_id" gorm:"column:notification_id;uniqueIndex;type:varchar(64);not null"`
+ Tipo string `json:"tipo" gorm:"column:tipo;type:varchar(30)"`
+ PaymentID string `json:"payment_id" gorm:"column:payment_id;type:varchar(64)"`
+ Referencia string `json:"referencia" gorm:"column:referencia;type:varchar(120)"`
+ PayerEmail string `json:"payer_email" gorm:"column:payer_email;type:varchar(255)"`
+ Monto int64 `json:"monto" gorm:"column:monto"`
+ Procesado bool `json:"procesado" gorm:"column:procesado;default:false"`
+ // Body crudo del webhook para auditoría
+ Raw string `json:"raw" gorm:"column:raw;type:text"`
+}
+
+func (BoldWebhookLog) TableName() string { return "bold_webhook_log" }
+
+// IsBoldNotificationDuplicate intenta insertar el log. Devuelve true si ya existía.
+func IsBoldNotificationDuplicate(notificationID string) bool {
+ result := app.Http.Database.DB.
+ Where("notification_id = ?", notificationID).
+ First(&BoldWebhookLog{})
+ return result.Error == nil // nil error = registro encontrado = duplicado
+}
+
+// SaveBoldWebhookLog guarda el log del webhook.
+func SaveBoldWebhookLog(entry BoldWebhookLog) error {
+ return app.Http.Database.DB.Create(&entry).Error
+}
+
+// MarkBoldWebhookProcessed marca la notificación como procesada.
+func MarkBoldWebhookProcessed(notificationID string) {
+ app.Http.Database.DB.Model(&BoldWebhookLog{}).
+ Where("notification_id = ?", notificationID).
+ Update("procesado", true)
+}
+
+// GetBoldWebhookLogs devuelve los últimos N registros del log.
+func GetBoldWebhookLogs(limit int) ([]BoldWebhookLog, error) {
+ var logs []BoldWebhookLog
+ if err := app.Http.Database.DB.Order("id DESC").Limit(limit).Find(&logs).Error; err != nil {
+ return nil, err
+ }
+ return logs, nil
+}
diff --git a/pkg/services/bold_service.go b/pkg/services/bold_service.go
new file mode 100644
index 0000000..4655008
--- /dev/null
+++ b/pkg/services/bold_service.go
@@ -0,0 +1,159 @@
+package services
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
+)
+
+const boldAPIBase = "https://integrations.api.bold.co"
+
+// ─── Estructuras de request/response ─────────────────────────────────────────
+
+// BoldPaymentLinkRequest es el payload para crear un link de pago en Bold.
+type BoldPaymentLinkRequest struct {
+ AmountType string `json:"amount_type"`
+ Amount BoldAmountField `json:"amount"`
+ Description string `json:"description"`
+ CallbackURL string `json:"callback_url"`
+ PayerEmail string `json:"payer_email,omitempty"`
+ Reference string `json:"reference"`
+}
+
+// BoldAmountField representa el campo de monto (COP sin centavos).
+type BoldAmountField struct {
+ Currency string `json:"currency"`
+ TotalAmount int64 `json:"total_amount"`
+}
+
+// BoldPaymentLinkResponse es la respuesta de la API al crear el link.
+type BoldPaymentLinkResponse struct {
+ Payload struct {
+ PaymentLink string `json:"payment_link"`
+ URL string `json:"url"`
+ } `json:"payload"`
+}
+
+// BoldWebhookEvent es la estructura del JSON que Bold envía en cada notificación.
+type BoldWebhookEvent struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Subject string `json:"subject"`
+ Data struct {
+ PaymentID string `json:"payment_id"`
+ Amount struct {
+ Total int64 `json:"total"`
+ Currency string `json:"currency"`
+ } `json:"amount"`
+ PayerEmail string `json:"payer_email"`
+ Metadata struct {
+ Reference string `json:"reference"`
+ } `json:"metadata"`
+ } `json:"data"`
+}
+
+// ─── Funciones del servicio ───────────────────────────────────────────────────
+
+// boldAPIKey devuelve la API key correcta según el modo configurado.
+func boldAPIKey(cfg *models.BoldConfig) string {
+ if cfg.Modo == "production" {
+ return cfg.ApiKeyProd
+ }
+ return cfg.ApiKeyTest
+}
+
+// boldSecretKey devuelve la secret key según el modo.
+// En test la spec oficial indica cadena vacía.
+func boldSecretKey(cfg *models.BoldConfig) string {
+ if cfg.Modo == "production" {
+ return cfg.SecretKeyProd
+ }
+ return cfg.SecretKeyTest
+}
+
+// CreateBoldPaymentLink crea un link de pago en Bold y devuelve la respuesta.
+func CreateBoldPaymentLink(cfg *models.BoldConfig, req BoldPaymentLinkRequest) (*BoldPaymentLinkResponse, error) {
+ if req.CallbackURL == "" {
+ req.CallbackURL = cfg.CallbackUrl
+ }
+
+ body, err := json.Marshal(req)
+ if err != nil {
+ return nil, fmt.Errorf("bold: marshal request: %w", err)
+ }
+
+ httpReq, err := http.NewRequest("POST", boldAPIBase+"/online/link/v1", bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ httpReq.Header.Set("Authorization", "x-api-key "+boldAPIKey(cfg))
+ httpReq.Header.Set("Content-Type", "application/json")
+ httpReq.Header.Set("Accept", "application/json")
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(httpReq)
+ if err != nil {
+ return nil, fmt.Errorf("bold: http call: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respBody, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("bold API status %d: %s", resp.StatusCode, string(respBody))
+ }
+
+ var result BoldPaymentLinkResponse
+ if err := json.Unmarshal(respBody, &result); err != nil {
+ return nil, fmt.Errorf("bold: unmarshal response: %w", err)
+ }
+ return &result, nil
+}
+
+// GetBoldPaymentLinkStatus consulta el estado de un payment link existente.
+func GetBoldPaymentLinkStatus(cfg *models.BoldConfig, linkID string) ([]byte, error) {
+ url := fmt.Sprintf("%s/online/link/v1/%s", boldAPIBase, linkID)
+ httpReq, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ httpReq.Header.Set("Authorization", "x-api-key "+boldAPIKey(cfg))
+ httpReq.Header.Set("Accept", "application/json")
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(httpReq)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ return io.ReadAll(resp.Body)
+}
+
+// VerifyBoldSignature verifica la firma HMAC-SHA256 del webhook.
+// Algoritmo: encoded = base64(rawBody); computed = HMAC-SHA256(encoded, secretKey) en hex.
+// En modo test la secretKey es cadena vacía.
+func VerifyBoldSignature(rawBody []byte, signature string, cfg *models.BoldConfig) bool {
+ secret := boldSecretKey(cfg)
+ encoded := base64.StdEncoding.EncodeToString(rawBody)
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(encoded))
+ computed := hex.EncodeToString(mac.Sum(nil))
+ return hmac.Equal([]byte(computed), []byte(signature))
+}
+
+// ParseBoldWebhookEvent parsea el JSON crudo del webhook en la estructura BoldWebhookEvent.
+func ParseBoldWebhookEvent(rawBody []byte) (*BoldWebhookEvent, error) {
+ var event BoldWebhookEvent
+ if err := json.Unmarshal(rawBody, &event); err != nil {
+ return nil, err
+ }
+ return &event, nil
+}
diff --git a/resources/views/pasarelas_pago.html b/resources/views/pasarelas_pago.html
new file mode 100644
index 0000000..99d2e28
--- /dev/null
+++ b/resources/views/pasarelas_pago.html
@@ -0,0 +1,548 @@
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
Pasarelas de Pago
+
Configura las credenciales de cada pasarela y selecciona el entorno activo.
+
+
+
+
+
+ Bold —
+
+
+
+ dLocal —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Credenciales Bold
+
+
Panel Bold → Configuración → Integraciones → API
+
+
+
+
+
+
+
+
+ Entorno de prueba
+
+
+
+
+
+
+
+
+ Producción
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Estado actual
+
+
+ Entorno
+
+
+
+ API Key (activa)
+
+
+
+ Callback URL
+
+
+
+
+
+
+
+
+
+ URL del Webhook
+
+
Registra esta URL en el panel Bold → Configuración → Webhooks
+
+
/webhooks/bold
+
+
+
+ La firma se verifica con HMAC-SHA256. En modo test la secret key puede ser vacía.
+
+
+
+
+
+
Eventos del webhook
+
+
+ SALE_APPROVED
+ Pago aprobado ✅
+
+
+ SALE_REJECTED
+ Pago rechazado ❌
+
+
+ SALE_REVERSED
+ Reverso / devolución 🔄
+
+
+ CHARGEBACK
+ Contracargo iniciado ⚠️
+
+
+
+
+
+
+
+
Últimas notificaciones
+
+
+
+ Sin notificaciones recibidas aún.
+
+
+
+
+
+ | Tipo |
+ Payment ID |
+ Monto |
+ Estado |
+
+
+
+
+
+ |
+
+ |
+ |
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Credenciales dLocal
+
+
Panel dLocal → API Credentials
+
+
+
+
+
+
+
+
+
+
+
+
+
Desarrollo / Sandbox
+
+
+
+
+
+
+
+
+
+
Estado actual
+
+
+ Entorno
+
+
+
+ Access Key (activa)
+
+
+
+ URL activa
+
+
+
+
+
+
+
Endpoints disponibles
+
+
+ POST
+ /v1/dlocal/subscription/crear-plan
+
+
+ GET
+ /v1/dlocal/subscription/ver-plan/:id
+
+
+ PATCH
+ /v1/dlocal/subscription/actualizar-plan/:id
+
+
+ POST
+ /v1/dlocal/payment/crear-pago
+
+
+
+
+
+
+
+
+
+
+
diff --git a/rest/controllers/api/bold_controller.go b/rest/controllers/api/bold_controller.go
new file mode 100644
index 0000000..ff31beb
--- /dev/null
+++ b/rest/controllers/api/bold_controller.go
@@ -0,0 +1,173 @@
+package controllers
+
+import (
+ "log"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
+ "github.com/sujit-baniya/fiber-boilerplate/pkg/services"
+)
+
+// ─── Webhook Bold ─────────────────────────────────────────────────────────────
+
+// BoldWebhook recibe las notificaciones de Bold y las procesa de forma idempotente.
+// La respuesta HTTP 200 se envía ANTES de cualquier lógica de negocio (requisito Bold).
+func BoldWebhook(c *fiber.Ctx) error {
+ rawBody := c.Body()
+
+ // ─── 1. Responder 200 de inmediato ────────────────────────────────────────
+ // Bold reintenta si no recibe 200 en < 2 segundos.
+ // Fiber no permite flush parcial, así que respondemos aquí y procesamos después.
+ c.Set("Content-Type", "application/json")
+
+ // Obtener configuración activa
+ cfg, err := models.GetBoldConfig()
+ if err != nil {
+ log.Println("[BOLD] Webhook: sin configuración activa")
+ return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
+ }
+
+ // ─── 2. Verificar firma HMAC ─────────────────────────────────────────────
+ signature := c.Get("x-bold-signature")
+ if signature != "" {
+ if !services.VerifyBoldSignature(rawBody, signature, cfg) {
+ log.Println("[BOLD] Webhook: firma inválida — descartado")
+ return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
+ }
+ }
+
+ // ─── 3. Parsear evento ───────────────────────────────────────────────────
+ event, err := services.ParseBoldWebhookEvent(rawBody)
+ if err != nil {
+ log.Printf("[BOLD] Webhook: error parseando body: %v", err)
+ return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
+ }
+
+ notificationID := event.ID
+ tipo := event.Type
+
+ // ID vacío no se puede procesar con idempotencia
+ if notificationID == "" {
+ log.Println("[BOLD] Webhook: notificationID vacío")
+ return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
+ }
+
+ // ─── 4. Idempotencia: descartar duplicados ───────────────────────────────
+ if models.IsBoldNotificationDuplicate(notificationID) {
+ log.Printf("[BOLD] Webhook: notificación duplicada %s", notificationID)
+ return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
+ }
+
+ paymentID := event.Data.PaymentID
+ if paymentID == "" {
+ paymentID = event.Subject
+ }
+ referencia := event.Data.Metadata.Reference
+ payerEmail := event.Data.PayerEmail
+ monto := event.Data.Amount.Total
+
+ // ─── 5. Guardar log del webhook ──────────────────────────────────────────
+ logEntry := models.BoldWebhookLog{
+ NotificationID: notificationID,
+ Tipo: tipo,
+ PaymentID: paymentID,
+ Referencia: referencia,
+ PayerEmail: payerEmail,
+ Monto: monto,
+ Procesado: false,
+ Raw: string(rawBody),
+ }
+ if err := models.SaveBoldWebhookLog(logEntry); err != nil {
+ log.Printf("[BOLD] Webhook: error guardando log: %v", err)
+ return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
+ }
+
+ // ─── 6. Solo procesar SALE_APPROVED ─────────────────────────────────────
+ if tipo != "SALE_APPROVED" {
+ log.Printf("[BOLD] Webhook: tipo '%s' ignorado", tipo)
+ return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
+ }
+
+ log.Printf("[BOLD] Webhook: SALE_APPROVED — payment_id=%s referencia=%s email=%s monto=%d",
+ paymentID, referencia, payerEmail, monto)
+
+ // ─── 7. Aquí se conecta la lógica de negocio ─────────────────────────────
+ // Ejemplo: actualizar estado de pago de un contrato de renovación.
+ // procesarPagoBold(paymentID, referencia, payerEmail, monto)
+
+ models.MarkBoldWebhookProcessed(notificationID)
+
+ return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
+}
+
+// ─── API: crear payment link ──────────────────────────────────────────────────
+
+// BoldCreatePaymentLink crea un link de pago en Bold y devuelve la URL.
+func BoldCreatePaymentLink(c *fiber.Ctx) error {
+ type bodyReq struct {
+ TotalAmount int64 `json:"total_amount"`
+ Description string `json:"description"`
+ PayerEmail string `json:"payer_email"`
+ Reference string `json:"reference"`
+ CallbackURL string `json:"callback_url"`
+ }
+ var req bodyReq
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
+ }
+ if req.TotalAmount <= 0 || req.Description == "" || req.Reference == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "total_amount, description y reference son requeridos",
+ })
+ }
+
+ cfg, err := models.GetBoldConfig()
+ if err != nil {
+ return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
+ "error": "No hay configuración activa de Bold",
+ })
+ }
+
+ linkReq := services.BoldPaymentLinkRequest{
+ AmountType: "CLOSE",
+ Amount: services.BoldAmountField{
+ Currency: "COP",
+ TotalAmount: req.TotalAmount,
+ },
+ Description: req.Description,
+ PayerEmail: req.PayerEmail,
+ Reference: req.Reference,
+ CallbackURL: req.CallbackURL,
+ }
+
+ result, err := services.CreateBoldPaymentLink(cfg, linkReq)
+ if err != nil {
+ return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ return c.JSON(fiber.Map{
+ "payment_link": result.Payload.PaymentLink,
+ "url": result.Payload.URL,
+ })
+}
+
+// BoldGetLinkStatus consulta el estado de un payment link existente.
+func BoldGetLinkStatus(c *fiber.Ctx) error {
+ linkID := c.Params("linkID")
+ if linkID == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "linkID requerido"})
+ }
+
+ cfg, err := models.GetBoldConfig()
+ if err != nil {
+ return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
+ "error": "No hay configuración activa de Bold",
+ })
+ }
+
+ data, err := services.GetBoldPaymentLinkStatus(cfg, linkID)
+ if err != nil {
+ return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"data": string(data)})
+}
diff --git a/rest/controllers/pasarelas_controller.go b/rest/controllers/pasarelas_controller.go
new file mode 100644
index 0000000..3630376
--- /dev/null
+++ b/rest/controllers/pasarelas_controller.go
@@ -0,0 +1,133 @@
+package controllers
+
+import (
+ "github.com/gofiber/fiber/v2"
+ "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
+)
+
+// PasarelasPage renderiza la vista unificada de pasarelas de pago.
+func PasarelasPage(c *fiber.Ctx) error {
+ boldCfg, _ := models.GetBoldConfig()
+ dlocalCfg, _ := models.GetLastActiveDlocalApi()
+
+ return c.Render("pasarelas_pago", fiber.Map{
+ "Title": "Pasarelas de Pago",
+ "Bold": boldCfg,
+ "Dlocal": dlocalCfg,
+ })
+}
+
+// ─── Bold ─────────────────────────────────────────────────────────────────────
+
+// SaveBoldConfig guarda o actualiza la configuración de Bold.
+func SaveBoldConfig(c *fiber.Ctx) error {
+ type body struct {
+ ID uint `json:"id" form:"id"`
+ ApiKeyProd string `json:"api_key_prod" form:"api_key_prod"`
+ SecretKeyProd string `json:"secret_key_prod" form:"secret_key_prod"`
+ ApiKeyTest string `json:"api_key_test" form:"api_key_test"`
+ SecretKeyTest string `json:"secret_key_test" form:"secret_key_test"`
+ Modo string `json:"modo" form:"modo"`
+ CallbackUrl string `json:"callback_url" form:"callback_url"`
+ Nota string `json:"nota" form:"nota"`
+ }
+ var b body
+ if err := c.BodyParser(&b); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
+ }
+ if b.Modo == "" {
+ b.Modo = "test"
+ }
+
+ cfg := models.BoldConfig{
+ ApiKeyProd: b.ApiKeyProd,
+ SecretKeyProd: b.SecretKeyProd,
+ ApiKeyTest: b.ApiKeyTest,
+ SecretKeyTest: b.SecretKeyTest,
+ Modo: b.Modo,
+ CallbackUrl: b.CallbackUrl,
+ Nota: b.Nota,
+ }
+ cfg.ID = b.ID
+
+ if err := models.SaveBoldConfig(cfg); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"message": "Configuración Bold guardada"})
+}
+
+// GetBoldConfig devuelve la configuración activa de Bold (para cargar el form).
+func GetBoldConfigAPI(c *fiber.Ctx) error {
+ cfg, err := models.GetBoldConfig()
+ if err != nil {
+ return c.JSON(fiber.Map{"data": nil})
+ }
+ return c.JSON(fiber.Map{"data": cfg})
+}
+
+// ─── dLocal ───────────────────────────────────────────────────────────────────
+
+// SaveDlocalConfig guarda o actualiza la configuración de dLocal.
+func SaveDlocalConfigWeb(c *fiber.Ctx) error {
+ type body struct {
+ ID uint `json:"id" form:"id"`
+ AccessKeyID string `json:"access_key_id" form:"access_key_id"`
+ AccessKeySecret string `json:"access_key_secret" form:"access_key_secret"`
+ AccessKeyIDdev string `json:"access_key_id_dev" form:"access_key_id_dev"`
+ AccessKeySecretdev string `json:"access_key_secret_dev" form:"access_key_secret_dev"`
+ UrlProd string `json:"url_prod" form:"url_prod"`
+ UrlDev string `json:"url_dev" form:"url_dev"`
+ Modo string `json:"modo" form:"modo"`
+ }
+ var b body
+ if err := c.BodyParser(&b); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
+ }
+ if b.Modo == "" {
+ b.Modo = "dev"
+ }
+
+ cfg := models.DlocalApi{
+ AccessKeyID: b.AccessKeyID,
+ AccessKeySecret: b.AccessKeySecret,
+ AccessKeyIDdev: b.AccessKeyIDdev,
+ AccessKeySecretdev: b.AccessKeySecretdev,
+ UrlProd: b.UrlProd,
+ UrlDev: b.UrlDev,
+ Modo: b.Modo,
+ IsActive: true,
+ }
+ cfg.ID = b.ID
+
+ // Desactivar configs previas
+ if cfg.ID == 0 {
+ if err := models.CreateDlocalApi(&cfg); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ } else {
+ if err := models.UpdateDlocalApi(&cfg); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ }
+ return c.JSON(fiber.Map{"message": "Configuración dLocal guardada"})
+}
+
+// GetDlocalConfigAPI devuelve la configuración activa de dLocal.
+func GetDlocalConfigAPI(c *fiber.Ctx) error {
+ cfg, err := models.GetLastActiveDlocalApi()
+ if err != nil {
+ return c.JSON(fiber.Map{"data": nil})
+ }
+ return c.JSON(fiber.Map{"data": cfg})
+}
+
+// ─── Logs Bold ────────────────────────────────────────────────────────────────
+
+// BoldWebhookLogs devuelve los últimos 50 registros del log de webhooks de Bold.
+func BoldWebhookLogs(c *fiber.Ctx) error {
+ logs, err := models.GetBoldWebhookLogs(50)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"data": logs})
+}
diff --git a/rest/controllers/seed_controller.go b/rest/controllers/seed_controller.go
index 7ed3cee..981da5f 100644
--- a/rest/controllers/seed_controller.go
+++ b/rest/controllers/seed_controller.go
@@ -15,6 +15,7 @@ func RunSeed(c *fiber.Ctx) error {
if err := seedModulo("Integraciones", "Conexión con servicios externos: Hostinger, Cloudflare y más", []seedEntry{
{"Hostinger", "Panel de VPS, dominios, hosting y DNS de Hostinger", "/app/hostinger"},
{"Cloudflare", "Gestión de zonas, DNS, SSL y firewall en Cloudflare", "/app/cloudflare"},
+ {"Pasarelas de Pago", "Configuración de Bold, dLocal y otras pasarelas", "/app/pasarelas-pago"},
}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
diff --git a/rest/routes/publicas.go b/rest/routes/publicas.go
index 44861c2..11c3614 100755
--- a/rest/routes/publicas.go
+++ b/rest/routes/publicas.go
@@ -3,6 +3,7 @@ package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/rest/controllers"
+ apiControllers "github.com/sujit-baniya/fiber-boilerplate/rest/controllers/api"
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
)
@@ -14,4 +15,8 @@ func RutasPublicas(web fiber.Router) {
web.Get("/all-routes", AllRoutes)
web.Get("/do/verify-email", middlewares.ValidateConfirmToken, controllers.VerifyRegisteredEmail)
+ // ─── Webhooks públicos (sin autenticación) ────────────────────────────
+ // Bold requiere respuesta HTTP 200 rápida, sin middleware de sesión.
+ web.Post("/webhooks/bold", apiControllers.BoldWebhook)
}
+
diff --git a/rest/routes/user.go b/rest/routes/user.go
index 84d6cf6..a362a94 100755
--- a/rest/routes/user.go
+++ b/rest/routes/user.go
@@ -5,6 +5,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/rest/controllers"
+ apiControllers "github.com/sujit-baniya/fiber-boilerplate/rest/controllers/api"
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
)
@@ -114,4 +115,17 @@ func UserRoutes(app fiber.Router) {
protected.Get("/cloudflare/zones/:zone_id/dns", controllers.GetCloudflareDNS)
protected.Get("/cloudflare/zones/:zone_id/ssl", controllers.GetCloudflareSSL)
protected.Get("/cloudflare/zones/:zone_id/firewall", controllers.GetCloudflareFirewall)
+
+ // ─── Pasarelas de Pago ────────────────────────────────────────────
+ protected.Get("/pasarelas-pago", middlewares.MenuMiddleware, controllers.PasarelasPage)
+ // Bold
+ protected.Get("/pasarelas/bold/config", controllers.GetBoldConfigAPI)
+ protected.Post("/pasarelas/bold/save", controllers.SaveBoldConfig)
+ protected.Get("/pasarelas/bold/logs", controllers.BoldWebhookLogs)
+ // dLocal
+ protected.Get("/pasarelas/dlocal/config", controllers.GetDlocalConfigAPI)
+ protected.Post("/pasarelas/dlocal/save", controllers.SaveDlocalConfigWeb)
+ // Bold API (crear link, consultar estado)
+ protected.Post("/pasarelas/bold/crear-link", apiControllers.BoldCreatePaymentLink)
+ protected.Get("/pasarelas/bold/link/:linkID", apiControllers.BoldGetLinkStatus)
}