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 @@ +
+ + +
+ Cargando... +
+ + +
+ + + + +
+ +
+ + +
+
+

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. +
+
+ + + + + + + + + + + + +
TipoPayment IDMontoEstado
+
+
+
+
+
+ + + + +
+ +
+ + +
+

+ + + + Credenciales dLocal +

+

Panel dLocal → API Credentials

+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

Producción

+
+
+ + +
+
+ + +
+
+
+ + +
+

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) }