Files
soft_usite/rest/controllers/api/dlocal_controller.go
T
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

423 lines
14 KiB
Go

package controllers
import (
"fmt"
"log"
"strconv"
"time"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// Función para manejar la solicitud de creación de un plan
func CreatePlan(c *fiber.Ctx) error {
var plan models.PlanRequest
// Parsear el cuerpo de la solicitud a la estructura PlanRequest
if err := c.BodyParser(&plan); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": fmt.Sprintf("Error al parsear el cuerpo de la solicitud: %v", err),
})
}
// Obtener la configuración activa de DlocalApi
dlocalConfig, err := models.GetLastActiveDlocalApi()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Error al obtener la configuración activa de DlocalApi.",
})
}
// Llamar al servicio para crear el plan
response, err := services.CreatePlan(*dlocalConfig, plan)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": fmt.Sprintf("Error al crear el plan: %v", err),
})
}
// Devolver la respuesta exitosa
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Plan creado exitosamente.",
"response": string(response),
})
}
func SeePlan(c *fiber.Ctx) error {
planID := c.Params("planID")
// Verifica si se recibió correctamente
if planID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "PlanID es requerido.",
})
}
// Obtener la configuración activa de DlocalApi
dlocalConfig, err := models.GetLastActiveDlocalApi()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Error al obtener la configuración activa de DlocalApi.",
})
}
// Llamar al servicio para ver el plan
response, err := services.SeePlan(*dlocalConfig, planID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": fmt.Sprintf("Error al obtener el plan: %v", err),
})
}
// Devolver la respuesta exitosa
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Plan obtenido exitosamente.",
"response": string(response),
})
}
func UpdatedPlan(c *fiber.Ctx) error {
planID := c.Params("planID")
var plan services.PlanUpdated
// Parsear el cuerpo de la solicitud a la estructura PlanRequest
if err := c.BodyParser(&plan); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": fmt.Sprintf("Error al parsear el cuerpo de la solicitud: %v", err),
})
}
// Obtener la configuración activa de DlocalApi
dlocalConfig, err := models.GetLastActiveDlocalApi()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Error al obtener la configuración activa de DlocalApi.",
})
}
// Llamar al servicio para crear el plan
response, err := services.UpdatedPlan(*dlocalConfig, plan, planID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": fmt.Sprintf("Error al crear el plan: %v", err),
})
}
// Devolver la respuesta exitosa
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Plan actualizado exitosamente.",
"response": string(response),
})
}
func DeactivatePlan(c *fiber.Ctx) error {
planID := c.Params("planID")
subscriptionId := c.Params("subscriptionId")
// Obtener la configuración activa de DlocalApi
dlocalConfig, err := models.GetLastActiveDlocalApi()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Error al obtener la configuración activa de DlocalApi.",
})
}
// Llamar al servicio para crear el plan
response, err := services.DeactivatePlan(*dlocalConfig, planID, subscriptionId)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": fmt.Sprintf("Error al crear el plan: %v", err),
})
}
// Devolver la respuesta exitosa
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Suscripción cancelada exitosamente.",
"response": string(response),
})
}
func SeePlanes(c *fiber.Ctx) error {
// Obtener la configuración activa de DlocalApi
dlocalConfig, err := models.GetLastActiveDlocalApi()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Error al obtener la configuración activa de DlocalApi.",
})
}
// Llamar al servicio para ver el plan
response, err := services.SeePlanes(*dlocalConfig)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": fmt.Sprintf("Error al obtener los planes: %v", err),
})
}
// Devolver la respuesta exitosa
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Planes obtenido exitosamente.",
"response": string(response),
})
}
func SeeSubscription(c *fiber.Ctx) error {
subscriptionId := c.Params("subscriptionId")
invoiceId := c.Params("invoiceId")
// Obtener la configuración activa de DlocalApi
dlocalConfig, err := models.GetLastActiveDlocalApi()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Error al obtener la configuración activa de DlocalApi.",
})
}
// Llamar al servicio para ver suscripción
response, err := services.SeeSubscription(*dlocalConfig, subscriptionId, invoiceId)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": fmt.Sprintf("Error al obtener la suscripción: %v", err),
})
}
// Devolver la respuesta exitosa
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Suscripción obtenida exitosamente.",
"response": string(response),
})
}
func CreatePago(c *fiber.Ctx) error {
var pago services.PagoRequest
// Parsear el cuerpo de la solicitud a la estructura PagoRequest
if err := c.BodyParser(&pago); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": fmt.Sprintf("Error al parsear el cuerpo de la solicitud: %v", err),
})
}
// Obtener la configuración activa de DlocalApi
dlocalConfig, err := models.GetLastActiveDlocalApi()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Error al obtener la configuración activa de DlocalApi.",
})
}
// Llamar al servicio para crear el pago
response, err := services.CreatePago(*dlocalConfig, pago)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": fmt.Sprintf("Error al crear el pago: %v", err),
})
}
// Devolver la respuesta exitosa
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Pago creado exitosamente.",
"response": string(response),
})
}
// ─── Webhook dLocal ───────────────────────────────────────────────────────────
// DlocalWebhook recibe notificaciones de pago de dLocal y las registra de forma idempotente.
// dLocal espera HTTP 200; se responde inmediatamente y se procesa a continuación.
func DlocalWebhook(c *fiber.Ctx) error {
rawBody := c.Body()
// ─── 1. Configuración activa ─────────────────────────────────────────────
cfg, err := models.GetLastActiveDlocalApi()
if err != nil {
log.Println("[DLOCAL] Webhook: sin configuración activa")
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
}
// ─── 2. Verificar firma HMAC-SHA256 (obligatoria) ────────────────────────
// Sin firma cualquiera podría notificar un pago falso y marcar el contrato
// como pagado, así que se rechaza la petición en vez de aceptarla.
signature := c.Get("X-dLocal-Signature")
if signature == "" {
log.Printf("[DLOCAL] Webhook rechazado: sin firma (IP %s)", c.IP())
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"ok": false, "error": "firma requerida"})
}
if !services.VerifyDlocalSignature(rawBody, signature, *cfg) {
log.Printf("[DLOCAL] 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 notificación ─────────────────────────────────────────────
notif, err := services.ParseDlocalWebhookNotification(rawBody)
if err != nil {
log.Printf("[DLOCAL] Webhook: error parseando body: %v", err)
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
}
notificationID := notif.ID
if notificationID == "" {
log.Println("[DLOCAL] Webhook: notification_id vacío")
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
}
// ─── 4. Idempotencia ─────────────────────────────────────────────────────
if models.IsDlocalNotificationDuplicate(notificationID) {
log.Printf("[DLOCAL] Webhook: notificación duplicada %s", notificationID)
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
}
// ─── 5. Extraer campos (pago directo o suscripción) ──────────────────────
paymentID := notif.Payment.ID
orderID := notif.Payment.OrderID
payerEmail := notif.Payment.Payer.Email
monto := notif.Payment.Amount
moneda := notif.Payment.Currency
tipo := notif.Type
estado := notif.Status
// Si es suscripción, tomar amounts del invoice
if tipo == "SUBSCRIPTION_CHARGE" || tipo == "SUBSCRIPTION_PAYMENT" {
if notif.Invoice.Amount > 0 {
monto = notif.Invoice.Amount
moneda = notif.Invoice.Currency
}
if paymentID == "" {
paymentID = notif.Invoice.ID
}
if orderID == "" {
orderID = notif.Subscription.ID
}
}
// ─── 6. Guardar log ──────────────────────────────────────────────────────
logEntry := models.DlocalPaymentLog{
NotificationID: notificationID,
Fuente: "webhook",
Tipo: tipo,
Estado: estado,
PaymentID: paymentID,
OrderID: orderID,
Referencia: orderID, // order_id coincide con la referencia del contrato
PayerEmail: payerEmail,
Monto: monto,
Moneda: moneda,
Procesado: false,
Raw: string(rawBody),
}
if err := models.SaveDlocalPaymentLog(logEntry); err != nil {
log.Printf("[DLOCAL] Webhook: error guardando log: %v", err)
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
}
// ─── 7. Procesar si el estado es PAID ────────────────────────────────────
if estado != "PAID" {
log.Printf("[DLOCAL] Webhook: estado '%s' — registrado sin procesar", estado)
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
}
log.Printf("[DLOCAL] Webhook: PAID — payment_id=%s order=%s email=%s monto=%.2f %s",
paymentID, orderID, payerEmail, monto, moneda)
// Intentar vincular con contrato si el order_id tiene formato "contrato-{id}"
if orderID != "" {
var contratoID uint
if _, err := fmt.Sscanf(orderID, "contrato-%d", &contratoID); err == nil && contratoID > 0 {
if ok, err := models.MarcarContratoPagado(contratoID); err != nil {
log.Printf("[DLOCAL] Webhook: error marcando contrato %d como pagado: %v", contratoID, err)
} else {
log.Printf("[DLOCAL] Webhook: contrato %d marcado como pagado (nuevo=%v)", contratoID, ok)
if ok {
go services.DispatchSaasPaymentNotification(contratoID, payerEmail, "dlocal", monto, moneda)
go services.EnviarCorreoConfirmacionPago(contratoID, "dlocal")
}
}
}
}
models.MarkDlocalPaymentProcessed(notificationID)
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
}
// ─── Registro manual de pago dLocal ──────────────────────────────────────────
// DlocalRegistrarPago permite registrar manualmente un pago dLocal desde el backoffice
// cuando la notificación no llegó por webhook (actualización directa, confirmación manual, etc.).
func DlocalRegistrarPago(c *fiber.Ctx) error {
type body struct {
PaymentID string `json:"payment_id"`
OrderID string `json:"order_id"`
Referencia string `json:"referencia"`
PayerEmail string `json:"payer_email"`
Monto float64 `json:"monto"`
Moneda string `json:"moneda"`
Estado string `json:"estado"` // PAID, PENDING, REJECTED, …
Tipo string `json:"tipo"` // PAYMENT, SUBSCRIPTION_CHARGE, manual, …
Nota string `json:"nota"`
}
var b body
if err := c.BodyParser(&b); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if b.Estado == "" {
b.Estado = "PAID"
}
if b.Tipo == "" {
b.Tipo = "manual"
}
if b.Moneda == "" {
b.Moneda = "USD"
}
// Generar un notification_id único para entradas manuales
notifID := "manual-" + strconv.FormatInt(time.Now().UnixNano(), 36)
if b.PaymentID != "" {
notifID = "manual-" + b.PaymentID
}
// No deduplicar entradas manuales con el mismo payment_id — pueden ser actualizaciones
// Se usa prefijo diferente por cada llamada si payment_id es el mismo
if models.IsDlocalNotificationDuplicate(notifID) {
notifID = "manual-" + strconv.FormatInt(time.Now().UnixNano(), 36)
}
referencia := b.Referencia
if referencia == "" {
referencia = b.OrderID
}
entry := models.DlocalPaymentLog{
NotificationID: notifID,
Fuente: "manual",
Tipo: b.Tipo,
Estado: b.Estado,
PaymentID: b.PaymentID,
OrderID: b.OrderID,
Referencia: referencia,
PayerEmail: b.PayerEmail,
Monto: b.Monto,
Moneda: b.Moneda,
Nota: b.Nota,
Procesado: b.Estado == "PAID",
}
if err := models.SaveDlocalPaymentLog(entry); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Si el estado es PAID y referencia tiene formato "contrato-{id}", marcar contrato
if b.Estado == "PAID" && referencia != "" {
var contratoID uint
if _, err := fmt.Sscanf(referencia, "contrato-%d", &contratoID); err == nil && contratoID > 0 {
if _, markErr := models.MarcarContratoPagado(contratoID); markErr != nil {
log.Printf("[DLOCAL] Registro manual: error marcando contrato %d: %v", contratoID, markErr)
} else {
log.Printf("[DLOCAL] Registro manual: contrato %d marcado como pagado", contratoID)
}
}
}
return c.JSON(fiber.Map{"message": "Pago registrado", "notification_id": notifID})
}