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,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
|
||||
}
|
||||
Reference in New Issue
Block a user