package virtualgameservice import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "log/slog" "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 } sessionToken := fmt.Sprintf("%d-%s-%d", userID, gameID, time.Now().UnixNano()) token, err := jwtutil.CreatePopOKJwt( userID, user.PhoneNumber, currency, "en", mode, sessionToken, s.config.PopOK.SecretKey, 24*time.Hour, ) if err != nil { s.logger.Error("Failed to create PopOK JWT", "userID", userID, "error", err) return "", err } 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, ) // params = fmt.Sprintf( // "partnerId=%s&gameId=%sgameMode=%s&lang=en&platform=%s", // "1", "1", "fun", "111", // ) // signature := s.generateSignature(params) return fmt.Sprintf("%s?%s", s.config.PopOK.BaseURL, params), nil // 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), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}) 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), domain.CustomerWalletType, domain.ValidInt64{}, domain.TRANSFER_DIRECT); 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), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}); 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), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}); 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) }