Agrega API de pagos externos: tokens por servicio, atados a pasarela e IP
Permite emitir credenciales (token hasheado + IP/CIDR opcional) desde
/app/pagos-externos para que aplicaciones de terceros pidan cobros a
través de Bold/dLocal/PayPal sin acceso a nada más del sistema:
- POST /api/v1/pagos-externos/solicitar genera el link de cobro real
usando solo las pasarelas habilitadas para ese servicio.
- Los webhooks existentes de Bold/dLocal/PayPal (firma obligatoria,
idempotentes) ahora también resuelven referencias "extpay-…" sin
tocar el flujo de contratos ("contrato-{id}").
- Al confirmarse el pago se notifica por webhook firmado (HMAC) y/o
Telegram, configurable por servicio.
- CRUD de servicios protegido con SoloAdmin; token y callback_secret
solo se muestran una vez, en DB se guardan hasheados.
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
var pasarelasValidas = map[string]bool{"bold": true, "dlocal": true, "paypal": true}
|
||||
|
||||
// PagosExternosIndex renderiza el panel de administración de servicios de pago externos.
|
||||
func PagosExternosIndex(c *fiber.Ctx) error {
|
||||
return c.Render("pagos_externos", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
// GetServiciosPagoExterno devuelve la lista paginada. El token nunca se
|
||||
// devuelve completo, solo el preview guardado al crearlo/regenerarlo.
|
||||
func GetServiciosPagoExterno(c *fiber.Ctx) error {
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := 20
|
||||
offset := (page - 1) * limit
|
||||
|
||||
items, total, err := models.GetAllServiciosPagoExterno(limit, offset)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
|
||||
type servicioPagoReq struct {
|
||||
Nombre string `json:"nombre"`
|
||||
IPsPermitidas []string `json:"ips_permitidas"`
|
||||
PasarelasHabilitadas []string `json:"pasarelas_habilitadas"`
|
||||
NotificarWebhook bool `json:"notificar_webhook"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
NotificarTelegram bool `json:"notificar_telegram"`
|
||||
TelegramChatID string `json:"telegram_chat_id"`
|
||||
Activo bool `json:"activo"`
|
||||
}
|
||||
|
||||
func (r servicioPagoReq) validar() (pasarelas []string, ips []string, err error) {
|
||||
if strings.TrimSpace(r.Nombre) == "" {
|
||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "nombre es requerido")
|
||||
}
|
||||
for _, p := range r.PasarelasHabilitadas {
|
||||
p = strings.ToLower(strings.TrimSpace(p))
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if !pasarelasValidas[p] {
|
||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "pasarela inválida: "+p)
|
||||
}
|
||||
pasarelas = append(pasarelas, p)
|
||||
}
|
||||
if len(pasarelas) == 0 {
|
||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "selecciona al menos una pasarela habilitada")
|
||||
}
|
||||
for _, ip := range r.IPsPermitidas {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip != "" {
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
}
|
||||
if r.NotificarWebhook && strings.TrimSpace(r.CallbackURL) == "" {
|
||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "callback_url es requerido si el webhook de notificación está activado")
|
||||
}
|
||||
if r.NotificarTelegram && strings.TrimSpace(r.TelegramChatID) == "" {
|
||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "telegram_chat_id es requerido si la notificación por Telegram está activada")
|
||||
}
|
||||
return pasarelas, ips, nil
|
||||
}
|
||||
|
||||
func generarSecretoHex(nbytes int) (string, error) {
|
||||
b := make([]byte, nbytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// CreateServicioPagoExternoHandler crea un nuevo servicio y devuelve el token
|
||||
// en texto plano — es la única respuesta donde vendrá completo.
|
||||
func CreateServicioPagoExternoHandler(c *fiber.Ctx) error {
|
||||
var req servicioPagoReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
pasarelas, ips, err := req.validar()
|
||||
if err != nil {
|
||||
return c.Status(err.(*fiber.Error).Code).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
item := &models.ServicioPagoExterno{
|
||||
Nombre: strings.TrimSpace(req.Nombre),
|
||||
IPsPermitidas: strings.Join(ips, ","),
|
||||
PasarelasHabilitadas: models.JoinModulos(pasarelas),
|
||||
NotificarWebhook: req.NotificarWebhook,
|
||||
CallbackURL: strings.TrimSpace(req.CallbackURL),
|
||||
NotificarTelegram: req.NotificarTelegram,
|
||||
TelegramChatID: strings.TrimSpace(req.TelegramChatID),
|
||||
Activo: true,
|
||||
CreadoPorID: extraerUserID(c),
|
||||
}
|
||||
if req.NotificarWebhook {
|
||||
secret, err := generarSecretoHex(32)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo generar el secreto de firma"})
|
||||
}
|
||||
item.CallbackSecret = secret
|
||||
}
|
||||
|
||||
tokenPlano, err := models.CreateServicioPagoExterno(item)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"ok": true,
|
||||
"id": item.ID,
|
||||
"token": tokenPlano,
|
||||
"callback_secret": item.CallbackSecret,
|
||||
"aviso": "Guarda el token y el callback_secret ahora: no se volverán a mostrar completos.",
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateServicioPagoExternoHandler actualiza un servicio existente (no toca el token).
|
||||
func UpdateServicioPagoExternoHandler(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
var req servicioPagoReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
pasarelas, ips, verr := req.validar()
|
||||
if verr != nil {
|
||||
return c.Status(verr.(*fiber.Error).Code).JSON(fiber.Map{"error": verr.Error()})
|
||||
}
|
||||
|
||||
existente, err := models.GetServicioPagoExternoByID(uint(id))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "servicio no encontrado"})
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"nombre": strings.TrimSpace(req.Nombre),
|
||||
"ips_permitidas": strings.Join(ips, ","),
|
||||
"pasarelas_habilitadas": models.JoinModulos(pasarelas),
|
||||
"notificar_webhook": req.NotificarWebhook,
|
||||
"callback_url": strings.TrimSpace(req.CallbackURL),
|
||||
"notificar_telegram": req.NotificarTelegram,
|
||||
"telegram_chat_id": strings.TrimSpace(req.TelegramChatID),
|
||||
"activo": req.Activo,
|
||||
}
|
||||
// Si se activa el webhook y todavía no tenía secreto (se creó sin él), generarlo ahora.
|
||||
if req.NotificarWebhook && existente.CallbackSecret == "" {
|
||||
secret, err := generarSecretoHex(32)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo generar el secreto de firma"})
|
||||
}
|
||||
updates["callback_secret"] = secret
|
||||
}
|
||||
|
||||
if err := models.UpdateServicioPagoExterno(uint(id), updates); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// RegenerarTokenServicioPagoHandler invalida el token actual y devuelve uno nuevo.
|
||||
func RegenerarTokenServicioPagoHandler(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
tokenPlano, err := models.RegenerarTokenServicioPago(uint(id))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true, "token": tokenPlano, "aviso": "El token anterior dejó de funcionar. Guarda este ahora, no se volverá a mostrar."})
|
||||
}
|
||||
|
||||
func DeleteServicioPagoExternoHandler(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
if err := models.DeleteServicioPagoExterno(uint(id)); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// GetSolicitudesPagoExternoHandler devuelve el historial de cobros, opcionalmente
|
||||
// filtrado por servicio, para trazabilidad/soporte.
|
||||
func GetSolicitudesPagoExternoHandler(c *fiber.Ctx) error {
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := 20
|
||||
offset := (page - 1) * limit
|
||||
servicioID, _ := strconv.ParseUint(c.Query("servicio_id", "0"), 10, 64)
|
||||
|
||||
items, total, err := models.GetAllSolicitudesPagoExterna(limit, offset, uint(servicioID))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user