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>
265 lines
9.3 KiB
Go
265 lines
9.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"
|
|
)
|
|
|
|
// PagoExitosoPage renderiza la página pública de confirmación de pago.
|
|
// Bold redirige al cliente aquí tras el pago. Registra el intento en bold_callback_log.
|
|
func PagoExitosoPage(c *fiber.Ctx) error {
|
|
// Recolectar todos los parámetros posibles que Bold puede enviar
|
|
params := map[string]string{}
|
|
// Parámetros clave que Bold envía en la callback_url
|
|
for _, k := range []string{"bold-order-id", "order_id", "payment_link", "reference", "id", "ref"} {
|
|
if v := c.Query(k, ""); v != "" {
|
|
params[k] = v
|
|
}
|
|
}
|
|
// bold-tx-status: Bold envía el resultado directamente en la URL
|
|
if v := c.Query("bold-tx-status", ""); v != "" {
|
|
params["bold-tx-status"] = v
|
|
}
|
|
c.Request().URI().QueryArgs().VisitAll(func(k, v []byte) {
|
|
params[string(k)] = string(v)
|
|
})
|
|
|
|
// Resolver la referencia principal (orden de prioridad)
|
|
ref := ""
|
|
for _, k := range []string{"ref", "reference", "bold-order-id", "payment_link", "order_id", "id"} {
|
|
if v := params[k]; v != "" {
|
|
ref = v
|
|
break
|
|
}
|
|
}
|
|
paymentLink := params["payment_link"]
|
|
|
|
// Serializar todos los params para auditoría
|
|
paramsJSON := "{"
|
|
for k, v := range params {
|
|
paramsJSON += `"` + k + `":"` + v + `",`
|
|
}
|
|
if len(paramsJSON) > 1 {
|
|
paramsJSON = paramsJSON[:len(paramsJSON)-1]
|
|
}
|
|
paramsJSON += "}"
|
|
|
|
// Si Bold ya envía el resultado en la URL (bold-tx-status), usarlo directamente
|
|
// antes de hacer cualquier llamada a la API externa.
|
|
boldTxStatus := params["bold-tx-status"]
|
|
if boldTxStatus == "rejected" || boldTxStatus == "cancelled" || boldTxStatus == "failed" {
|
|
_ = models.SaveBoldCallbackLog(models.BoldCallbackLog{
|
|
Referencia: ref, PaymentLink: paymentLink, Params: paramsJSON,
|
|
Estado: "fallido", IP: c.IP(), UserAgent: string(c.Request().Header.UserAgent()),
|
|
})
|
|
return c.Render("pago_exitoso", fiber.Map{
|
|
"Ref": ref, "Estado": "rechazado", "FechaPago": "",
|
|
}, "layouts/landing")
|
|
}
|
|
|
|
// Bold dice en la URL de retorno que el pago fue aprobado.
|
|
// OJO: esto viene del navegador del usuario y es manipulable, así que NO se
|
|
// usa para marcar el contrato como pagado. Solo se registra el retorno; la
|
|
// confirmación real la hace verificarPago() contra la API de Bold / el
|
|
// webhook firmado, que son las únicas fuentes de verdad.
|
|
if boldTxStatus == "approved" {
|
|
// IP real (detrás de nginx)
|
|
ip := c.Get("X-Real-IP")
|
|
if ip == "" {
|
|
ip = c.Get("X-Forwarded-For")
|
|
}
|
|
if ip == "" {
|
|
ip = c.IP()
|
|
}
|
|
|
|
estado, fechaPago := verificarPago(ref)
|
|
|
|
estadoLog := "pendiente"
|
|
if estado {
|
|
estadoLog = "pagado"
|
|
}
|
|
_ = models.SaveBoldCallbackLog(models.BoldCallbackLog{
|
|
Referencia: ref, PaymentLink: paymentLink, Params: paramsJSON,
|
|
Estado: estadoLog, IP: ip, UserAgent: string(c.Request().Header.UserAgent()),
|
|
})
|
|
if !estado {
|
|
log.Printf("[PAGO] Retorno con bold-tx-status=approved para %s pero la verificación contra Bold no lo confirma todavía", ref)
|
|
}
|
|
|
|
// Enriquecer callback log: primero webhook log, luego Bold API directa
|
|
go func(linkID string) {
|
|
// 1. Si el webhook ya llegó, usar esos datos
|
|
if wLogs, err := models.GetBoldWebhookLogsByRef(ref); err == nil {
|
|
for _, wl := range wLogs {
|
|
if wl.PayerEmail != "" || wl.Monto > 0 {
|
|
models.EnrichBoldCallbackLog(ref, wl.PayerEmail, wl.Monto)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
// 2. Fallback: consultar Bold API directamente para obtener el monto
|
|
if linkID != "" {
|
|
if boldCfg, err := models.GetBoldConfig(); err == nil {
|
|
if _, _, monto, err := services.CheckBoldLinkStatus(boldCfg, linkID); err == nil && monto > 0 {
|
|
models.EnrichBoldCallbackLog(ref, "", monto)
|
|
}
|
|
}
|
|
}
|
|
}(paymentLink)
|
|
|
|
return c.Render("pago_exitoso", fiber.Map{
|
|
"Ref": ref, "Estado": estado, "FechaPago": fechaPago,
|
|
}, "layouts/landing")
|
|
}
|
|
|
|
// Registrar el intento de pago en la tabla de callbacks
|
|
if ref != "" || paymentLink != "" {
|
|
ip := c.Get("X-Real-IP")
|
|
if ip == "" {
|
|
ip = c.Get("X-Forwarded-For")
|
|
}
|
|
if ip == "" {
|
|
ip = c.IP()
|
|
}
|
|
entry := models.BoldCallbackLog{
|
|
Referencia: ref,
|
|
PaymentLink: paymentLink,
|
|
Params: paramsJSON,
|
|
Estado: "pendiente",
|
|
IP: ip,
|
|
UserAgent: string(c.Request().Header.UserAgent()),
|
|
}
|
|
_ = models.SaveBoldCallbackLog(entry)
|
|
}
|
|
|
|
// Verificar inmediatamente el estado del pago en el servidor
|
|
estado, fechaPago := verificarPago(ref)
|
|
|
|
return c.Render("pago_exitoso", fiber.Map{
|
|
"Ref": ref,
|
|
"Estado": estado,
|
|
"FechaPago": fechaPago,
|
|
}, "layouts/landing")
|
|
}
|
|
|
|
// verificarPago centraliza la lógica de verificación de pago para una referencia dada.
|
|
// Devuelve (confirmado, fechaPago). Lo usan tanto PagoExitosoPage como PagoEstadoAPI.
|
|
//
|
|
// Flujo (en orden):
|
|
// 1. DB: si pago_confirmado = true → listo.
|
|
// 2. dlocal_payment_log: si hay log con estado PAID/AUTHORIZED → marca y confirma.
|
|
// 3. Bold API: consulta directo usando enlace_pago_link_id si existe.
|
|
// 4. dLocal API: consulta directo por order_id.
|
|
func verificarPago(ref string) (confirmado bool, fechaPago string) {
|
|
var contratoID uint
|
|
if _, err := fmt.Sscanf(ref, "contrato-%d", &contratoID); err != nil || contratoID == 0 {
|
|
return false, ""
|
|
}
|
|
|
|
// ─── 1. DB ────────────────────────────────────────────────────────────────
|
|
contrato, err := models.GetContratoParaVerificacion(contratoID)
|
|
if err != nil {
|
|
return false, ""
|
|
}
|
|
if contrato.PagoConfirmado {
|
|
fp := ""
|
|
if contrato.FechaPago != nil {
|
|
fp = contrato.FechaPago.Format("02/01/2006 15:04")
|
|
}
|
|
return true, fp
|
|
}
|
|
|
|
// ─── 2. dlocal_payment_log ────────────────────────────────────────────────
|
|
dlocalLogs, err := models.GetDlocalPaymentLogsByRef(ref)
|
|
if err == nil {
|
|
for _, l := range dlocalLogs {
|
|
if l.Estado == "PAID" || l.Estado == "AUTHORIZED" {
|
|
log.Printf("[PAGO-ESTADO] Contrato %d confirmado via dlocal_payment_log (id=%d)", contratoID, l.ID)
|
|
if ok, _ := models.MarcarContratoPagado(contratoID); ok {
|
|
go services.EnviarCorreoConfirmacionPago(contratoID, "dlocal")
|
|
}
|
|
return true, l.CreatedAt.Format("02/01/2006 15:04")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── 3. Bold API ─────────────────────────────────────────────────────────
|
|
if contrato.EnlacePagoLinkID != "" {
|
|
boldCfg, boldErr := models.GetBoldConfig()
|
|
if boldErr == nil {
|
|
paid, paymentID, monto, boldApiErr := services.CheckBoldLinkPaid(boldCfg, contrato.EnlacePagoLinkID)
|
|
if boldApiErr != nil {
|
|
log.Printf("[PAGO-ESTADO] Bold API error para link %s: %v", contrato.EnlacePagoLinkID, boldApiErr)
|
|
} else if paid {
|
|
log.Printf("[PAGO-ESTADO] Contrato %d confirmado via Bold API (payment_id=%s)", contratoID, paymentID)
|
|
if ok, _ := models.MarcarContratoPagado(contratoID); ok {
|
|
go services.EnviarCorreoConfirmacionPago(contratoID, "bold")
|
|
}
|
|
// Enriquecer callback log con monto y email del cliente
|
|
go models.EnrichBoldCallbackLog(ref, contrato.Cliente.Email, monto)
|
|
if paymentID != "" && !models.IsBoldNotificationDuplicate("api-check-"+paymentID) {
|
|
_ = models.SaveBoldWebhookLog(models.BoldWebhookLog{
|
|
NotificationID: "api-check-" + paymentID,
|
|
Tipo: "API_CHECK",
|
|
PaymentID: paymentID,
|
|
Referencia: ref,
|
|
PayerEmail: contrato.Cliente.Email,
|
|
Monto: monto,
|
|
Procesado: true,
|
|
})
|
|
}
|
|
return true, ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── 4. dLocal API ───────────────────────────────────────────────────────
|
|
dlocalCfg, dlocalErr := models.GetLastActiveDlocalApi()
|
|
if dlocalErr == nil {
|
|
paid, paymentID, monto, payerEmail, dlocalApiErr := services.CheckDlocalPaymentByOrderID(*dlocalCfg, ref)
|
|
if dlocalApiErr != nil {
|
|
log.Printf("[PAGO-ESTADO] dLocal API error para order_id %s: %v", ref, dlocalApiErr)
|
|
} else if paid {
|
|
log.Printf("[PAGO-ESTADO] Contrato %d confirmado via dLocal API (payment_id=%s)", contratoID, paymentID)
|
|
if ok, _ := models.MarcarContratoPagado(contratoID); ok {
|
|
go services.EnviarCorreoConfirmacionPago(contratoID, "dlocal")
|
|
}
|
|
notifID := "api-check-" + ref
|
|
if !models.IsDlocalNotificationDuplicate(notifID) {
|
|
_ = models.SaveDlocalPaymentLog(models.DlocalPaymentLog{
|
|
NotificationID: notifID,
|
|
Fuente: "api_check",
|
|
Tipo: "API_CHECK",
|
|
Estado: "PAID",
|
|
PaymentID: paymentID,
|
|
Referencia: ref,
|
|
PayerEmail: payerEmail,
|
|
Monto: monto,
|
|
Procesado: true,
|
|
})
|
|
}
|
|
return true, ""
|
|
}
|
|
}
|
|
|
|
return false, ""
|
|
}
|
|
|
|
// PagoEstadoAPI devuelve el estado de pago de un contrato para polling desde el frontend.
|
|
// GET /api/pago-estado?ref=contrato-{id}
|
|
func PagoEstadoAPI(c *fiber.Ctx) error {
|
|
ref := c.Query("ref", "")
|
|
if ref == "" {
|
|
return c.JSON(fiber.Map{"confirmado": false, "error": "ref requerido"})
|
|
}
|
|
confirmado, fechaPago := verificarPago(ref)
|
|
if confirmado {
|
|
return c.JSON(fiber.Map{"confirmado": true, "fecha_pago": fechaPago})
|
|
}
|
|
return c.JSON(fiber.Map{"confirmado": false})
|
|
}
|