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:
Lizandro GD
2026-08-04 14:16:13 +00:00
parent 3e2e4b63e9
commit e5849549a0
15 changed files with 1424 additions and 5 deletions
+7 -1
View File
@@ -117,7 +117,13 @@ func BoldWebhook(c *fiber.Ctx) error {
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 ────────────────────
// ─── 7. Intentar vincular con una solicitud de pago externo ─────────────
if services.ConfirmarPagoExternoPorReferencia(referencia, paymentID, "bold") {
models.MarkBoldWebhookProcessed(notificationID)
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
}
// ─── 8. Marcar contrato como pagado y limpiar enlace ────────────────────
// La referencia tiene formato "contrato-{id}"
if referencia != "" {
var contratoID uint
@@ -320,6 +320,12 @@ func DlocalWebhook(c *fiber.Ctx) error {
log.Printf("[DLOCAL] Webhook: PAID — payment_id=%s order=%s email=%s monto=%.2f %s",
paymentID, orderID, payerEmail, monto, moneda)
// Intentar vincular con una solicitud de pago externo (referencia "extpay-…")
if services.ConfirmarPagoExternoPorReferencia(orderID, paymentID, "dlocal") {
models.MarkDlocalPaymentProcessed(notificationID)
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
}
// Intentar vincular con contrato si el order_id tiene formato "contrato-{id}"
if orderID != "" {
var contratoID uint
@@ -0,0 +1,101 @@
package controllers
import (
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
func servicioPagoDeContexto(c *fiber.Ctx) (*models.ServicioPagoExterno, error) {
servicio, ok := c.Locals("servicio_pago").(*models.ServicioPagoExterno)
if !ok || servicio == nil {
return nil, fiber.NewError(fiber.StatusUnauthorized, "no autenticado")
}
return servicio, nil
}
// PasarelasDisponiblesHandler indica qué pasarelas puede usar el token actual.
// Ruta: GET /api/v1/pagos-externos/pasarelas
func PasarelasDisponiblesHandler(c *fiber.Ctx) error {
servicio, err := servicioPagoDeContexto(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()})
}
return c.JSON(fiber.Map{"pasarelas": servicio.PasarelasList()})
}
// SolicitarPagoExternoHandler crea un cobro nuevo con el token autenticado.
// Ruta: POST /api/v1/pagos-externos/solicitar
func SolicitarPagoExternoHandler(c *fiber.Ctx) error {
servicio, err := servicioPagoDeContexto(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()})
}
var req struct {
ReferenciaExterna string `json:"referencia_externa"`
Pasarela string `json:"pasarela"`
Monto float64 `json:"monto"`
Moneda string `json:"moneda"`
Descripcion string `json:"descripcion"`
ClienteID *uint `json:"cliente_id"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": "body inválido"})
}
if strings.TrimSpace(req.ReferenciaExterna) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": "referencia_externa es requerida"})
}
solicitud, err := services.SolicitarPagoExterno(
servicio,
strings.TrimSpace(req.ReferenciaExterna),
strings.ToLower(strings.TrimSpace(req.Pasarela)),
strings.TrimSpace(req.Descripcion),
strings.ToUpper(strings.TrimSpace(req.Moneda)),
req.Monto,
req.ClienteID,
)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"referencia_externa": solicitud.ReferenciaExterna,
"referencia_interna": solicitud.ReferenciaInterna,
"enlace_pago": solicitud.EnlacePago,
"pasarela": solicitud.Pasarela,
"estado": solicitud.Estado,
"monto": solicitud.Monto,
"moneda": solicitud.Moneda,
})
}
// EstadoPagoExternoHandler consulta el estado de un cobro. Solo devuelve datos
// si la solicitud pertenece al mismo servicio autenticado, para que un token
// no pueda enumerar/consultar solicitudes de otro cliente.
// Ruta: GET /api/v1/pagos-externos/:referencia/estado
func EstadoPagoExternoHandler(c *fiber.Ctx) error {
servicio, err := servicioPagoDeContexto(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()})
}
referencia := c.Params("referencia")
solicitud, err := models.GetSolicitudPagoExternaByReferenciaInterna(referencia)
if err != nil || solicitud.ServicioID != servicio.ID {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": true, "message": "solicitud no encontrada"})
}
return c.JSON(fiber.Map{
"referencia_externa": solicitud.ReferenciaExterna,
"referencia_interna": solicitud.ReferenciaInterna,
"estado": solicitud.Estado,
"pasarela": solicitud.Pasarela,
"monto": solicitud.Monto,
"moneda": solicitud.Moneda,
"fecha_pago": solicitud.FechaPago,
})
}
+7 -3
View File
@@ -74,17 +74,21 @@ func PaypalWebhook(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
}
marcarContratoPagadoPorReferencia(evento.Referencia, "paypal")
marcarContratoPagadoPorReferencia(evento.Referencia, evento.ResourceID, "paypal")
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
}
// marcarContratoPagadoPorReferencia confirma el pago del contrato codificado en
// una referencia con formato "contrato-{id}".
func marcarContratoPagadoPorReferencia(referencia, pasarela string) {
// una referencia con formato "contrato-{id}", o de una solicitud de pago
// externo con formato "extpay-…".
func marcarContratoPagadoPorReferencia(referencia, transaccionID, pasarela string) {
if referencia == "" {
log.Printf("[%s] Pago confirmado pero sin referencia de contrato: requiere conciliación manual", pasarela)
return
}
if services.ConfirmarPagoExternoPorReferencia(referencia, transaccionID, pasarela) {
return
}
var contratoID uint
if _, err := fmt.Sscanf(referencia, "contrato-%d", &contratoID); err != nil || contratoID == 0 {
log.Printf("[%s] Referencia '%s' no corresponde a un contrato", pasarela, referencia)
@@ -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,
})
}