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 ───────────────────────────────────────────── // La firma es obligatoria: sin ella cualquiera podría enviar un // "SALE_APPROVED" falso y marcar un contrato como pagado sin haber pagado. signature := c.Get("x-bold-signature") if signature == "" { log.Printf("[BOLD] Webhook rechazado: sin firma (IP %s)", c.IP()) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"ok": false, "error": "firma requerida"}) } if !services.VerifyBoldSignature(rawBody, signature, cfg) { log.Printf("[BOLD] Webhook rechazado: firma inválida (IP %s)", c.IP()) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"ok": false, "error": "firma inválida"}) } // ─── 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) } // Enriquecer con email y monto del webhook (solo cuando hay datos reales) if estimado == "pagado" { models.EnrichBoldCallbackLog(referencia, payerEmail, monto) } } // 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 ok, 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 (nuevo=%v)", contratoID, ok) if ok { // Primer registro del pago: notificar SaaS + enviar correo go services.DispatchSaasPaymentNotification(contratoID, payerEmail, "bold", float64(monto), "COP") go services.EnviarCorreoConfirmacionPago(contratoID, "bold") } } } } 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)}) }