Yimaru-BackEnd/internal/services/virtualGame/service.go

870 lines
28 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) {
// 1. Fetch user
user, err := s.store.GetUserByID(ctx, userID)
if err != nil {
s.logger.Error("Failed to get user", "userID", userID, "error", err)
return "", err
}
// 2. Generate session and token
sessionID := fmt.Sprintf("%d-%s-%d", userID, gameID, time.Now().UnixNano())
token, err := jwtutil.CreatePopOKJwt(
userID,
user.CompanyID,
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
}
// 3. Record virtual game history (optional but recommended)
history := &domain.VirtualGameHistory{
// SessionID: sessionID,
UserID: userID,
CompanyID: user.CompanyID.Value,
Provider: string(domain.PROVIDER_POPOK),
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, history); err != nil {
s.logger.Error("Failed to record game launch transaction", "error", err)
// Non-fatal: log and continue
}
// 4. Prepare PopOK API request
timestamp := time.Now().Format("02-01-2006 15:04:05")
partnerID, err := strconv.Atoi(s.config.PopOK.ClientID)
if err != nil {
return "", fmt.Errorf("invalid PopOK ClientID: %v", err)
}
data := domain.PopokLaunchRequestData{
GameMode: mode,
GameID: gameID,
Lang: "en",
Token: token,
ExitURL: "",
}
hash, err := generatePopOKHash(s.config.PopOK.SecretKey, timestamp, data)
if err != nil {
return "", fmt.Errorf("failed to generate PopOK hash: %w", err)
}
platformInt, err := strconv.Atoi(s.config.PopOK.Platform)
if err != nil {
return "", fmt.Errorf("invalid PopOK Platform: %v", err)
}
reqBody := domain.PopokLaunchRequest{
Action: "getLauncherURL",
Platform: platformInt,
PartnerID: partnerID,
Time: timestamp,
Hash: hash,
Data: data,
}
// 5. Make API request
bodyBytes, _ := json.Marshal(reqBody)
resp, err := http.Post(s.config.PopOK.BaseURL+"/serviceApi.php", "application/json", bytes.NewReader(bodyBytes))
if err != nil {
return "", fmt.Errorf("PopOK POST failed: %w", err)
}
defer resp.Body.Close()
var parsedResp domain.PopokLaunchResponse
if err := json.NewDecoder(resp.Body).Decode(&parsedResp); err != nil {
return "", fmt.Errorf("failed to parse PopOK response: %w", err)
}
if parsedResp.Code != 0 {
return "", fmt.Errorf("PopOK error: %s", parsedResp.Message)
}
return parsedResp.Data.LauncherURL, 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) // 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), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{},
fmt.Sprintf("Added %v to wallet for winning PopOkBet", 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)
fmt.Printf("\n\nClaims: %+v\n\n", claims)
fmt.Printf("\n\nExternal token: %+v\n\n", req.ExternalToken)
if err != nil {
s.logger.Error("Failed to parse JWT", "error", err)
return nil, fmt.Errorf("invalid token")
}
wallet, err := s.walletSvc.GetCustomerWallet(ctx, claims.UserID)
if err != nil {
s.logger.Error("No wallets found for user", "userID", claims.UserID)
return nil, err
}
return &domain.PopOKPlayerInfoResponse{
Country: "ET",
Currency: claims.Currency,
Balance: float64(wallet.RegularBalance.Float32()), // 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 external token and extract user context
claims, err := jwtutil.ParsePopOKJwt(req.ExternalToken, s.config.PopOK.SecretKey)
if err != nil {
return nil, fmt.Errorf("invalid or expired token: %w", err)
}
// Validate required fields
if req.Amount <= 0 {
return nil, errors.New("invalid bet amount")
}
if req.TransactionID == "" {
return nil, errors.New("missing transaction_id")
}
// Retrieve user's wallet
wallet, err := s.walletSvc.GetCustomerWallet(ctx, claims.UserID)
if err != nil {
return nil, fmt.Errorf("failed to fetch wallet for user %d: %w", claims.UserID, err)
}
// Deduct amount from wallet
_, err = s.walletSvc.DeductFromWallet(
ctx,
wallet.RegularID,
domain.Currency(req.Amount),
domain.ValidInt64{}, // optional linked transfer ID
domain.TRANSFER_DIRECT,
fmt.Sprintf("Virtual game bet placed (PopOK) - Txn: %s", req.TransactionID),
)
if err != nil {
return nil, fmt.Errorf("failed to deduct from wallet (insufficient balance or wallet error): %w", err)
}
// Record the transaction
tx := &domain.VirtualGameTransaction{
UserID: claims.UserID,
CompanyID: claims.CompanyID.Value,
Provider: string(domain.PROVIDER_POPOK),
GameID: req.GameID,
TransactionType: "BET",
Amount: -int64(req.Amount), // Represent bet as negative in accounting
Currency: req.Currency,
ExternalTransactionID: req.TransactionID,
Status: "COMPLETED",
CreatedAt: time.Now(),
}
if err := s.repo.CreateVirtualGameTransaction(ctx, tx); err != nil {
// Optionally rollback wallet deduction if transaction record fails
_, addErr := s.walletSvc.AddToWallet(
ctx,
wallet.RegularID,
domain.Currency(req.Amount),
domain.ValidInt64{},
domain.TRANSFER_DIRECT,
domain.PaymentDetails{},
fmt.Sprintf("Rollback refund for failed bet transaction (PopOK) - Txn: %s", req.TransactionID),
)
if addErr != nil {
return nil, fmt.Errorf("failed to credit wallet: %w", addErr)
}
s.logger.Error("Failed to create bet transaction", "error", err, "txn_id", req.TransactionID, "user_id", claims.UserID)
return nil, fmt.Errorf("failed to record bet transaction: %w", err)
}
// Return updated wallet balance
updatedWallet, err := s.walletSvc.GetCustomerWallet(ctx, claims.UserID)
if err != nil {
return nil, fmt.Errorf("failed to refresh wallet balance: %w", err)
}
return &domain.PopOKBetResponse{
TransactionID: req.TransactionID,
ExternalTrxID: fmt.Sprintf("%v", tx.ID), // internal reference
Balance: float64(updatedWallet.RegularBalance.Float32()),
}, 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")
}
fmt.Printf("\n\nClaims: %+v\n\n", claims)
// 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)
wallet, _ := s.walletSvc.GetCustomerWallet(ctx, claims.UserID)
// balance := 0.0
// if len(wallets) > 0 {
// balance = float64(wallets[0].Balance)
// }
return &domain.PopOKWinResponse{
TransactionID: req.TransactionID,
ExternalTrxID: fmt.Sprintf("%d", existingTx.ID),
Balance: float64(wallet.RegularBalance.Float32()),
}, nil
}
// 3. Convert amount to cents
// amountCents := int64(req.Amount)
wallet, err := s.walletSvc.GetCustomerWallet(ctx, claims.UserID)
if err != nil {
return nil, fmt.Errorf("failed to read user wallets")
}
// 4. Credit to wallet
_, err = s.walletSvc.AddToWallet(ctx, wallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{},
domain.TRANSFER_DIRECT, domain.PaymentDetails{},
fmt.Sprintf("Added %v to wallet for winning PopOkBet", req.Amount),
)
if 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,
CompanyID: claims.CompanyID.Value,
Provider: string(domain.PROVIDER_POPOK),
GameID: req.GameID,
TransactionType: "WIN",
Amount: int64(req.Amount),
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")
}
fmt.Printf("\n\n Win balance is:%v\n\n", float64(wallet.RegularBalance.Float32()))
return &domain.PopOKWinResponse{
TransactionID: req.TransactionID,
ExternalTrxID: fmt.Sprintf("%v", tx.ID),
Balance: float64(wallet.RegularBalance.Float32()),
}, nil
}
func (s *service) ProcessTournamentWin(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 tournament win request", "error", err)
return nil, fmt.Errorf("invalid token")
}
// --- 2. Check for duplicate tournament win transaction (idempotency) ---
existingTx, err := s.repo.GetVirtualGameTransactionByExternalID(ctx, req.TransactionID)
if err != nil {
s.logger.Error("Failed to check existing tournament transaction", "error", err)
return nil, fmt.Errorf("transaction check failed")
}
if existingTx != nil && existingTx.TransactionType == "TOURNAMENT_WIN" {
s.logger.Warn("Duplicate tournament win", "transactionID", req.TransactionID)
wallet, _ := s.walletSvc.GetCustomerWallet(ctx, claims.UserID)
return &domain.PopOKWinResponse{
TransactionID: req.TransactionID,
ExternalTrxID: fmt.Sprintf("%v", existingTx.ID),
Balance: float64(wallet.RegularBalance.Float32()),
}, nil
}
// --- 3. Fetch wallet ---
wallet, err := s.walletSvc.GetCustomerWallet(ctx, claims.UserID)
if err != nil {
return nil, fmt.Errorf("failed to fetch wallet for user %d: %w", claims.UserID, err)
}
// --- 4. Credit user wallet ---
_, err = s.walletSvc.AddToWallet(
ctx,
wallet.RegularID,
domain.Currency(req.Amount),
domain.ValidInt64{},
domain.TRANSFER_DIRECT,
domain.PaymentDetails{
ReferenceNumber: domain.ValidString{
Value: req.TransactionID,
Valid: true,
},
},
fmt.Sprintf("Added %v to wallet for winning PopOK Tournament", req.Amount),
)
if err != nil {
s.logger.Error("Failed to credit wallet for tournament", "userID", claims.UserID, "error", err)
return nil, fmt.Errorf("wallet credit failed")
}
// --- 5. Record tournament win transaction ---
tx := &domain.VirtualGameTransaction{
UserID: claims.UserID,
CompanyID: claims.CompanyID.Value,
Provider: string(domain.PROVIDER_POPOK),
GameID: req.GameID,
TransactionType: "TOURNAMENT_WIN",
Amount: int64(req.Amount),
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 record tournament win transaction", "error", err)
return nil, fmt.Errorf("transaction recording failed")
}
// --- 6. Return response with updated balance ---
return &domain.PopOKWinResponse{
TransactionID: req.TransactionID,
ExternalTrxID: fmt.Sprintf("%v", tx.ID),
Balance: float64(wallet.RegularBalance.Float32()),
}, nil
}
func (s *service) ProcessPromoWin(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 promo win request", "error", err)
return nil, fmt.Errorf("invalid token")
}
// --- 2. Check for duplicate promo win transaction (idempotency) ---
existingTx, err := s.repo.GetVirtualGameTransactionByExternalID(ctx, req.TransactionID)
if err != nil {
s.logger.Error("Failed to check existing promo transaction", "error", err)
return nil, fmt.Errorf("transaction check failed")
}
if existingTx != nil && existingTx.TransactionType == "PROMO_WIN" {
s.logger.Warn("Duplicate promo win", "transactionID", req.TransactionID)
wallet, _ := s.walletSvc.GetCustomerWallet(ctx, claims.UserID)
return &domain.PopOKWinResponse{
TransactionID: req.TransactionID,
ExternalTrxID: fmt.Sprintf("%v", existingTx.ID),
Balance: float64(wallet.RegularBalance.Float32()),
}, nil
}
// --- 3. Fetch wallet ---
wallet, err := s.walletSvc.GetCustomerWallet(ctx, claims.UserID)
if err != nil {
return nil, fmt.Errorf("failed to fetch wallet for user %d: %w", claims.UserID, err)
}
// --- 4. Credit user wallet ---
_, err = s.walletSvc.AddToWallet(
ctx,
wallet.RegularID,
domain.Currency(req.Amount),
domain.ValidInt64{},
domain.TRANSFER_DIRECT,
domain.PaymentDetails{
ReferenceNumber: domain.ValidString{
Value: req.TransactionID,
Valid: true,
},
},
fmt.Sprintf("Added %v to wallet for winning PopOK Promo Win", req.Amount),
)
if err != nil {
s.logger.Error("Failed to credit wallet for promo", "userID", claims.UserID, "error", err)
return nil, fmt.Errorf("wallet credit failed")
}
// --- 5. Record promo win transaction ---
tx := &domain.VirtualGameTransaction{
UserID: claims.UserID,
CompanyID: claims.CompanyID.Value,
Provider: string(domain.PROVIDER_POPOK),
GameID: req.GameID,
TransactionType: "PROMO_WIN",
Amount: int64(req.Amount),
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 record promo win transaction", "error", err)
return nil, fmt.Errorf("transaction recording failed")
}
// --- 6. Return response with updated balance ---
return &domain.PopOKWinResponse{
TransactionID: req.TransactionID,
ExternalTrxID: fmt.Sprintf("%v", tx.ID),
Balance: float64(wallet.RegularBalance.Float32()),
}, 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. Fetch wallet ---
wallet, err := s.walletSvc.GetCustomerWallet(ctx, claims.UserID)
if err != nil {
return nil, fmt.Errorf("failed to fetch wallet for user %d: %w", claims.UserID, err)
}
// --- 3. 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")
}
// --- 4. Validate 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")
}
// --- 5. Check for duplicate cancellation ---
if originalBet.Status == "CANCELLED" {
s.logger.Warn("Transaction already cancelled", "transactionID", req.TransactionID)
return &domain.PopOKCancelResponse{
TransactionID: req.TransactionID,
ExternalTrxID: fmt.Sprintf("%v", originalBet.ID),
Balance: float64(wallet.RegularBalance.Float32()),
}, nil
}
// --- 6. Refund the bet amount ---
refundAmount := -originalBet.Amount // Bet amounts are negative
_, err = s.walletSvc.AddToWallet(
ctx,
wallet.RegularID,
domain.Currency(refundAmount),
domain.ValidInt64{},
domain.TRANSFER_DIRECT,
domain.PaymentDetails{
ReferenceNumber: domain.ValidString{
Value: req.TransactionID,
Valid: true,
},
},
fmt.Sprintf("Refunded %v to wallet for cancelling PopOK bet", refundAmount),
)
if err != nil {
s.logger.Error("Failed to refund bet", "userID", claims.UserID, "error", err)
return nil, fmt.Errorf("refund failed")
}
// --- 7. 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(),
}
// Update original transaction status
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 original transaction status
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 cancel transaction failed")
}
// --- 8. Return response with updated balance ---
return &domain.PopOKCancelResponse{
TransactionID: req.TransactionID,
ExternalTrxID: fmt.Sprintf("%v", cancelTx.ID),
Balance: float64(wallet.RegularBalance.Float32()),
}, nil
}
func generatePopOKHash(privateKey, timestamp string, data domain.PopokLaunchRequestData) (string, error) {
// Marshal data to JSON (compact format, like json_encode in PHP)
dataBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
// Concatenate: privateKey + time + json_encoded(data)
hashInput := fmt.Sprintf("%s%s%s", privateKey, timestamp, string(dataBytes))
// SHA-256 hash
hash := sha256.Sum256([]byte(hashInput))
return hex.EncodeToString(hash[:]), 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) 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
// Step 1: Construct payload without the hash
data := map[string]interface{}{
"action": "gameList",
"platform": s.config.PopOK.Platform,
"partnerId": s.config.PopOK.ClientID,
"currency": currency,
"time": now,
}
// Step 2: Marshal data to JSON for hash calculation
// dataBytes, err := json.Marshal(data)
// if err != nil {
// s.logger.Error("Failed to marshal data for hash generation", "error", err)
// return nil, err
// }
// Step 3: Calculate hash: sha256(privateKey + time + json(data))
rawHash := s.config.PopOK.SecretKey + now
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(rawHash)))
// Step 4: Add the hash to the payload
data["hash"] = hash
// Step 5: Marshal full payload with hash
bodyBytes, err := json.Marshal(data)
if err != nil {
s.logger.Error("Failed to marshal final payload with hash", "error", err)
return nil, err
}
// Step 6: Create and send the request
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")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
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()
// Step 7: Handle response
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")
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
}
func (s *service) AddFavoriteGame(ctx context.Context, userID, gameID int64) error {
return s.repo.AddFavoriteGame(ctx, userID, gameID)
}
func (s *service) RemoveFavoriteGame(ctx context.Context, userID, gameID int64) error {
return s.repo.RemoveFavoriteGame(ctx, userID, gameID)
}
func (s *service) ListFavoriteGames(ctx context.Context, userID int64) ([]domain.GameRecommendation, error) {
gameIDs, err := s.repo.ListFavoriteGames(ctx, userID)
if err != nil {
s.logger.Error("Failed to list favorite games", "userID", userID, "error", err)
return nil, err
}
if len(gameIDs) == 0 {
return []domain.GameRecommendation{}, nil
}
allGames, err := s.ListGames(ctx, "ETB") // You can use dynamic currency if needed
if err != nil {
return nil, err
}
var favorites []domain.GameRecommendation
idMap := make(map[int64]bool)
for _, id := range gameIDs {
idMap[id] = true
}
for _, g := range allGames {
if idMap[int64(g.ID)] {
favorites = append(favorites, domain.GameRecommendation{
GameID: g.ID,
GameName: g.GameName,
Thumbnail: g.Thumbnail,
Bets: g.Bets,
Reason: "Marked as favorite",
})
}
}
return favorites, nil
}