Seguridad (crítico): - Los webhooks de Bold y dLocal solo validaban la firma si el atacante la enviaba: sin cabecera se aceptaba cualquier payload. Ahora es obligatoria. - GET /pago-exitoso marcaba contratos como pagados leyendo un query param del navegador. Ahora solo muestra estado; la confirmación la hace la verificación contra la API de la pasarela o el webhook firmado. - /uploads se servía como estático público: se descargaban RUTs, facturas y entregables sabiendo la ruta. Ahora exige sesión. - Los secretos JWT no se podían sobreescribir por entorno (faltaba el tag env:) y su valor estaba en el repo, permitiendo firmarse una sesión de admin. Ahora son configurables y el arranque se detiene si siguen con el valor publicado. - .env y session.db salen del control de versiones. - Query Runner, gestión de usuarios/roles/módulos y seeds quedan restringidos a administradores; antes bastaba con tener sesión. Pasarelas de pago: - dLocal generaba enlaces que nunca se reconciliaban: mandaba el ID numérico en vez de "contrato-N", la URL de retorno apuntaba a la API de dLocal y nunca se enviaba notification_url, así que su webhook jamás se disparaba. - PayPal solo tenía pantalla de configuración. Se implementa el servicio completo (OAuth, orden, captura, verificación de webhook) y queda seleccionable como pasarela. - La moneda estaba fija en COP: un contrato en USD generaba un cobro por esa cifra en pesos. Contratos: - pago_confirmado nunca volvía a false, así que el segundo ciclo de renovación no se cobraba aunque el cliente pagara. Se reinicia al generar enlace nuevo. - Los contratos vencidos nunca cambiaban de estado y recibían correo a diario de forma indefinida; ahora se cierran tras 30 días de gracia. Otros: - Coolify: coolifyCall ignoraba el status HTTP y reportaba errores como éxito. El agente pasa de 10 a cobertura completa (servicios, bases de datos, variables de entorno, proyectos, equipos y recursos de servidor). - SeedBalanceData ya no corre en cada arranque (recreaba transacciones borradas); ahora se invoca con SEED_BALANCE=1. - Los seeds dejan de devolver permisos revocados en cada despliegue. - Timeouts en las llamadas HTTP a Telegram y dLocal que podían colgarse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
7.9 KiB
Go
214 lines
7.9 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 ─────────────────────────────────────────────
|
|
// 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)})
|
|
}
|