543 lines
18 KiB
Go
543 lines
18 KiB
Go
package virtualgameservice
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"math/rand/v2"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
|
|
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
|
|
"github.com/SamuelTariku/FortuneBet-Backend/internal/repository"
|
|
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
|
|
jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt"
|
|
)
|
|
|
|
type service struct {
|
|
repo repository.VirtualGameRepository
|
|
walletSvc wallet.Service
|
|
store *repository.Store
|
|
// virtualGameStore repository.VirtualGameRepository
|
|
config *config.Config
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func New(repo repository.VirtualGameRepository, walletSvc wallet.Service, store *repository.Store, cfg *config.Config, logger *slog.Logger) VirtualGameService {
|
|
return &service{
|
|
repo: repo,
|
|
walletSvc: walletSvc,
|
|
store: store,
|
|
config: cfg,
|
|
logger: logger}
|
|
}
|
|
|
|
func (s *service) GenerateGameLaunchURL(ctx context.Context, userID int64, gameID, currency, mode string) (string, error) {
|
|
user, err := s.store.GetUserByID(ctx, userID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get user", "userID", userID, "error", err)
|
|
return "", err
|
|
}
|
|
|
|
sessionId := fmt.Sprintf("%d-%s-%d", userID, gameID, time.Now().UnixNano())
|
|
token, err := jwtutil.CreatePopOKJwt(
|
|
userID,
|
|
user.PhoneNumber,
|
|
currency,
|
|
"en",
|
|
mode,
|
|
sessionId,
|
|
s.config.PopOK.SecretKey,
|
|
24*time.Hour,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error("Failed to create PopOK JWT", "userID", userID, "error", err)
|
|
return "", err
|
|
}
|
|
|
|
// Record game launch as a transaction (for history and recommendation purposes)
|
|
tx := &domain.VirtualGameHistory{
|
|
SessionID: sessionId, // Optional: populate if session tracking is implemented
|
|
UserID: userID,
|
|
GameID: toInt64Ptr(gameID),
|
|
TransactionType: "LAUNCH",
|
|
Amount: 0,
|
|
Currency: currency,
|
|
ExternalTransactionID: sessionId,
|
|
Status: "COMPLETED",
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if err := s.repo.CreateVirtualGameHistory(ctx, tx); err != nil {
|
|
s.logger.Error("Failed to record game launch transaction", "error", err)
|
|
// Do not fail game launch on logging error — just log and continue
|
|
}
|
|
|
|
params := fmt.Sprintf(
|
|
"partnerId=%s&gameId=%s&gameMode=%s&lang=en&platform=%s&externalToken=%s",
|
|
s.config.PopOK.ClientID, gameID, mode, s.config.PopOK.Platform, token,
|
|
)
|
|
|
|
return fmt.Sprintf("%s?%s", s.config.PopOK.BaseURL, params), nil
|
|
}
|
|
|
|
func (s *service) HandleCallback(ctx context.Context, callback *domain.PopOKCallback) error {
|
|
s.logger.Info("Handling PopOK callback", "transactionID", callback.TransactionID, "type", callback.Type)
|
|
|
|
if !s.verifySignature(callback) {
|
|
s.logger.Error("Invalid callback signature", "transactionID", callback.TransactionID)
|
|
return errors.New("invalid signature")
|
|
}
|
|
|
|
existingTx, err := s.repo.GetVirtualGameTransactionByExternalID(ctx, callback.TransactionID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to check existing transaction", "transactionID", callback.TransactionID, "error", err)
|
|
return err
|
|
}
|
|
if existingTx != nil {
|
|
s.logger.Warn("Transaction already processed", "transactionID", callback.TransactionID)
|
|
return nil // Idempotency
|
|
}
|
|
|
|
session, err := s.repo.GetVirtualGameSessionByToken(ctx, callback.SessionID)
|
|
if err != nil || session == nil {
|
|
s.logger.Error("Invalid or missing session", "sessionID", callback.SessionID, "error", err)
|
|
return errors.New("invalid session")
|
|
}
|
|
|
|
wallets, err := s.walletSvc.GetWalletsByUser(ctx, session.UserID)
|
|
if err != nil || len(wallets) == 0 {
|
|
s.logger.Error("Failed to get wallets or no wallet found", "userID", session.UserID, "error", err)
|
|
return errors.New("user has no wallet")
|
|
}
|
|
|
|
walletID := wallets[0].ID
|
|
amount := int64(callback.Amount * 100) // Convert to cents
|
|
transactionType := callback.Type
|
|
|
|
switch transactionType {
|
|
case "BET":
|
|
amount = -amount // Debit for bets
|
|
case "WIN", "JACKPOT_WIN", "REFUND":
|
|
default:
|
|
s.logger.Error("Unknown transaction type", "transactionID", callback.TransactionID, "type", transactionType)
|
|
return errors.New("unknown transaction type")
|
|
}
|
|
|
|
err = s.walletSvc.AddToWallet(ctx, walletID, domain.Currency(amount))
|
|
if err != nil {
|
|
s.logger.Error("Failed to update wallet", "walletID", walletID, "userID", session.UserID, "amount", amount, "error", err)
|
|
return err
|
|
}
|
|
|
|
// Record transaction
|
|
tx := &domain.VirtualGameTransaction{
|
|
SessionID: session.ID,
|
|
UserID: session.UserID,
|
|
WalletID: walletID,
|
|
TransactionType: transactionType,
|
|
Amount: amount,
|
|
Currency: callback.Currency,
|
|
ExternalTransactionID: callback.TransactionID,
|
|
Status: "COMPLETED",
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if err := s.repo.CreateVirtualGameTransaction(ctx, tx); err != nil {
|
|
s.logger.Error("Failed to create transaction", "transactionID", callback.TransactionID, "error", err)
|
|
return err
|
|
}
|
|
|
|
s.logger.Info("Callback processed successfully", "transactionID", callback.TransactionID, "type", transactionType, "amount", callback.Amount)
|
|
return nil
|
|
}
|
|
|
|
func (s *service) GetPlayerInfo(ctx context.Context, req *domain.PopOKPlayerInfoRequest) (*domain.PopOKPlayerInfoResponse, error) {
|
|
claims, err := jwtutil.ParsePopOKJwt(req.ExternalToken, s.config.PopOK.SecretKey)
|
|
if err != nil {
|
|
s.logger.Error("Failed to parse JWT", "error", err)
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
|
|
wallets, err := s.walletSvc.GetWalletsByUser(ctx, claims.UserID)
|
|
if err != nil || len(wallets) == 0 {
|
|
s.logger.Error("No wallets found for user", "userID", claims.UserID)
|
|
return nil, fmt.Errorf("no wallet found")
|
|
}
|
|
|
|
return &domain.PopOKPlayerInfoResponse{
|
|
Country: "ET",
|
|
Currency: claims.Currency,
|
|
Balance: float64(wallets[0].Balance) / 100, // Convert cents to currency
|
|
PlayerID: fmt.Sprintf("%d", claims.UserID),
|
|
}, nil
|
|
}
|
|
|
|
func (s *service) ProcessBet(ctx context.Context, req *domain.PopOKBetRequest) (*domain.PopOKBetResponse, error) {
|
|
// Validate token and get user ID
|
|
claims, err := jwtutil.ParsePopOKJwt(req.ExternalToken, s.config.PopOK.SecretKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
|
|
// Convert amount to cents (assuming wallet uses cents)
|
|
amountCents := int64(req.Amount * 100)
|
|
|
|
// Deduct from wallet
|
|
|
|
userWallets, err := s.walletSvc.GetWalletsByUser(ctx, claims.UserID)
|
|
if err != nil {
|
|
return &domain.PopOKBetResponse{}, fmt.Errorf("Failed to read user wallets")
|
|
}
|
|
|
|
if err := s.walletSvc.DeductFromWallet(ctx, claims.UserID, domain.Currency(amountCents)); err != nil {
|
|
return nil, fmt.Errorf("insufficient balance")
|
|
}
|
|
|
|
// Create transaction record
|
|
tx := &domain.VirtualGameTransaction{
|
|
UserID: claims.UserID,
|
|
TransactionType: "BET",
|
|
Amount: -amountCents, // Negative for bets
|
|
Currency: req.Currency,
|
|
ExternalTransactionID: req.TransactionID,
|
|
Status: "COMPLETED",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if err := s.repo.CreateVirtualGameTransaction(ctx, tx); err != nil {
|
|
s.logger.Error("Failed to create bet transaction", "error", err)
|
|
return nil, fmt.Errorf("transaction failed")
|
|
}
|
|
|
|
return &domain.PopOKBetResponse{
|
|
TransactionID: req.TransactionID,
|
|
ExternalTrxID: fmt.Sprintf("%v", tx.ID), // Your internal transaction ID
|
|
Balance: float64(userWallets[0].Balance) / 100,
|
|
}, nil
|
|
}
|
|
|
|
func (s *service) ProcessWin(ctx context.Context, req *domain.PopOKWinRequest) (*domain.PopOKWinResponse, error) {
|
|
// 1. Validate token and get user ID
|
|
claims, err := jwtutil.ParsePopOKJwt(req.ExternalToken, s.config.PopOK.SecretKey)
|
|
// if err != nil {
|
|
// s.logger.Error("Invalid token in win request", "error", err)
|
|
// return nil, fmt.Errorf("invalid token")
|
|
// }
|
|
|
|
// 2. Check for duplicate transaction (idempotency)
|
|
existingTx, err := s.repo.GetVirtualGameTransactionByExternalID(ctx, req.TransactionID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to check existing transaction", "error", err)
|
|
return nil, fmt.Errorf("transaction check failed")
|
|
}
|
|
|
|
if existingTx != nil && existingTx.TransactionType == "WIN" {
|
|
s.logger.Warn("Duplicate win transaction", "transactionID", req.TransactionID)
|
|
wallets, _ := s.walletSvc.GetWalletsByUser(ctx, claims.UserID)
|
|
balance := 0.0
|
|
if len(wallets) > 0 {
|
|
balance = float64(wallets[0].Balance) / 100
|
|
}
|
|
return &domain.PopOKWinResponse{
|
|
TransactionID: req.TransactionID,
|
|
ExternalTrxID: fmt.Sprintf("%d", existingTx.ID),
|
|
Balance: balance,
|
|
}, nil
|
|
}
|
|
|
|
// 3. Convert amount to cents
|
|
amountCents := int64(req.Amount * 100)
|
|
|
|
// 4. Credit to wallet
|
|
|
|
if err := s.walletSvc.AddToWallet(ctx, claims.UserID, domain.Currency(amountCents)); err != nil {
|
|
s.logger.Error("Failed to credit wallet", "userID", claims.UserID, "error", err)
|
|
return nil, fmt.Errorf("wallet credit failed")
|
|
}
|
|
|
|
userWallets, err := s.walletSvc.GetWalletsByUser(ctx, claims.UserID)
|
|
if err != nil {
|
|
return &domain.PopOKWinResponse{}, fmt.Errorf("Failed to read user wallets")
|
|
}
|
|
// 5. Create transaction record
|
|
tx := &domain.VirtualGameTransaction{
|
|
UserID: claims.UserID,
|
|
TransactionType: "WIN",
|
|
Amount: amountCents,
|
|
Currency: req.Currency,
|
|
ExternalTransactionID: req.TransactionID,
|
|
Status: "COMPLETED",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if err := s.repo.CreateVirtualGameTransaction(ctx, tx); err != nil {
|
|
s.logger.Error("Failed to create win transaction", "error", err)
|
|
return nil, fmt.Errorf("transaction recording failed")
|
|
}
|
|
|
|
return &domain.PopOKWinResponse{
|
|
TransactionID: req.TransactionID,
|
|
ExternalTrxID: fmt.Sprintf("%v", tx.ID),
|
|
Balance: float64(userWallets[0].Balance) / 100,
|
|
}, nil
|
|
}
|
|
|
|
func (s *service) ProcessCancel(ctx context.Context, req *domain.PopOKCancelRequest) (*domain.PopOKCancelResponse, error) {
|
|
// 1. Validate token and get user ID
|
|
claims, err := jwtutil.ParsePopOKJwt(req.ExternalToken, s.config.PopOK.SecretKey)
|
|
// if err != nil {
|
|
// s.logger.Error("Invalid token in cancel request", "error", err)
|
|
// return nil, fmt.Errorf("invalid token")
|
|
// }
|
|
|
|
// 2. Find the original bet transaction
|
|
originalBet, err := s.repo.GetVirtualGameTransactionByExternalID(ctx, req.TransactionID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to find original bet", "transactionID", req.TransactionID, "error", err)
|
|
return nil, fmt.Errorf("original bet not found")
|
|
}
|
|
|
|
// 3. Validate the original transaction
|
|
if originalBet == nil || originalBet.TransactionType != "BET" {
|
|
s.logger.Error("Invalid original transaction for cancel", "transactionID", req.TransactionID)
|
|
return nil, fmt.Errorf("invalid original transaction")
|
|
}
|
|
|
|
// 4. Check if already cancelled
|
|
if originalBet.Status == "CANCELLED" {
|
|
s.logger.Warn("Transaction already cancelled", "transactionID", req.TransactionID)
|
|
wallets, _ := s.walletSvc.GetWalletsByUser(ctx, claims.UserID)
|
|
balance := 0.0
|
|
if len(wallets) > 0 {
|
|
balance = float64(wallets[0].Balance) / 100
|
|
}
|
|
return &domain.PopOKCancelResponse{
|
|
TransactionID: req.TransactionID,
|
|
ExternalTrxID: fmt.Sprintf("%v", originalBet.ID),
|
|
Balance: balance,
|
|
}, nil
|
|
}
|
|
|
|
// 5. Refund the bet amount (absolute value since bet amount is negative)
|
|
refundAmount := -originalBet.Amount
|
|
|
|
if err := s.walletSvc.AddToWallet(ctx, claims.UserID, domain.Currency(refundAmount)); err != nil {
|
|
s.logger.Error("Failed to refund bet", "userID", claims.UserID, "error", err)
|
|
return nil, fmt.Errorf("refund failed")
|
|
}
|
|
|
|
userWallets, err := s.walletSvc.GetWalletsByUser(ctx, claims.UserID)
|
|
if err != nil {
|
|
return &domain.PopOKCancelResponse{}, fmt.Errorf("Failed to read user wallets")
|
|
}
|
|
|
|
// 6. Mark original bet as cancelled and create cancel record
|
|
cancelTx := &domain.VirtualGameTransaction{
|
|
UserID: claims.UserID,
|
|
TransactionType: "CANCEL",
|
|
Amount: refundAmount,
|
|
Currency: originalBet.Currency,
|
|
ExternalTransactionID: req.TransactionID,
|
|
ReferenceTransactionID: fmt.Sprintf("%v", originalBet.ID),
|
|
Status: "COMPLETED",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if err := s.repo.UpdateVirtualGameTransactionStatus(ctx, originalBet.ID, "CANCELLED"); err != nil {
|
|
s.logger.Error("Failed to update transaction status", "error", err)
|
|
return nil, fmt.Errorf("update failed")
|
|
}
|
|
|
|
// Create cancel transaction
|
|
if err := s.repo.CreateVirtualGameTransaction(ctx, cancelTx); err != nil {
|
|
s.logger.Error("Failed to create cancel transaction", "error", err)
|
|
|
|
// Attempt to revert the status update
|
|
if revertErr := s.repo.UpdateVirtualGameTransactionStatus(ctx, originalBet.ID, originalBet.Status); revertErr != nil {
|
|
s.logger.Error("Failed to revert transaction status", "error", revertErr)
|
|
}
|
|
|
|
return nil, fmt.Errorf("create failed")
|
|
}
|
|
|
|
// if err != nil {
|
|
// s.logger.Error("Failed to process cancel transaction", "error", err)
|
|
// return nil, fmt.Errorf("transaction processing failed")
|
|
// }
|
|
|
|
return &domain.PopOKCancelResponse{
|
|
TransactionID: req.TransactionID,
|
|
ExternalTrxID: fmt.Sprintf("%v", cancelTx.ID),
|
|
Balance: float64(userWallets[0].Balance) / 100,
|
|
}, nil
|
|
}
|
|
|
|
func (s *service) GenerateSignature(params string) string {
|
|
h := hmac.New(sha256.New, []byte(s.config.PopOK.SecretKey))
|
|
h.Write([]byte(params))
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
func (s *service) verifySignature(callback *domain.PopOKCallback) bool {
|
|
data, _ := json.Marshal(struct {
|
|
TransactionID string `json:"transaction_id"`
|
|
SessionID string `json:"session_id"`
|
|
Type string `json:"type"`
|
|
Amount float64 `json:"amount"`
|
|
Currency string `json:"currency"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
}{
|
|
TransactionID: callback.TransactionID,
|
|
SessionID: callback.SessionID,
|
|
Type: callback.Type,
|
|
Amount: callback.Amount,
|
|
Currency: callback.Currency,
|
|
Timestamp: callback.Timestamp,
|
|
})
|
|
|
|
h := hmac.New(sha256.New, []byte(s.config.PopOK.SecretKey))
|
|
h.Write(data)
|
|
expected := hex.EncodeToString(h.Sum(nil))
|
|
return expected == callback.Signature
|
|
}
|
|
|
|
func (s *service) GetGameCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error) {
|
|
return s.repo.GetGameCounts(ctx, filter)
|
|
}
|
|
|
|
func (s *service) ListGames(ctx context.Context, currency string) ([]domain.PopOKGame, error) {
|
|
now := time.Now().Format("02-01-2006 15:04:05") // dd-mm-yyyy hh:mm:ss
|
|
|
|
// Calculate hash: sha256(privateKey + time)
|
|
rawHash := s.config.PopOK.SecretKey + now
|
|
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(rawHash)))
|
|
|
|
// Construct request payload
|
|
payload := map[string]interface{}{
|
|
"action": "gameList",
|
|
"platform": s.config.PopOK.Platform,
|
|
"partnerId": s.config.PopOK.ClientID,
|
|
"currency": currency,
|
|
"time": now,
|
|
"hash": hash,
|
|
}
|
|
|
|
bodyBytes, err := json.Marshal(payload)
|
|
if err != nil {
|
|
s.logger.Error("Failed to marshal game list request", "error", err)
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", s.config.PopOK.BaseURL+"/serviceApi.php", bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
s.logger.Error("Failed to create game list request", "error", err)
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
s.logger.Error("Failed to send game list request", "error", err)
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("PopOK game list failed with status %d: %s", resp.StatusCode, string(b))
|
|
}
|
|
|
|
var gameList domain.PopOKGameListResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&gameList); err != nil {
|
|
s.logger.Error("Failed to decode game list response", "error", err)
|
|
return nil, err
|
|
}
|
|
|
|
if gameList.Code != 0 {
|
|
return nil, fmt.Errorf("PopOK error: %s", gameList.Message)
|
|
}
|
|
|
|
return gameList.Data.Slots, nil
|
|
}
|
|
|
|
func (s *service) RecommendGames(ctx context.Context, userID int64) ([]domain.GameRecommendation, error) {
|
|
// Fetch all available games
|
|
games, err := s.ListGames(ctx, "ETB") // currency can be dynamic
|
|
if err != nil || len(games) == 0 {
|
|
return nil, fmt.Errorf("could not fetch games")
|
|
}
|
|
|
|
// Check if user has existing interaction
|
|
history, err := s.repo.GetUserGameHistory(ctx, userID)
|
|
if err != nil {
|
|
s.logger.Warn("No previous game history", "userID", userID)
|
|
}
|
|
|
|
recommendations := []domain.GameRecommendation{}
|
|
|
|
if len(history) > 0 {
|
|
// Score games based on interaction frequency
|
|
gameScores := map[int64]int{}
|
|
for _, h := range history {
|
|
if h.GameID != nil {
|
|
gameScores[*h.GameID]++
|
|
}
|
|
}
|
|
|
|
// Sort by score descending
|
|
sort.SliceStable(games, func(i, j int) bool {
|
|
return gameScores[int64(games[i].ID)] > gameScores[int64(games[j].ID)]
|
|
})
|
|
|
|
// Pick top 3
|
|
for _, g := range games[:min(3, len(games))] {
|
|
recommendations = append(recommendations, domain.GameRecommendation{
|
|
GameID: g.ID,
|
|
GameName: g.GameName,
|
|
Thumbnail: g.Thumbnail,
|
|
Bets: g.Bets,
|
|
Reason: "Based on your activity",
|
|
})
|
|
}
|
|
} else {
|
|
// Pick 3 random games for new users
|
|
rand.Shuffle(len(games), func(i, j int) {
|
|
games[i], games[j] = games[j], games[i]
|
|
})
|
|
|
|
for _, g := range games[:min(3, len(games))] {
|
|
recommendations = append(recommendations, domain.GameRecommendation{
|
|
GameID: g.ID,
|
|
GameName: g.GameName,
|
|
Thumbnail: g.Thumbnail,
|
|
Bets: g.Bets,
|
|
Reason: "Random pick",
|
|
})
|
|
}
|
|
}
|
|
|
|
return recommendations, nil
|
|
}
|
|
|
|
func toInt64Ptr(s string) *int64 {
|
|
id, err := strconv.ParseInt(s, 10, 64)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &id
|
|
}
|