Files
soft_usite/pkg/models/pago_externo.go
T
Lizandro GD e5849549a0 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.
2026-08-04 14:16:13 +00:00

299 lines
12 KiB
Go

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
}