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:
@@ -129,6 +129,9 @@ func main() {
|
||||
// Tablero de tareas
|
||||
&models.Tarea{},
|
||||
&models.TareaComentario{},
|
||||
// Pagos externos: API de cobros para aplicaciones de terceros
|
||||
&models.ServicioPagoExterno{},
|
||||
&models.SolicitudPagoExterna{},
|
||||
// Soporte: webhook de correo entrante
|
||||
&models.SoporteWebhookConfig{},
|
||||
// Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA
|
||||
@@ -169,6 +172,7 @@ func main() {
|
||||
migrations.SeedWebSms()
|
||||
migrations.SeedUrlMonitor()
|
||||
migrations.SeedTareas()
|
||||
migrations.SeedPagosExternos()
|
||||
migrations.SeedAutomatizacionIA()
|
||||
if n, err := models.RepararEstadosTareaInvalidos(); err != nil {
|
||||
log.Printf("[FIX] Error reparando estados de tareas: %v", err)
|
||||
|
||||
@@ -104,6 +104,9 @@ func Migrate() {
|
||||
// Tablero de tareas
|
||||
&models.Tarea{},
|
||||
&models.TareaComentario{},
|
||||
// Pagos externos: API de cobros para aplicaciones de terceros
|
||||
&models.ServicioPagoExterno{},
|
||||
&models.SolicitudPagoExterna{},
|
||||
// Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA
|
||||
&models.PlantillaDocumento{},
|
||||
&models.Tarifa{},
|
||||
@@ -1221,3 +1224,40 @@ func SeedTareas() {
|
||||
}
|
||||
log.Println("[SEED] Seed de Tareas completado.")
|
||||
}
|
||||
|
||||
// SeedPagosExternos registra el submódulo del panel de administración de
|
||||
// servicios de pago externos (emisión de tokens de API para terceros).
|
||||
func SeedPagosExternos() {
|
||||
db := app.Http.Database.DB
|
||||
var modulo models.Modules
|
||||
if err := db.Where("title = ?", "Administración").First(&modulo).Error; err != nil {
|
||||
log.Println("[SEED] Módulo 'Administración' no encontrado, se omite SeedPagosExternos")
|
||||
return
|
||||
}
|
||||
url := "/app/pagos-externos"
|
||||
var sub models.Submodules
|
||||
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
|
||||
sub = models.Submodules{
|
||||
Title: "Pagos externos",
|
||||
Description: "Tokens de API para que apps de terceros pidan cobros por Bold/dLocal/PayPal",
|
||||
Url: url,
|
||||
ModuleId: modulo.ID,
|
||||
ModifiedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&sub).Error; err != nil {
|
||||
log.Printf("[SEED] Error creando submódulo 'Pagos externos': %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[SEED] Submódulo 'Pagos externos' creado")
|
||||
} else if sub.ModuleId != modulo.ID {
|
||||
db.Model(&sub).Update("module_id", modulo.ID)
|
||||
}
|
||||
var rol models.Roles
|
||||
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||
} else {
|
||||
asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
|
||||
log.Printf("[SEED] Submódulo 'Pagos externos' asignado al rol 'Administrador'")
|
||||
}
|
||||
log.Println("[SEED] Seed de Pagos externos completado.")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ServicioPagoExterno es una credencial que le entregamos a una aplicación
|
||||
// externa para que pueda pedir cobros a través de nuestras pasarelas
|
||||
// (Bold/dLocal/PayPal) sin darle acceso a nada más del sistema. El token solo
|
||||
// se muestra en texto plano una vez, al crearlo o regenerarlo: en la base
|
||||
// solo se guarda su hash (igual que una API key de Stripe/GitHub).
|
||||
type ServicioPagoExterno struct {
|
||||
gorm.Model
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:150"`
|
||||
TokenHash string `json:"-" gorm:"column:token_hash;uniqueIndex;size:64"`
|
||||
TokenPreview string `json:"token_preview" gorm:"column:token_preview;size:12"`
|
||||
IPsPermitidas string `json:"ips_permitidas" gorm:"column:ips_permitidas;type:text"`
|
||||
PasarelasHabilitadas string `json:"pasarelas_habilitadas" gorm:"column:pasarelas_habilitadas;size:100"`
|
||||
NotificarWebhook bool `json:"notificar_webhook" gorm:"column:notificar_webhook;default:false"`
|
||||
CallbackURL string `json:"callback_url" gorm:"column:callback_url;type:text"`
|
||||
CallbackSecret string `json:"-" gorm:"column:callback_secret;size:100"`
|
||||
NotificarTelegram bool `json:"notificar_telegram" gorm:"column:notificar_telegram;default:false"`
|
||||
TelegramChatID string `json:"telegram_chat_id" gorm:"column:telegram_chat_id;size:50"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"`
|
||||
}
|
||||
|
||||
func (ServicioPagoExterno) TableName() string { return "servicios_pago_externos" }
|
||||
|
||||
// SolicitudPagoExterna es cada cobro individual pedido por una app externa a
|
||||
// través de un ServicioPagoExterno.
|
||||
type SolicitudPagoExterna struct {
|
||||
gorm.Model
|
||||
ServicioID uint `json:"servicio_id" gorm:"column:servicio_id;index"`
|
||||
Servicio *ServicioPagoExterno `json:"servicio,omitempty" gorm:"foreignKey:ServicioID"`
|
||||
ReferenciaExterna string `json:"referencia_externa" gorm:"column:referencia_externa;size:150;index"`
|
||||
ReferenciaInterna string `json:"referencia_interna" gorm:"column:referencia_interna;uniqueIndex;size:60"`
|
||||
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id"`
|
||||
Cliente *Cliente `json:"cliente,omitempty" gorm:"foreignKey:ClienteID"`
|
||||
Pasarela string `json:"pasarela" gorm:"column:pasarela;size:20"`
|
||||
Monto float64 `json:"monto" gorm:"column:monto"`
|
||||
Moneda string `json:"moneda" gorm:"column:moneda;size:10"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
// Estado: pendiente | pagado | fallido | expirado
|
||||
Estado string `json:"estado" gorm:"column:estado;default:'pendiente';index"`
|
||||
EnlacePago string `json:"enlace_pago" gorm:"column:enlace_pago;type:text"`
|
||||
PasarelaLinkID string `json:"-" gorm:"column:pasarela_link_id;size:150"`
|
||||
PasarelaTxID string `json:"pasarela_tx_id" gorm:"column:pasarela_tx_id;size:150"`
|
||||
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
|
||||
CallbackEntregado bool `json:"callback_entregado" gorm:"column:callback_entregado;default:false"`
|
||||
CallbackIntentos int `json:"callback_intentos" gorm:"column:callback_intentos;default:0"`
|
||||
TelegramEntregado bool `json:"telegram_entregado" gorm:"column:telegram_entregado;default:false"`
|
||||
}
|
||||
|
||||
func (SolicitudPagoExterna) TableName() string { return "solicitudes_pago_externas" }
|
||||
|
||||
// ─── Tokens ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// GenerarTokenServicioPago crea un token aleatorio de 32 bytes (64 hex chars) y
|
||||
// su hash SHA-256. El token crudo se devuelve una sola vez: solo el hash se
|
||||
// guarda en base de datos, así una filtración de la BD no expone credenciales
|
||||
// utilizables directamente.
|
||||
func GenerarTokenServicioPago() (raw string, hash string, err error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", fmt.Errorf("no se pudo generar el token: %w", err)
|
||||
}
|
||||
raw = "spx_" + hex.EncodeToString(b)
|
||||
hash = HashTokenServicioPago(raw)
|
||||
return raw, hash, nil
|
||||
}
|
||||
|
||||
func HashTokenServicioPago(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// tokenPreview devuelve los últimos caracteres del token para poder
|
||||
// identificarlo en el panel sin volver a mostrarlo completo.
|
||||
func tokenPreview(raw string) string {
|
||||
if len(raw) <= 8 {
|
||||
return raw
|
||||
}
|
||||
return "..." + raw[len(raw)-6:]
|
||||
}
|
||||
|
||||
// ─── CRUD ServicioPagoExterno ───────────────────────────────────────────────
|
||||
|
||||
// CreateServicioPagoExterno genera el token, lo hashea y crea el registro.
|
||||
// Devuelve el token en texto plano: es la única vez que estará disponible.
|
||||
func CreateServicioPagoExterno(s *ServicioPagoExterno) (tokenPlano string, err error) {
|
||||
raw, hash, err := GenerarTokenServicioPago()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.TokenHash = hash
|
||||
s.TokenPreview = tokenPreview(raw)
|
||||
if err := app.Http.Database.DB.Create(s).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// RegenerarTokenServicioPago invalida el token anterior y genera uno nuevo.
|
||||
func RegenerarTokenServicioPago(id uint) (tokenPlano string, err error) {
|
||||
raw, hash, err := GenerarTokenServicioPago()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result := app.Http.Database.DB.Model(&ServicioPagoExterno{}).Where("id = ?", id).
|
||||
Updates(map[string]interface{}{"token_hash": hash, "token_preview": tokenPreview(raw)})
|
||||
if result.Error != nil {
|
||||
return "", result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return "", fmt.Errorf("servicio de pago no encontrado")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func GetAllServiciosPagoExterno(limit, offset int) ([]ServicioPagoExterno, int64, error) {
|
||||
var items []ServicioPagoExterno
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&ServicioPagoExterno{})
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func GetServicioPagoExternoByID(id uint) (*ServicioPagoExterno, error) {
|
||||
var s ServicioPagoExterno
|
||||
if err := app.Http.Database.DB.First(&s, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// FindServicioPagoExternoActivoByToken resuelve el servicio a partir del token
|
||||
// crudo recibido en el header Authorization. Solo hace match si está activo.
|
||||
func FindServicioPagoExternoActivoByToken(rawToken string) (*ServicioPagoExterno, error) {
|
||||
hash := HashTokenServicioPago(rawToken)
|
||||
var s ServicioPagoExterno
|
||||
if err := app.Http.Database.DB.Where("token_hash = ? AND activo = ?", hash, true).First(&s).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func UpdateServicioPagoExterno(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&ServicioPagoExterno{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func DeleteServicioPagoExterno(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&ServicioPagoExterno{}, id).Error
|
||||
}
|
||||
|
||||
// IPPermitida valida la IP del caller contra la lista configurada en el
|
||||
// servicio (coma-separada, admite IP exacta o CIDR). Lista vacía = sin
|
||||
// restricción de IP.
|
||||
func (s *ServicioPagoExterno) IPPermitida(ip string) bool {
|
||||
lista := strings.TrimSpace(s.IPsPermitidas)
|
||||
if lista == "" {
|
||||
return true
|
||||
}
|
||||
callerIP := net.ParseIP(ip)
|
||||
if callerIP == nil {
|
||||
return false
|
||||
}
|
||||
for _, entrada := range strings.Split(lista, ",") {
|
||||
entrada = strings.TrimSpace(entrada)
|
||||
if entrada == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(entrada, "/") {
|
||||
_, red, err := net.ParseCIDR(entrada)
|
||||
if err == nil && red.Contains(callerIP) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if net.ParseIP(entrada) != nil && net.ParseIP(entrada).Equal(callerIP) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PasarelasList devuelve las pasarelas habilitadas para este servicio.
|
||||
func (s *ServicioPagoExterno) PasarelasList() []string {
|
||||
return SplitModulos(s.PasarelasHabilitadas)
|
||||
}
|
||||
|
||||
// PasarelaHabilitada indica si el servicio puede usar esa pasarela.
|
||||
func (s *ServicioPagoExterno) PasarelaHabilitada(pasarela string) bool {
|
||||
for _, p := range s.PasarelasList() {
|
||||
if p == pasarela {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── SolicitudPagoExterna ───────────────────────────────────────────────────
|
||||
|
||||
// GenerarReferenciaInterna crea una referencia única con el prefijo "extpay-"
|
||||
// que los webhooks de las pasarelas usan para distinguir estas solicitudes de
|
||||
// las de un Contrato (que usan "contrato-{id}").
|
||||
func GenerarReferenciaInterna() (string, error) {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("no se pudo generar la referencia: %w", err)
|
||||
}
|
||||
return "extpay-" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func CreateSolicitudPagoExterna(s *SolicitudPagoExterna) error {
|
||||
return app.Http.Database.DB.Create(s).Error
|
||||
}
|
||||
|
||||
func GetSolicitudPagoExternaByReferenciaInterna(ref string) (*SolicitudPagoExterna, error) {
|
||||
var s SolicitudPagoExterna
|
||||
if err := app.Http.Database.DB.Preload("Servicio").Preload("Cliente").
|
||||
Where("referencia_interna = ?", ref).First(&s).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func GetAllSolicitudesPagoExterna(limit, offset int, servicioID uint) ([]SolicitudPagoExterna, int64, error) {
|
||||
var items []SolicitudPagoExterna
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&SolicitudPagoExterna{}).Preload("Servicio").Preload("Cliente")
|
||||
if servicioID > 0 {
|
||||
db = db.Where("servicio_id = ?", servicioID)
|
||||
}
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// MarcarSolicitudPagoExternaPagada transiciona pendiente→pagado de forma
|
||||
// idempotente (igual patrón que MarcarContratoPagado): si ya estaba pagada,
|
||||
// no vuelve a disparar la notificación. Devuelve ok=true solo en la
|
||||
// transición real.
|
||||
func MarcarSolicitudPagoExternaPagada(referenciaInterna, pasarelaTxID string) (ok bool, solicitud *SolicitudPagoExterna, err error) {
|
||||
now := time.Now()
|
||||
result := app.Http.Database.DB.Model(&SolicitudPagoExterna{}).
|
||||
Where("referencia_interna = ? AND estado = ?", referenciaInterna, "pendiente").
|
||||
Updates(map[string]interface{}{
|
||||
"estado": "pagado",
|
||||
"fecha_pago": now,
|
||||
"pasarela_tx_id": pasarelaTxID,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, nil, result.Error
|
||||
}
|
||||
s, getErr := GetSolicitudPagoExternaByReferenciaInterna(referenciaInterna)
|
||||
if getErr != nil {
|
||||
return result.RowsAffected > 0, nil, getErr
|
||||
}
|
||||
return result.RowsAffected > 0, s, nil
|
||||
}
|
||||
|
||||
// MarcarCallbackEntregado registra que la notificación saliente (webhook y/o
|
||||
// Telegram) ya se intentó entregar, para no reintentar indefinidamente sin
|
||||
// visibilidad.
|
||||
func MarcarCallbackEntregado(id uint, webhookOk, telegramOk bool) error {
|
||||
updates := map[string]interface{}{}
|
||||
if webhookOk {
|
||||
updates["callback_entregado"] = true
|
||||
}
|
||||
if telegramOk {
|
||||
updates["telegram_entregado"] = true
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return app.Http.Database.DB.Model(&SolicitudPagoExterna{}).Where("id = ?", id).
|
||||
Update("callback_intentos", gorm.Expr("callback_intentos + 1")).Error
|
||||
}
|
||||
updates["callback_intentos"] = gorm.Expr("callback_intentos + 1")
|
||||
return app.Http.Database.DB.Model(&SolicitudPagoExterna{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
var pagoExternoHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// ConfirmarPagoExternoPorReferencia resuelve una referencia de webhook con
|
||||
// prefijo "extpay-" (las de Contrato usan "contrato-{id}") y, si corresponde,
|
||||
// marca la solicitud como pagada y dispara la notificación al dueño del
|
||||
// servicio. Devuelve manejada=true cuando la referencia era de este tipo, para
|
||||
// que el webhook que llama sepa que no debe intentar resolverla como contrato.
|
||||
func ConfirmarPagoExternoPorReferencia(referencia, pasarelaTxID, pasarela string) (manejada bool) {
|
||||
if !strings.HasPrefix(referencia, "extpay-") {
|
||||
return false
|
||||
}
|
||||
ok, solicitud, err := models.MarcarSolicitudPagoExternaPagada(referencia, pasarelaTxID)
|
||||
if err != nil {
|
||||
log.Printf("[PAGO_EXTERNO] error marcando %s como pagada: %v", referencia, err)
|
||||
return true
|
||||
}
|
||||
if !ok {
|
||||
log.Printf("[PAGO_EXTERNO] %s ya estaba confirmada, se ignora notificación duplicada", referencia)
|
||||
return true
|
||||
}
|
||||
log.Printf("[PAGO_EXTERNO] %s confirmada vía %s", referencia, pasarela)
|
||||
go EntregarNotificacionPagoExterno(solicitud)
|
||||
return true
|
||||
}
|
||||
|
||||
// SolicitarPagoExterno crea un cobro para un ServicioPagoExterno ya
|
||||
// autenticado (token+IP verificados por el middleware) usando una de sus
|
||||
// pasarelas habilitadas.
|
||||
func SolicitarPagoExterno(servicio *models.ServicioPagoExterno, referenciaExterna, pasarela, descripcion, moneda string, monto float64, clienteID *uint) (*models.SolicitudPagoExterna, error) {
|
||||
if monto <= 0 {
|
||||
return nil, fmt.Errorf("monto debe ser mayor a 0")
|
||||
}
|
||||
habilitadas := servicio.PasarelasList()
|
||||
if len(habilitadas) == 0 {
|
||||
return nil, fmt.Errorf("este servicio no tiene ninguna pasarela habilitada, contacta al administrador")
|
||||
}
|
||||
if pasarela == "" {
|
||||
if len(habilitadas) == 1 {
|
||||
pasarela = habilitadas[0]
|
||||
} else {
|
||||
return nil, fmt.Errorf("especifica 'pasarela': este servicio tiene varias habilitadas (%v)", habilitadas)
|
||||
}
|
||||
} else if !servicio.PasarelaHabilitada(pasarela) {
|
||||
return nil, fmt.Errorf("pasarela '%s' no está habilitada para este servicio (habilitadas: %v)", pasarela, habilitadas)
|
||||
}
|
||||
if clienteID != nil {
|
||||
if _, err := models.GetClienteByID(*clienteID); err != nil {
|
||||
return nil, fmt.Errorf("cliente_id inválido: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
referenciaInterna, err := models.GenerarReferenciaInterna()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if descripcion == "" {
|
||||
descripcion = fmt.Sprintf("Cobro %s", referenciaInterna)
|
||||
}
|
||||
if moneda == "" {
|
||||
moneda = "COP"
|
||||
}
|
||||
|
||||
var enlace, linkID string
|
||||
switch pasarela {
|
||||
case "bold":
|
||||
enlace, linkID, err = crearCobroBoldExterno(referenciaInterna, descripcion, moneda, monto)
|
||||
case "dlocal":
|
||||
enlace, linkID, err = crearCobroDlocalExterno(referenciaInterna, descripcion, moneda, monto)
|
||||
case "paypal":
|
||||
enlace, linkID, err = crearCobroPaypalExterno(referenciaInterna, descripcion, moneda, monto)
|
||||
default:
|
||||
err = fmt.Errorf("pasarela '%s' no soportada", pasarela)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
solicitud := &models.SolicitudPagoExterna{
|
||||
ServicioID: servicio.ID,
|
||||
ReferenciaExterna: referenciaExterna,
|
||||
ReferenciaInterna: referenciaInterna,
|
||||
ClienteID: clienteID,
|
||||
Pasarela: pasarela,
|
||||
Monto: monto,
|
||||
Moneda: moneda,
|
||||
Descripcion: descripcion,
|
||||
Estado: "pendiente",
|
||||
EnlacePago: enlace,
|
||||
PasarelaLinkID: linkID,
|
||||
}
|
||||
if err := models.CreateSolicitudPagoExterna(solicitud); err != nil {
|
||||
return nil, fmt.Errorf("no se pudo guardar la solicitud: %w", err)
|
||||
}
|
||||
return solicitud, nil
|
||||
}
|
||||
|
||||
func crearCobroBoldExterno(referencia, descripcion, moneda string, monto float64) (enlace, linkID string, err error) {
|
||||
cfg, err := models.GetBoldConfig()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("Bold no está configurado")
|
||||
}
|
||||
req := BoldPaymentLinkRequest{
|
||||
AmountType: "CLOSE",
|
||||
Amount: BoldAmountField{
|
||||
Currency: moneda,
|
||||
TotalAmount: int64(math.Round(monto)),
|
||||
},
|
||||
Description: descripcion,
|
||||
Reference: referencia,
|
||||
CallbackURL: cfg.CallbackUrl + "?ref=" + referencia,
|
||||
}
|
||||
result, err := CreateBoldPaymentLink(cfg, req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return result.Payload.URL, result.Payload.PaymentLink, nil
|
||||
}
|
||||
|
||||
func crearCobroDlocalExterno(referencia, descripcion, moneda string, monto float64) (enlace, linkID string, err error) {
|
||||
cfg, err := models.GetLastActiveDlocalApi()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("dLocal no está configurado")
|
||||
}
|
||||
pagoReq := PagoRequest{
|
||||
Currency: moneda,
|
||||
Amount: monto,
|
||||
OrderID: referencia,
|
||||
Description: descripcion,
|
||||
SuccessURL: absAppURL("/pago-exitoso?ref=" + referencia),
|
||||
BackURL: absAppURL("/pago-cancelado?ref=" + referencia),
|
||||
NotificationURL: absAppURL("/webhooks/dlocal"),
|
||||
}
|
||||
body, err := CreatePago(*cfg, pagoReq)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var resp struct {
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil || resp.RedirectURL == "" {
|
||||
return "", "", fmt.Errorf("dLocal no devolvió redirect_url: %s", string(body))
|
||||
}
|
||||
return resp.RedirectURL, resp.ID, nil
|
||||
}
|
||||
|
||||
func crearCobroPaypalExterno(referencia, descripcion, moneda string, monto float64) (enlace, linkID string, err error) {
|
||||
cfg, err := models.GetPaypalConfig()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("PayPal no está configurado")
|
||||
}
|
||||
orderID, urlAprobacion, err := PaypalCrearOrden(cfg, referencia, descripcion, moneda, monto)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return urlAprobacion, orderID, nil
|
||||
}
|
||||
|
||||
// EntregarNotificacionPagoExterno avisa al dueño del ServicioPagoExterno que
|
||||
// un cobro se confirmó, por los canales que tenga habilitados (webhook
|
||||
// firmado y/o Telegram). Llamar solo una vez, justo después de que
|
||||
// MarcarSolicitudPagoExternaPagada confirme la transición real (ok=true) —
|
||||
// si no hay transición real (ya estaba pagada), no se vuelve a notificar.
|
||||
func EntregarNotificacionPagoExterno(solicitud *models.SolicitudPagoExterna) {
|
||||
if solicitud == nil {
|
||||
return
|
||||
}
|
||||
if solicitud.Servicio == nil {
|
||||
full, err := models.GetSolicitudPagoExternaByReferenciaInterna(solicitud.ReferenciaInterna)
|
||||
if err != nil {
|
||||
log.Printf("[PAGO_EXTERNO] no se pudo recargar la solicitud %s para notificar: %v", solicitud.ReferenciaInterna, err)
|
||||
return
|
||||
}
|
||||
solicitud = full
|
||||
}
|
||||
servicio := solicitud.Servicio
|
||||
if servicio == nil {
|
||||
log.Printf("[PAGO_EXTERNO] solicitud %s sin servicio asociado, no se puede notificar", solicitud.ReferenciaInterna)
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"referencia_externa": solicitud.ReferenciaExterna,
|
||||
"referencia_interna": solicitud.ReferenciaInterna,
|
||||
"estado": solicitud.Estado,
|
||||
"pasarela": solicitud.Pasarela,
|
||||
"monto": solicitud.Monto,
|
||||
"moneda": solicitud.Moneda,
|
||||
"pasarela_tx_id": solicitud.PasarelaTxID,
|
||||
"fecha_pago": solicitud.FechaPago,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
webhookOk := true
|
||||
if servicio.NotificarWebhook && servicio.CallbackURL != "" {
|
||||
webhookOk = enviarCallbackFirmado(servicio.CallbackURL, servicio.CallbackSecret, body)
|
||||
if !webhookOk {
|
||||
log.Printf("[PAGO_EXTERNO] fallo entregando callback de %s a %s", solicitud.ReferenciaInterna, servicio.CallbackURL)
|
||||
}
|
||||
}
|
||||
|
||||
telegramOk := true
|
||||
if servicio.NotificarTelegram && servicio.TelegramChatID != "" {
|
||||
telegramOk = enviarTelegramPagoExterno(servicio.TelegramChatID, solicitud)
|
||||
}
|
||||
|
||||
if err := models.MarcarCallbackEntregado(solicitud.ID, webhookOk, telegramOk); err != nil {
|
||||
log.Printf("[PAGO_EXTERNO] error registrando intento de entrega para %s: %v", solicitud.ReferenciaInterna, err)
|
||||
}
|
||||
}
|
||||
|
||||
func enviarCallbackFirmado(url, secret string, body []byte) bool {
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if secret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
resp, err := pagoExternoHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode >= 200 && resp.StatusCode < 300
|
||||
}
|
||||
|
||||
func enviarTelegramPagoExterno(chatID string, s *models.SolicitudPagoExterna) bool {
|
||||
_, tgCfg, err := models.GetAgenteBotConfig()
|
||||
if err != nil || tgCfg == nil || tgCfg.BotToken == "" {
|
||||
log.Printf("[PAGO_EXTERNO] no hay bot de Telegram del agente configurado para notificar %s", s.ReferenciaInterna)
|
||||
return false
|
||||
}
|
||||
msg := fmt.Sprintf("💰 Pago confirmado\nRef: %s\nMonto: %.2f %s\nPasarela: %s",
|
||||
s.ReferenciaExterna, s.Monto, s.Moneda, s.Pasarela)
|
||||
svc := &TelegramService{BotToken: tgCfg.BotToken}
|
||||
if err := svc.SendMessage(chatID, msg); err != nil {
|
||||
log.Printf("[PAGO_EXTERNO] error enviando Telegram para %s: %v", s.ReferenciaInterna, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
<!-- Vista: Pagos externos — API para que apps de terceros pidan cobros -->
|
||||
<div x-data="pagosExternosApp()" x-init="init()" @keydown.escape.window="closeModal()" class="bg-white rounded-lg shadow">
|
||||
|
||||
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
|
||||
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto p-6 w-full">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Pagos externos</h1>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Tokens de API para que aplicaciones de terceros pidan cobros a través de Bold, dLocal o PayPal.</p>
|
||||
</div>
|
||||
<button x-show="tab === 'servicios'" @click="openAdd()"
|
||||
class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg"
|
||||
style="background-color:#8eb02f"
|
||||
onmouseover="this.style.backgroundColor='#6d8c24'"
|
||||
onmouseout="this.style.backgroundColor='#8eb02f'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Nuevo servicio
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 border-b border-gray-200 mb-5">
|
||||
<button @click="tab = 'servicios'; loadServicios()"
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition"
|
||||
:class="tab === 'servicios' ? 'border-[#8eb02f] text-[#5a7a1e]' : 'border-transparent text-gray-500 hover:text-gray-700'">
|
||||
Servicios
|
||||
</button>
|
||||
<button @click="tab = 'historial'; loadSolicitudes()"
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition"
|
||||
:class="tab === 'historial' ? 'border-[#8eb02f] text-[#5a7a1e]' : 'border-transparent text-gray-500 hover:text-gray-700'">
|
||||
Historial de cobros
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Alerta -->
|
||||
<div x-show="errorMsg" x-cloak class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="errorMsg"></div>
|
||||
<div x-show="successMsg" x-cloak class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700" x-text="successMsg"></div>
|
||||
|
||||
<!-- ─── Tab: Servicios ─────────────────────────────────────────────── -->
|
||||
<div x-show="tab === 'servicios'">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto w-full text-sm">
|
||||
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
|
||||
<tr>
|
||||
<th class="py-2 px-3">Nombre</th>
|
||||
<th class="py-2 px-3">Token</th>
|
||||
<th class="py-2 px-3">Pasarelas</th>
|
||||
<th class="py-2 px-3">Notificación</th>
|
||||
<th class="py-2 px-3">Estado</th>
|
||||
<th class="py-2 px-3 text-right">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-if="servicios.length === 0">
|
||||
<tr><td colspan="6" class="py-8 text-center text-gray-400">Sin servicios registrados</td></tr>
|
||||
</template>
|
||||
<template x-for="item in servicios" :key="item.ID">
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="py-2 px-3 font-medium" x-text="item.nombre"></td>
|
||||
<td class="py-2 px-3 font-mono text-xs text-gray-400" x-text="item.token_preview"></td>
|
||||
<td class="py-2 px-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<template x-for="p in (item.pasarelas_habilitadas || '').split(',').filter(x => x.trim())" :key="p">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-[#e9f0cf] text-[#5a7a1e]" x-text="p.trim()"></span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-xs text-gray-500">
|
||||
<span x-show="item.notificar_webhook">🔗 Webhook</span>
|
||||
<span x-show="item.notificar_webhook && item.notificar_telegram"> + </span>
|
||||
<span x-show="item.notificar_telegram">✈️ Telegram</span>
|
||||
<span x-show="!item.notificar_webhook && !item.notificar_telegram" class="text-gray-300">Ninguna</span>
|
||||
</td>
|
||||
<td class="py-2 px-3">
|
||||
<span :class="item.activo ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||||
class="px-2 py-0.5 rounded-full text-xs font-semibold"
|
||||
x-text="item.activo ? 'Activo' : 'Inactivo'"></span>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="openEdit(item)"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 font-medium transition">Editar</button>
|
||||
<button @click="confirmRegenerar(item.ID)"
|
||||
class="text-xs text-amber-600 hover:text-amber-800 font-medium transition">Regenerar token</button>
|
||||
<button @click="confirmDelete(item.ID)"
|
||||
class="text-xs text-red-500 hover:text-red-700 font-medium transition">Eliminar</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Tab: Historial ─────────────────────────────────────────────── -->
|
||||
<div x-show="tab === 'historial'">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto w-full text-sm">
|
||||
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
|
||||
<tr>
|
||||
<th class="py-2 px-3">Referencia externa</th>
|
||||
<th class="py-2 px-3">Servicio</th>
|
||||
<th class="py-2 px-3">Pasarela</th>
|
||||
<th class="py-2 px-3">Monto</th>
|
||||
<th class="py-2 px-3">Estado</th>
|
||||
<th class="py-2 px-3">Notificado</th>
|
||||
<th class="py-2 px-3">Fecha</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-if="solicitudes.length === 0">
|
||||
<tr><td colspan="7" class="py-8 text-center text-gray-400">Sin cobros registrados todavía</td></tr>
|
||||
</template>
|
||||
<template x-for="s in solicitudes" :key="s.ID">
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="py-2 px-3 font-mono text-xs" x-text="s.referencia_externa"></td>
|
||||
<td class="py-2 px-3 text-xs text-gray-500" x-text="s.servicio ? s.servicio.nombre : '—'"></td>
|
||||
<td class="py-2 px-3 text-xs" x-text="s.pasarela"></td>
|
||||
<td class="py-2 px-3 text-xs" x-text="s.monto + ' ' + s.moneda"></td>
|
||||
<td class="py-2 px-3">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-semibold"
|
||||
:class="{
|
||||
'bg-green-100 text-green-700': s.estado === 'pagado',
|
||||
'bg-yellow-100 text-yellow-700': s.estado === 'pendiente',
|
||||
'bg-red-100 text-red-700': s.estado === 'fallido' || s.estado === 'expirado'
|
||||
}" x-text="s.estado"></span>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-xs text-gray-500">
|
||||
<span x-show="s.callback_entregado">🔗</span>
|
||||
<span x-show="s.telegram_entregado">✈️</span>
|
||||
<span x-show="!s.callback_entregado && !s.telegram_entregado">—</span>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-xs text-gray-400" x-text="new Date(s.CreatedAt).toLocaleString()"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal crear / editar servicio -->
|
||||
<div x-show="showModal" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
|
||||
<div @click.outside="closeModal()" class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-lg font-bold mb-4" x-text="editItem ? 'Editar servicio' : 'Nuevo servicio de pago externo'"></h2>
|
||||
<form @submit.prevent="save()">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Nombre *</label>
|
||||
<input x-model="form.nombre" type="text" required placeholder="Ej: App de reservas"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-2">Pasarelas habilitadas *</label>
|
||||
<div class="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50">
|
||||
<template x-for="p in ['bold', 'dlocal', 'paypal']" :key="p">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input type="checkbox" :checked="form.pasarelas_habilitadas.includes(p)"
|
||||
@change="togglePasarela(p)" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
|
||||
<span class="text-sm text-gray-700 capitalize" x-text="p"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-400 mt-1">Si el que llama no indica cuál usar, se toma la única habilitada (o falla si hay varias).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">IPs permitidas</label>
|
||||
<textarea x-model="ipsText" rows="2" placeholder="Una por línea o separadas por coma. IP exacta o CIDR (ej: 190.10.20.30 o 190.10.0.0/16). Vacío = sin restricción."
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="border border-gray-200 rounded-lg p-3 space-y-3 bg-gray-50">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input x-model="form.notificar_webhook" type="checkbox" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
|
||||
<span class="text-sm text-gray-700">Notificar por webhook HTTP</span>
|
||||
</label>
|
||||
<div x-show="form.notificar_webhook" x-cloak>
|
||||
<input x-model="form.callback_url" type="url" placeholder="https://tu-app.com/webhooks/pagos"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||||
<p class="text-[10px] text-gray-400 mt-1">Se envía un POST firmado (header <code>X-Signature</code>, HMAC-SHA256 con el callback_secret) al confirmarse el pago.</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input x-model="form.notificar_telegram" type="checkbox" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
|
||||
<span class="text-sm text-gray-700">Notificar por Telegram</span>
|
||||
</label>
|
||||
<div x-show="form.notificar_telegram" x-cloak>
|
||||
<input x-model="form.telegram_chat_id" type="text" placeholder="Chat ID de Telegram"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input x-model="form.activo" type="checkbox" id="pe_activo" class="rounded" />
|
||||
<label for="pe_activo" class="text-sm text-gray-700">Activo</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="formError" class="mt-3 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-600" x-text="formError"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-5">
|
||||
<button type="button" @click="closeModal()"
|
||||
class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancelar</button>
|
||||
<button type="submit" :disabled="saving"
|
||||
class="px-4 py-2 text-sm text-white rounded-lg transition disabled:opacity-50"
|
||||
style="background-color:#8eb02f"
|
||||
onmouseover="this.style.backgroundColor='#6d8c24'"
|
||||
onmouseout="this.style.backgroundColor='#8eb02f'">
|
||||
<span x-text="saving ? 'Guardando...' : (editItem ? 'Actualizar' : 'Crear')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal token generado (se muestra una sola vez) -->
|
||||
<div x-show="tokenResult" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||
<h2 class="text-lg font-bold mb-1">⚠️ Guarda estas credenciales ahora</h2>
|
||||
<p class="text-xs text-gray-500 mb-4">No se volverán a mostrar completas. Si las pierdes, tendrás que regenerarlas.</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Token (Authorization: Bearer …)</label>
|
||||
<div class="flex gap-2">
|
||||
<input readonly :value="tokenResult && tokenResult.token" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-xs font-mono bg-gray-50">
|
||||
<button @click="copiar(tokenResult.token)" class="px-3 py-2 text-xs border border-gray-300 rounded-lg hover:bg-gray-50">Copiar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="tokenResult && tokenResult.callback_secret">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Callback secret (para verificar la firma del webhook saliente)</label>
|
||||
<div class="flex gap-2">
|
||||
<input readonly :value="tokenResult && tokenResult.callback_secret" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-xs font-mono bg-gray-50">
|
||||
<button @click="copiar(tokenResult.callback_secret)" class="px-3 py-2 text-xs border border-gray-300 rounded-lg hover:bg-gray-50">Copiar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-5">
|
||||
<button @click="tokenResult = null; load()" class="px-4 py-2 text-sm text-white rounded-lg" style="background-color:#8eb02f">Listo, ya lo guardé</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal confirmar regenerar token -->
|
||||
<div x-show="regenerarId" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm p-6 text-center">
|
||||
<p class="text-gray-700 font-semibold mb-1">¿Regenerar token?</p>
|
||||
<p class="text-xs text-gray-500 mb-5">El token actual dejará de funcionar de inmediato. Cualquier app que lo use quedará desconectada hasta que actualices el nuevo.</p>
|
||||
<div class="flex justify-center gap-3">
|
||||
<button @click="regenerarId = null" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">Cancelar</button>
|
||||
<button @click="doRegenerar()" :disabled="saving"
|
||||
class="px-4 py-2 text-sm bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition disabled:opacity-50">
|
||||
<span x-text="saving ? 'Regenerando...' : 'Regenerar'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal confirmar eliminación -->
|
||||
<div x-show="deleteId" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm p-6 text-center">
|
||||
<p class="text-gray-700 font-semibold mb-1">¿Eliminar servicio?</p>
|
||||
<p class="text-xs text-gray-500 mb-5">El token dejará de funcionar de inmediato. Esta acción no se puede deshacer.</p>
|
||||
<div class="flex justify-center gap-3">
|
||||
<button @click="deleteId = null" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">Cancelar</button>
|
||||
<button @click="doDelete()" :disabled="saving"
|
||||
class="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition disabled:opacity-50">
|
||||
<span x-text="saving ? 'Eliminando...' : 'Eliminar'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function pagosExternosApp() {
|
||||
return {
|
||||
loading: false, saving: false,
|
||||
tab: 'servicios',
|
||||
servicios: [], solicitudes: [],
|
||||
showModal: false, editItem: null, deleteId: null, regenerarId: null, tokenResult: null,
|
||||
errorMsg: '', successMsg: '', formError: '',
|
||||
ipsText: '',
|
||||
form: { nombre: '', pasarelas_habilitadas: [], notificar_webhook: false, callback_url: '', notificar_telegram: false, telegram_chat_id: '', activo: true },
|
||||
|
||||
async init() { await this.loadServicios() },
|
||||
|
||||
async load() { await this.loadServicios() },
|
||||
|
||||
async loadServicios() {
|
||||
this.loading = true; this.errorMsg = ''
|
||||
const res = await fetch('/app/pagos-externos/servicios')
|
||||
const data = await res.json()
|
||||
this.loading = false
|
||||
if (!res.ok) { this.errorMsg = data.error || 'Error cargando datos'; return }
|
||||
this.servicios = data.items || []
|
||||
},
|
||||
|
||||
async loadSolicitudes() {
|
||||
this.loading = true; this.errorMsg = ''
|
||||
const res = await fetch('/app/pagos-externos/solicitudes')
|
||||
const data = await res.json()
|
||||
this.loading = false
|
||||
if (!res.ok) { this.errorMsg = data.error || 'Error cargando datos'; return }
|
||||
this.solicitudes = data.items || []
|
||||
},
|
||||
|
||||
togglePasarela(p) {
|
||||
const idx = this.form.pasarelas_habilitadas.indexOf(p)
|
||||
if (idx >= 0) { this.form.pasarelas_habilitadas.splice(idx, 1) } else { this.form.pasarelas_habilitadas.push(p) }
|
||||
},
|
||||
|
||||
openAdd() {
|
||||
this.editItem = null
|
||||
this.form = { nombre: '', pasarelas_habilitadas: [], notificar_webhook: false, callback_url: '', notificar_telegram: false, telegram_chat_id: '', activo: true }
|
||||
this.ipsText = ''
|
||||
this.formError = ''
|
||||
this.showModal = true
|
||||
},
|
||||
|
||||
openEdit(item) {
|
||||
this.editItem = item
|
||||
this.form = {
|
||||
nombre: item.nombre,
|
||||
pasarelas_habilitadas: (item.pasarelas_habilitadas || '').split(',').map(s => s.trim()).filter(s => s),
|
||||
notificar_webhook: item.notificar_webhook,
|
||||
callback_url: item.callback_url || '',
|
||||
notificar_telegram: item.notificar_telegram,
|
||||
telegram_chat_id: item.telegram_chat_id || '',
|
||||
activo: item.activo,
|
||||
}
|
||||
this.ipsText = (item.ips_permitidas || '').split(',').map(s => s.trim()).filter(s => s).join('\n')
|
||||
this.formError = ''
|
||||
this.showModal = true
|
||||
},
|
||||
|
||||
closeModal() { this.showModal = false; this.editItem = null; this.formError = '' },
|
||||
|
||||
async save() {
|
||||
this.saving = true; this.formError = ''
|
||||
const ips = this.ipsText.split(/[\n,]/).map(s => s.trim()).filter(s => s)
|
||||
const payload = { ...this.form, ips_permitidas: ips }
|
||||
const url = this.editItem ? `/app/pagos-externos/servicios/${this.editItem.ID}` : '/app/pagos-externos/servicios'
|
||||
const method = this.editItem ? 'PUT' : 'POST'
|
||||
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
|
||||
const data = await res.json()
|
||||
this.saving = false
|
||||
if (!res.ok) { this.formError = data.error || 'Error guardando'; return }
|
||||
this.closeModal()
|
||||
if (data.token) {
|
||||
this.tokenResult = data
|
||||
} else {
|
||||
this.showSuccess('Servicio actualizado')
|
||||
await this.loadServicios()
|
||||
}
|
||||
},
|
||||
|
||||
confirmRegenerar(id) { this.regenerarId = id },
|
||||
|
||||
async doRegenerar() {
|
||||
this.saving = true
|
||||
const res = await fetch(`/app/pagos-externos/servicios/${this.regenerarId}/regenerar-token`, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
this.saving = false
|
||||
this.regenerarId = null
|
||||
if (!res.ok) { this.errorMsg = data.error || 'Error regenerando token'; return }
|
||||
this.tokenResult = data
|
||||
},
|
||||
|
||||
confirmDelete(id) { this.deleteId = id },
|
||||
|
||||
async doDelete() {
|
||||
this.saving = true
|
||||
const res = await fetch(`/app/pagos-externos/servicios/${this.deleteId}`, { method: 'DELETE' })
|
||||
this.saving = false
|
||||
this.deleteId = null
|
||||
if (!res.ok) { this.errorMsg = 'Error eliminando'; return }
|
||||
this.showSuccess('Servicio eliminado')
|
||||
await this.loadServicios()
|
||||
},
|
||||
|
||||
copiar(texto) {
|
||||
if (!texto) return
|
||||
navigator.clipboard.writeText(texto)
|
||||
this.showSuccess('Copiado al portapapeles')
|
||||
},
|
||||
|
||||
showSuccess(msg) {
|
||||
this.successMsg = msg
|
||||
setTimeout(() => { this.successMsg = '' }, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -263,7 +263,8 @@ func AuthAdmin(c *fiber.Ctx) error {
|
||||
func AuthApi() func(*fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Excluir rutas públicas y /api/v2 (tiene su propio middleware)
|
||||
if c.Path() == "/api/v1/oauth/token" || c.Path() == "/api/sms/send" || strings.HasPrefix(c.Path(), "/api/v2") {
|
||||
if c.Path() == "/api/v1/oauth/token" || c.Path() == "/api/sms/send" || strings.HasPrefix(c.Path(), "/api/v2") ||
|
||||
strings.HasPrefix(c.Path(), "/api/v1/pagos-externos") {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// AuthServicioPago protege los endpoints públicos de la API de pagos externos
|
||||
// (/api/v1/pagos-externos/*). Es independiente de la sesión/JWT interna: el
|
||||
// caller es una aplicación de un tercero que solo tiene un token largo
|
||||
// (Bearer) y opcionalmente está restringido a una o varias IPs/CIDRs. Si pasa,
|
||||
// deja el *models.ServicioPagoExterno resuelto en Locals("servicio_pago").
|
||||
func AuthServicioPago(c *fiber.Ctx) error {
|
||||
auth := c.Get("Authorization")
|
||||
token := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
|
||||
if token == "" || token == auth {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": "Falta el header Authorization: Bearer <token>"})
|
||||
}
|
||||
|
||||
servicio, err := models.FindServicioPagoExternoActivoByToken(token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": "Token inválido o servicio inactivo"})
|
||||
}
|
||||
|
||||
if !servicio.IPPermitida(c.IP()) {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": true, "message": "IP no autorizada para este servicio"})
|
||||
}
|
||||
|
||||
c.Locals("servicio_pago", servicio)
|
||||
return c.Next()
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package routes
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
apiControllers "github.com/sujit-baniya/fiber-boilerplate/rest/controllers/api"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
|
||||
)
|
||||
|
||||
func ApiRoutes(api fiber.Router) {
|
||||
@@ -24,6 +25,14 @@ func v1AuthRoutes(api fiber.Router) {
|
||||
api.Post("/rapyd/wallet/create", apiControllers.MakeWallet)
|
||||
api.Post("/generate-url-qr", apiControllers.CreateUrlQr)
|
||||
api.Post("/ia/ollama-generate", apiControllers.GeneraTextoStream)
|
||||
|
||||
// ─── Pagos externos: API pública para apps de terceros ────────────────────
|
||||
// Autenticación propia (token Bearer + IP), independiente de la sesión/JWT
|
||||
// interna — por eso está excluida en AuthApi() (rest/middlewares/auth.go).
|
||||
pagosExt := api.Group("/pagos-externos", middlewares.AuthServicioPago)
|
||||
pagosExt.Get("/pasarelas", apiControllers.PasarelasDisponiblesHandler)
|
||||
pagosExt.Post("/solicitar", apiControllers.SolicitarPagoExternoHandler)
|
||||
pagosExt.Get("/:referencia/estado", apiControllers.EstadoPagoExternoHandler)
|
||||
}
|
||||
|
||||
func v1Routes(api fiber.Router) {
|
||||
|
||||
@@ -262,6 +262,14 @@ func AdminApiRoutes(api fiber.Router) {
|
||||
h.Delete("/ai-config/:id", controllers.DeleteAiConfigHandler)
|
||||
h.Get("/ai-config/:id/test", controllers.TestAiConfigHandler)
|
||||
|
||||
// ─── Pagos externos (API para que apps de terceros pidan cobros) ───────────
|
||||
h.Get("/pagos-externos/servicios", controllers.GetServiciosPagoExterno)
|
||||
h.Post("/pagos-externos/servicios", controllers.CreateServicioPagoExternoHandler)
|
||||
h.Put("/pagos-externos/servicios/:id", controllers.UpdateServicioPagoExternoHandler)
|
||||
h.Delete("/pagos-externos/servicios/:id", controllers.DeleteServicioPagoExternoHandler)
|
||||
h.Post("/pagos-externos/servicios/:id/regenerar-token", controllers.RegenerarTokenServicioPagoHandler)
|
||||
h.Get("/pagos-externos/solicitudes", controllers.GetSolicitudesPagoExternoHandler)
|
||||
|
||||
// ─── OSS API (almacenamiento) ────────────────────────────────────────────
|
||||
h.Get("/oss-api", controllers.GetOssApiConfigs)
|
||||
h.Get("/oss-api/active", controllers.GetActiveOssApiList)
|
||||
|
||||
@@ -329,6 +329,17 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Delete("/ai-config/:id", controllers.DeleteAiConfigHandler)
|
||||
protected.Get("/ai-config/:id/test", controllers.TestAiConfigHandler)
|
||||
|
||||
// ─── Pagos externos (API para que apps de terceros pidan cobros) ───────────
|
||||
// Sensible: emitir/regenerar un token da acceso a crear cobros reales por
|
||||
// Bold/dLocal/PayPal, así que solo un administrador puede administrarlo.
|
||||
protected.Get("/pagos-externos", middlewares.MenuMiddleware, controllers.PagosExternosIndex)
|
||||
protected.Get("/pagos-externos/servicios", controllers.GetServiciosPagoExterno)
|
||||
protected.Post("/pagos-externos/servicios", middlewares.SoloAdmin, controllers.CreateServicioPagoExternoHandler)
|
||||
protected.Put("/pagos-externos/servicios/:id", middlewares.SoloAdmin, controllers.UpdateServicioPagoExternoHandler)
|
||||
protected.Delete("/pagos-externos/servicios/:id", middlewares.SoloAdmin, controllers.DeleteServicioPagoExternoHandler)
|
||||
protected.Post("/pagos-externos/servicios/:id/regenerar-token", middlewares.SoloAdmin, controllers.RegenerarTokenServicioPagoHandler)
|
||||
protected.Get("/pagos-externos/solicitudes", controllers.GetSolicitudesPagoExternoHandler)
|
||||
|
||||
// ─── OSS API (Alibaba Cloud + S3/MinIO) ────────────────────────────────────
|
||||
protected.Get("/oss-api", middlewares.MenuMiddleware, controllers.OssApiIndex)
|
||||
protected.Get("/loadossapi", controllers.GetOssApiConfigs)
|
||||
|
||||
Reference in New Issue
Block a user