Files
soft_usite/rest/controllers/api/bold_controller.go
T
2026-05-06 15:29:14 -05:00

203 lines
7.3 KiB
Go

package controllers
import (
"fmt"
"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. Actualizar estado en callback_log para TODOS los eventos ─────────
if referencia != "" {
estimado := "pendiente"
switch tipo {
case "SALE_APPROVED":
estimado = "pagado"
case "SALE_REJECTED":
estimado = "fallido"
case "SALE_REVERSED", "CHARGEBACK":
estimado = "revertido"
}
models.UpdateBoldCallbackEstado(referencia, estimado)
if paymentID != "" {
models.UpdateBoldCallbackEstado(paymentID, estimado)
}
}
// Solo continuar lógica de negocio para SALE_APPROVED ─────────────────────
if tipo != "SALE_APPROVED" {
log.Printf("[BOLD] Webhook: tipo '%s' registrado", 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. Marcar contrato como pagado y limpiar enlace ────────────────────
// La referencia tiene formato "contrato-{id}"
if referencia != "" {
var contratoID uint
if _, err := fmt.Sscanf(referencia, "contrato-%d", &contratoID); err == nil && contratoID > 0 {
if err := models.MarcarContratoPagado(contratoID); err != nil {
log.Printf("[BOLD] Webhook: error marcando contrato %d como pagado: %v", contratoID, err)
} else {
log.Printf("[BOLD] Webhook: contrato %d marcado como pagado", contratoID)
// Notificar SaaS externos asociados al contrato (goroutine, no bloquea respuesta)
go services.DispatchSaasPaymentNotification(contratoID, payerEmail, "bold", float64(monto), "COP")
}
}
}
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)})
}