feat: integracion Bold y pagina unificada de pasarelas de pago
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user