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 }