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. 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. Limpiar enlace de pago del contrato (para generar uno nuevo en la próxima notificación) // 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.LimpiarEnlacePago(contratoID); err != nil { log.Printf("[BOLD] Webhook: error limpiando enlace pago contrato %d: %v", contratoID, err) } else { log.Printf("[BOLD] Webhook: enlace pago limpiado para contrato %d", contratoID) } } } 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)}) }