Files
Lizandro GDandClaude Opus 5 c8e5afceb2 fix: seguridad de pagos y accesos, integración PayPal y Coolify ampliado
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>
2026-08-03 03:20:00 +00:00

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})
}