feat: integracion Bold y pagina unificada de pasarelas de pago
This commit is contained in:
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
<div x-data="pasarelasApp()" class="bg-white rounded-lg shadow">
|
||||
|
||||
<!-- Overlay de carga -->
|
||||
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
|
||||
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
|
||||
</div>
|
||||
|
||||
<!-- Toast de notificación -->
|
||||
<div x-show="toast.show" x-transition
|
||||
:class="toast.type === 'error' ? 'bg-red-600' : 'bg-green-600'"
|
||||
class="fixed bottom-5 right-5 z-50 text-white text-sm px-5 py-3 rounded-xl shadow-lg flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<span x-text="toast.msg"></span>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto p-6 w-full">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Pasarelas de Pago</h1>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Configura las credenciales de cada pasarela y selecciona el entorno activo.</p>
|
||||
</div>
|
||||
<!-- Indicador de modo activo por pasarela -->
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<span class="px-3 py-1 rounded-full text-xs font-semibold flex items-center gap-1"
|
||||
:class="boldModo === 'production' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'">
|
||||
<span class="w-2 h-2 rounded-full inline-block"
|
||||
:class="boldModo === 'production' ? 'bg-green-500' : 'bg-yellow-500'"></span>
|
||||
Bold — <span x-text="boldModo === 'production' ? 'Producción' : 'Test'"></span>
|
||||
</span>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-semibold flex items-center gap-1"
|
||||
:class="dlocalModo === 'prod' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'">
|
||||
<span class="w-2 h-2 rounded-full inline-block"
|
||||
:class="dlocalModo === 'prod' ? 'bg-green-500' : 'bg-yellow-500'"></span>
|
||||
dLocal — <span x-text="dlocalModo === 'prod' ? 'Producción' : 'Desarrollo'"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-gray-200 mb-6">
|
||||
<nav class="-mb-px flex gap-6 text-sm">
|
||||
<button @click="activeTab = 'bold'"
|
||||
:class="activeTab === 'bold' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'"
|
||||
class="pb-2 transition-colors flex items-center gap-2">
|
||||
<!-- Bold icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 3h7a5 5 0 0 1 5 5 5 5 0 0 1-2.5 4.33A5.5 5.5 0 0 1 13 22H6V3zm3 3v4h4a2 2 0 0 0 0-4H9zm0 7v4h4.5a2.5 2.5 0 0 0 0-5H9z"/>
|
||||
</svg>
|
||||
Bold
|
||||
</button>
|
||||
<button @click="activeTab = 'dlocal'"
|
||||
:class="activeTab === 'dlocal' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'"
|
||||
class="pb-2 transition-colors flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
|
||||
</svg>
|
||||
dLocal
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- TAB: BOLD -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<div x-show="activeTab === 'bold'" x-transition>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Formulario de credenciales Bold -->
|
||||
<div class="border border-gray-200 rounded-xl p-5">
|
||||
<h2 class="text-base font-semibold mb-1 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#8eb02f]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||
</svg>
|
||||
Credenciales Bold
|
||||
</h2>
|
||||
<p class="text-xs text-gray-500 mb-4">Panel Bold → Configuración → Integraciones → API</p>
|
||||
|
||||
<!-- Selector de entorno -->
|
||||
<div class="mb-5">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1.5">Entorno activo</label>
|
||||
<div class="flex gap-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" x-model="boldModo" value="test" class="accent-[#8eb02f]"/>
|
||||
<span class="text-sm px-2 py-0.5 rounded bg-yellow-50 text-yellow-700 font-medium">Test / Sandbox</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" x-model="boldModo" value="production" class="accent-[#8eb02f]"/>
|
||||
<span class="text-sm px-2 py-0.5 rounded bg-green-50 text-green-700 font-medium">Producción</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sección Test -->
|
||||
<div class="mb-5 p-4 rounded-lg bg-yellow-50 border border-yellow-200">
|
||||
<p class="text-xs font-semibold text-yellow-700 mb-3 flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"/>
|
||||
</svg>
|
||||
Entorno de prueba
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">API Key (test)</label>
|
||||
<input x-model="bold.api_key_test" type="text" placeholder="pk_test_..." class="ui-input text-xs font-mono" autocomplete="off"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">
|
||||
Secret Key (test)
|
||||
<span class="text-gray-400 font-normal">(puede quedar vacía en sandbox)</span>
|
||||
</label>
|
||||
<input x-model="bold.secret_key_test" type="password" placeholder="Dejar vacío para test" class="ui-input text-xs font-mono" autocomplete="off"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sección Producción -->
|
||||
<div class="mb-5 p-4 rounded-lg bg-green-50 border border-green-200">
|
||||
<p class="text-xs font-semibold text-green-700 mb-3 flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||
</svg>
|
||||
Producción
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">API Key (producción)</label>
|
||||
<input x-model="bold.api_key_prod" type="text" placeholder="pk_live_..." class="ui-input text-xs font-mono" autocomplete="off"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Secret Key (producción)</label>
|
||||
<input x-model="bold.secret_key_prod" type="password" placeholder="sk_live_..." class="ui-input text-xs font-mono" autocomplete="off"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Callback URL -->
|
||||
<div class="mb-5">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">
|
||||
Callback URL
|
||||
<span class="text-gray-400 font-normal">(Bold redirige aquí al usuario tras el pago)</span>
|
||||
</label>
|
||||
<input x-model="bold.callback_url" type="url" placeholder="https://tudominio.com/pago-exitoso" class="ui-input text-xs"/>
|
||||
</div>
|
||||
|
||||
<!-- Nota -->
|
||||
<div class="mb-5">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Nota interna</label>
|
||||
<input x-model="bold.nota" type="text" placeholder="Opcional" class="ui-input text-xs"/>
|
||||
</div>
|
||||
|
||||
<button @click="saveBold()"
|
||||
class="w-full py-2.5 rounded-xl text-sm font-semibold text-white transition-all"
|
||||
style="background-color:#8eb02f"
|
||||
onmouseover="this.style.backgroundColor='#6d8c24'"
|
||||
onmouseout="this.style.backgroundColor='#8eb02f'">
|
||||
Guardar configuración Bold
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Info / Guía Bold -->
|
||||
<div class="space-y-4">
|
||||
|
||||
<!-- Estado actual -->
|
||||
<div class="border border-gray-200 rounded-xl p-5">
|
||||
<h3 class="text-sm font-semibold mb-3 text-gray-700">Estado actual</h3>
|
||||
<div class="space-y-2 text-xs">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Entorno</span>
|
||||
<span class="font-semibold"
|
||||
:class="boldModo === 'production' ? 'text-green-600' : 'text-yellow-600'"
|
||||
x-text="boldModo === 'production' ? 'Producción 🟢' : 'Test / Sandbox 🟡'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">API Key (activa)</span>
|
||||
<span class="font-mono text-gray-700"
|
||||
x-text="boldModo === 'production'
|
||||
? (bold.api_key_prod ? bold.api_key_prod.slice(0,8)+'...' : '—')
|
||||
: (bold.api_key_test ? bold.api_key_test.slice(0,8)+'...' : '—')"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Callback URL</span>
|
||||
<span class="font-mono text-gray-700 truncate max-w-[180px]" x-text="bold.callback_url || '—'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL del Webhook -->
|
||||
<div class="border border-gray-200 rounded-xl p-5">
|
||||
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2 text-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-[#8eb02f]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
|
||||
</svg>
|
||||
URL del Webhook
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 mb-3">Registra esta URL en el panel Bold → Configuración → Webhooks</p>
|
||||
<div class="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-lg px-3 py-2">
|
||||
<span class="text-xs font-mono text-gray-700 flex-1 truncate" id="webhookUrl">/webhooks/bold</span>
|
||||
<button @click="copyWebhook()" title="Copiar URL"
|
||||
class="text-[#8eb02f] hover:text-[#6d8c24] flex-shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-2">
|
||||
La firma se verifica con HMAC-SHA256. En modo test la secret key puede ser vacía.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Eventos soportados -->
|
||||
<div class="border border-gray-200 rounded-xl p-5">
|
||||
<h3 class="text-sm font-semibold mb-3 text-gray-700">Eventos del webhook</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="font-mono bg-green-50 text-green-700 px-2 py-0.5 rounded">SALE_APPROVED</span>
|
||||
<span class="text-gray-500">Pago aprobado ✅</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="font-mono bg-red-50 text-red-600 px-2 py-0.5 rounded">SALE_REJECTED</span>
|
||||
<span class="text-gray-500">Pago rechazado ❌</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="font-mono bg-orange-50 text-orange-600 px-2 py-0.5 rounded">SALE_REVERSED</span>
|
||||
<span class="text-gray-500">Reverso / devolución 🔄</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="font-mono bg-purple-50 text-purple-600 px-2 py-0.5 rounded">CHARGEBACK</span>
|
||||
<span class="text-gray-500">Contracargo iniciado ⚠️</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log de webhooks recientes (tabla) -->
|
||||
<div class="border border-gray-200 rounded-xl p-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700">Últimas notificaciones</h3>
|
||||
<button @click="loadBoldLogs()" class="text-xs text-[#8eb02f] hover:underline">Actualizar</button>
|
||||
</div>
|
||||
<div x-show="boldLogs.length === 0" class="text-xs text-gray-400 text-center py-4">
|
||||
Sin notificaciones recibidas aún.
|
||||
</div>
|
||||
<div x-show="boldLogs.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full text-xs table-auto">
|
||||
<thead>
|
||||
<tr class="text-left border-b font-semibold text-gray-500">
|
||||
<th class="pb-2 pr-3">Tipo</th>
|
||||
<th class="pb-2 pr-3">Payment ID</th>
|
||||
<th class="pb-2 pr-3">Monto</th>
|
||||
<th class="pb-2">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="log in boldLogs" :key="log.ID">
|
||||
<tr class="border-b last:border-0">
|
||||
<td class="py-2 pr-3">
|
||||
<span class="px-2 py-0.5 rounded font-semibold"
|
||||
:class="log.tipo === 'SALE_APPROVED' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'"
|
||||
x-text="log.tipo"></span>
|
||||
</td>
|
||||
<td class="py-2 pr-3 font-mono text-gray-600" x-text="log.payment_id || '—'"></td>
|
||||
<td class="py-2 pr-3 text-gray-700" x-text="log.monto ? '$' + log.monto.toLocaleString('es-CO') : '—'"></td>
|
||||
<td class="py-2">
|
||||
<span class="px-2 py-0.5 rounded"
|
||||
:class="log.procesado ? 'bg-green-50 text-green-600' : 'bg-yellow-50 text-yellow-600'"
|
||||
x-text="log.procesado ? 'Procesado' : 'Pendiente'"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- TAB: dLOCAL -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<div x-show="activeTab === 'dlocal'" x-transition>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Formulario dLocal -->
|
||||
<div class="border border-gray-200 rounded-xl p-5">
|
||||
<h2 class="text-base font-semibold mb-1 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#8eb02f]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
|
||||
</svg>
|
||||
Credenciales dLocal
|
||||
</h2>
|
||||
<p class="text-xs text-gray-500 mb-4">Panel dLocal → API Credentials</p>
|
||||
|
||||
<!-- Selector de entorno -->
|
||||
<div class="mb-5">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1.5">Entorno activo</label>
|
||||
<div class="flex gap-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" x-model="dlocalModo" value="dev" class="accent-[#8eb02f]"/>
|
||||
<span class="text-sm px-2 py-0.5 rounded bg-yellow-50 text-yellow-700 font-medium">Desarrollo</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" x-model="dlocalModo" value="prod" class="accent-[#8eb02f]"/>
|
||||
<span class="text-sm px-2 py-0.5 rounded bg-green-50 text-green-700 font-medium">Producción</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URLs -->
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">URL Producción</label>
|
||||
<input x-model="dlocal.url_prod" type="url" placeholder="https://api.dlocal.com" class="ui-input text-xs"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">URL Desarrollo</label>
|
||||
<input x-model="dlocal.url_dev" type="url" placeholder="https://sandbox.dlocal.com" class="ui-input text-xs"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Producción -->
|
||||
<div class="mb-4 p-4 rounded-lg bg-green-50 border border-green-200">
|
||||
<p class="text-xs font-semibold text-green-700 mb-3">Producción</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Access Key ID</label>
|
||||
<input x-model="dlocal.access_key_id" type="text" placeholder="xkey_..." class="ui-input text-xs font-mono" autocomplete="off"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Access Key Secret</label>
|
||||
<input x-model="dlocal.access_key_secret" type="password" placeholder="•••••••" class="ui-input text-xs font-mono" autocomplete="off"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desarrollo -->
|
||||
<div class="mb-5 p-4 rounded-lg bg-yellow-50 border border-yellow-200">
|
||||
<p class="text-xs font-semibold text-yellow-700 mb-3">Desarrollo / Sandbox</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Access Key ID (dev)</label>
|
||||
<input x-model="dlocal.access_key_id_dev" type="text" placeholder="xkey_dev_..." class="ui-input text-xs font-mono" autocomplete="off"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Access Key Secret (dev)</label>
|
||||
<input x-model="dlocal.access_key_secret_dev" type="password" placeholder="•••••••" class="ui-input text-xs font-mono" autocomplete="off"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="saveDlocal()"
|
||||
class="w-full py-2.5 rounded-xl text-sm font-semibold text-white transition-all"
|
||||
style="background-color:#8eb02f"
|
||||
onmouseover="this.style.backgroundColor='#6d8c24'"
|
||||
onmouseout="this.style.backgroundColor='#8eb02f'">
|
||||
Guardar configuración dLocal
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Info dLocal -->
|
||||
<div class="space-y-4">
|
||||
<div class="border border-gray-200 rounded-xl p-5">
|
||||
<h3 class="text-sm font-semibold mb-3 text-gray-700">Estado actual</h3>
|
||||
<div class="space-y-2 text-xs">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Entorno</span>
|
||||
<span class="font-semibold"
|
||||
:class="dlocalModo === 'prod' ? 'text-green-600' : 'text-yellow-600'"
|
||||
x-text="dlocalModo === 'prod' ? 'Producción 🟢' : 'Desarrollo 🟡'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Access Key (activa)</span>
|
||||
<span class="font-mono text-gray-700"
|
||||
x-text="dlocalModo === 'prod'
|
||||
? (dlocal.access_key_id ? dlocal.access_key_id.slice(0,8)+'...' : '—')
|
||||
: (dlocal.access_key_id_dev ? dlocal.access_key_id_dev.slice(0,8)+'...' : '—')"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">URL activa</span>
|
||||
<span class="font-mono text-gray-700 truncate max-w-[180px]"
|
||||
x-text="dlocalModo === 'prod' ? (dlocal.url_prod || '—') : (dlocal.url_dev || '—')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-gray-200 rounded-xl p-5">
|
||||
<h3 class="text-sm font-semibold mb-2 text-gray-700">Endpoints disponibles</h3>
|
||||
<div class="space-y-1.5 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-mono text-[10px]">POST</span>
|
||||
<span class="text-gray-600">/v1/dlocal/subscription/crear-plan</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-mono text-[10px]">GET</span>
|
||||
<span class="text-gray-600">/v1/dlocal/subscription/ver-plan/:id</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="bg-yellow-50 text-yellow-600 px-1.5 py-0.5 rounded font-mono text-[10px]">PATCH</span>
|
||||
<span class="text-gray-600">/v1/dlocal/subscription/actualizar-plan/:id</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-mono text-[10px]">POST</span>
|
||||
<span class="text-gray-600">/v1/dlocal/payment/crear-pago</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /container -->
|
||||
</div><!-- /x-data -->
|
||||
|
||||
<script>
|
||||
function pasarelasApp() {
|
||||
return {
|
||||
activeTab: 'bold',
|
||||
loading: false,
|
||||
toast: { show: false, msg: '', type: 'success' },
|
||||
|
||||
// ─── Bold ───────────────────────────────────────────────────────
|
||||
boldModo: 'test',
|
||||
bold: {
|
||||
id: 0,
|
||||
api_key_prod: '',
|
||||
secret_key_prod: '',
|
||||
api_key_test: '',
|
||||
secret_key_test: '',
|
||||
callback_url: '',
|
||||
nota: '',
|
||||
},
|
||||
boldLogs: [],
|
||||
|
||||
// ─── dLocal ─────────────────────────────────────────────────────
|
||||
dlocalModo: 'dev',
|
||||
dlocal: {
|
||||
id: 0,
|
||||
access_key_id: '',
|
||||
access_key_secret: '',
|
||||
access_key_id_dev: '',
|
||||
access_key_secret_dev: '',
|
||||
url_prod: '',
|
||||
url_dev: '',
|
||||
},
|
||||
|
||||
// ─── Init ───────────────────────────────────────────────────────
|
||||
init() {
|
||||
this.loadBold();
|
||||
this.loadDlocal();
|
||||
this.loadBoldLogs();
|
||||
},
|
||||
|
||||
// ─── Bold helpers ────────────────────────────────────────────────
|
||||
async loadBold() {
|
||||
try {
|
||||
const r = await axios.get('/app/pasarelas/bold/config');
|
||||
if (r.data.data) {
|
||||
const d = r.data.data;
|
||||
this.bold = {
|
||||
id: d.ID || 0,
|
||||
api_key_prod: d.api_key_prod || '',
|
||||
secret_key_prod: d.secret_key_prod || '',
|
||||
api_key_test: d.api_key_test || '',
|
||||
secret_key_test: d.secret_key_test || '',
|
||||
callback_url: d.callback_url || '',
|
||||
nota: d.nota || '',
|
||||
};
|
||||
this.boldModo = d.modo || 'test';
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async saveBold() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const payload = { ...this.bold, modo: this.boldModo };
|
||||
await axios.post('/app/pasarelas/bold/save', payload);
|
||||
this.showToast('Configuración Bold guardada ✓');
|
||||
this.loadBold();
|
||||
} catch (e) {
|
||||
this.showToast(e.response?.data?.error || 'Error guardando Bold', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadBoldLogs() {
|
||||
try {
|
||||
const r = await axios.get('/app/pasarelas/bold/logs');
|
||||
this.boldLogs = r.data.data || [];
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
copyWebhook() {
|
||||
const base = window.location.origin;
|
||||
const url = base + '/webhooks/bold';
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
this.showToast('URL copiada: ' + url);
|
||||
});
|
||||
},
|
||||
|
||||
// ─── dLocal helpers ──────────────────────────────────────────────
|
||||
async loadDlocal() {
|
||||
try {
|
||||
const r = await axios.get('/app/pasarelas/dlocal/config');
|
||||
if (r.data.data) {
|
||||
const d = r.data.data;
|
||||
this.dlocal = {
|
||||
id: d.ID || 0,
|
||||
access_key_id: d.access_key_id || '',
|
||||
access_key_secret: d.access_key_secret || '',
|
||||
access_key_id_dev: d.access_key_id_dev || '',
|
||||
access_key_secret_dev: d.access_key_secret_dev || '',
|
||||
url_prod: d.url_prod || '',
|
||||
url_dev: d.url_dev || '',
|
||||
};
|
||||
this.dlocalModo = d.modo || 'dev';
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async saveDlocal() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const payload = { ...this.dlocal, modo: this.dlocalModo };
|
||||
await axios.post('/app/pasarelas/dlocal/save', payload);
|
||||
this.showToast('Configuración dLocal guardada ✓');
|
||||
this.loadDlocal();
|
||||
} catch (e) {
|
||||
this.showToast(e.response?.data?.error || 'Error guardando dLocal', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Toast ───────────────────────────────────────────────────────
|
||||
showToast(msg, type = 'success') {
|
||||
this.toast = { show: true, msg, type };
|
||||
setTimeout(() => { this.toast.show = false; }, 3500);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user