feat: integracion Bold y pagina unificada de pasarelas de pago

This commit is contained in:
Lizandro Guarnizo
2026-05-01 11:05:54 -05:00
parent 40e5df9f4e
commit 357bdc7397
9 changed files with 1199 additions and 0 deletions
+173
View File
@@ -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)})
}
+133
View File
@@ -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})
}
+1
View File
@@ -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()})
}
+5
View File
@@ -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)
}
+14
View File
@@ -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)
}