From 2f2ba65abdb07cfe8c6e33046b51afb10add8a6a Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Thu, 12 Jun 2025 16:12:57 +0300 Subject: [PATCH] PopOK callback fix + API credentials --- go.mod | 2 +- internal/config/config.go | 5 + internal/domain/virtual_game.go | 81 +++++- internal/repository/virtual_game.go | 25 ++ internal/services/virtualGame/port.go | 4 + internal/services/virtualGame/service.go | 248 +++++++++++++++++- .../handlers/virtual_games_hadlers.go | 82 +++++- internal/web_server/routes.go | 4 + 8 files changed, 424 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index e9123c5..03a19b0 100644 --- a/go.mod +++ b/go.mod @@ -72,6 +72,6 @@ require ( ) require ( - github.com/resend/resend-go/v2 v2.20.0 // indirect + github.com/resend/resend-go/v2 v2.20.0 // direct go.uber.org/multierr v1.10.0 // indirect ) diff --git a/internal/config/config.go b/internal/config/config.go index edcc62f..f6e6533 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -260,7 +260,11 @@ func (c *Config) loadEnv() error { if c.ADRO_SMS_HOST_URL == "" { c.ADRO_SMS_HOST_URL = "https://api.afrosms.com" } + popOKClientID := os.Getenv("POPOK_CLIENT_ID") + + popOKPlatform := os.Getenv("POPOK_PLATFORM") + if popOKClientID == "" { return ErrInvalidPopOKClientID } @@ -285,6 +289,7 @@ func (c *Config) loadEnv() error { SecretKey: popOKSecretKey, BaseURL: popOKBaseURL, CallbackURL: popOKCallbackURL, + Platform: popOKPlatform, } betToken := os.Getenv("BET365_TOKEN") if betToken == "" { diff --git a/internal/domain/virtual_game.go b/internal/domain/virtual_game.go index f17bdd9..0c5af92 100644 --- a/internal/domain/virtual_game.go +++ b/internal/domain/virtual_game.go @@ -12,7 +12,7 @@ type VirtualGame struct { MinBet float64 `json:"min_bet"` MaxBet float64 `json:"max_bet"` Volatility string `json:"volatility"` - IsActive bool `json:"is_active"` + IsActive bool `json:"is_active"` RTP float64 `json:"rtp"` IsFeatured bool `json:"is_featured"` PopularityScore int `json:"popularity_score"` @@ -39,17 +39,18 @@ type VirtualGameSession struct { } type VirtualGameTransaction struct { - ID int64 `json:"id"` - SessionID int64 `json:"session_id"` - UserID int64 `json:"user_id"` - WalletID int64 `json:"wallet_id"` - TransactionType string `json:"transaction_type"` // BET, WIN, REFUND, CASHOUT, etc. - Amount int64 `json:"amount"` // Always in cents - Currency string `json:"currency"` - ExternalTransactionID string `json:"external_transaction_id"` - Status string `json:"status"` // PENDING, COMPLETED, FAILED - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + SessionID int64 `json:"session_id"` + UserID int64 `json:"user_id"` + WalletID int64 `json:"wallet_id"` + TransactionType string `json:"transaction_type"` // BET, WIN, REFUND, CASHOUT, etc. + Amount int64 `json:"amount"` // Always in cents + Currency string `json:"currency"` + ExternalTransactionID string `json:"external_transaction_id"` + ReferenceTransactionID string `json:"reference_transaction_id"` + Status string `json:"status"` // PENDING, COMPLETED, FAILED + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // Alea Play specific fields GameRoundID string `json:"game_round_id"` // Round identifier @@ -87,6 +88,7 @@ type PopOKConfig struct { SecretKey string BaseURL string CallbackURL string + Platform string } type PopOKCallback struct { @@ -99,6 +101,61 @@ type PopOKCallback struct { Signature string `json:"signature"` // HMAC-SHA256 signature for verification } +type PopOKPlayerInfoRequest struct { + ExternalToken string `json:"externalToken"` +} + +type PopOKPlayerInfoResponse struct { + Country string `json:"country"` + Currency string `json:"currency"` + Balance float64 `json:"balance"` + PlayerID string `json:"playerId"` +} + +type PopOKBetRequest struct { + ExternalToken string `json:"externalToken"` + PlayerID string `json:"playerId"` + GameID string `json:"gameId"` + TransactionID string `json:"transactionId"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` +} + +type PopOKBetResponse struct { + TransactionID string `json:"transactionId"` + ExternalTrxID string `json:"externalTrxId"` + Balance float64 `json:"balance"` +} + +// domain/popok.go +type PopOKWinRequest struct { + ExternalToken string `json:"externalToken"` + PlayerID string `json:"playerId"` + GameID string `json:"gameId"` + TransactionID string `json:"transactionId"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` +} + +type PopOKWinResponse struct { + TransactionID string `json:"transactionId"` + ExternalTrxID string `json:"externalTrxId"` + Balance float64 `json:"balance"` +} + +type PopOKCancelRequest struct { + ExternalToken string `json:"externalToken"` + PlayerID string `json:"playerId"` + GameID string `json:"gameId"` + TransactionID string `json:"transactionId"` +} + +type PopOKCancelResponse struct { + TransactionID string `json:"transactionId"` + ExternalTrxID string `json:"externalTrxId"` + Balance float64 `json:"balance"` +} + type AleaPlayCallback struct { EventID string `json:"event_id"` TransactionID string `json:"transaction_id"` diff --git a/internal/repository/virtual_game.go b/internal/repository/virtual_game.go index f736390..3b5277b 100644 --- a/internal/repository/virtual_game.go +++ b/internal/repository/virtual_game.go @@ -18,6 +18,7 @@ type VirtualGameRepository interface { CreateVirtualGameTransaction(ctx context.Context, tx *domain.VirtualGameTransaction) error GetVirtualGameTransactionByExternalID(ctx context.Context, externalID string) (*domain.VirtualGameTransaction, error) UpdateVirtualGameTransactionStatus(ctx context.Context, id int64, status string) error + // WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error GetGameCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error) } @@ -151,3 +152,27 @@ func (r *VirtualGameRepo) GetGameCounts(ctx context.Context, filter domain.Repor return total, active, inactive, nil } + +// func (r *VirtualGameRepo) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error { +// _, tx, err := r.store.BeginTx(ctx) +// if err != nil { +// return err +// } + +// txCtx := context.WithValue(ctx, contextTxKey, tx) + +// defer func() { +// if p := recover(); p != nil { +// tx.Rollback(ctx) +// panic(p) +// } +// }() + +// err = fn(txCtx) +// if err != nil { +// tx.Rollback(ctx) +// return err +// } + +// return tx.Commit(ctx) +// } diff --git a/internal/services/virtualGame/port.go b/internal/services/virtualGame/port.go index 4daa771..6a80458 100644 --- a/internal/services/virtualGame/port.go +++ b/internal/services/virtualGame/port.go @@ -9,6 +9,10 @@ import ( type VirtualGameService interface { GenerateGameLaunchURL(ctx context.Context, userID int64, gameID, currency, mode string) (string, error) HandleCallback(ctx context.Context, callback *domain.PopOKCallback) error + ProcessBet(ctx context.Context, req *domain.PopOKBetRequest) (*domain.PopOKBetResponse, error) + GetPlayerInfo(ctx context.Context, req *domain.PopOKPlayerInfoRequest) (*domain.PopOKPlayerInfoResponse, error) + ProcessWin(ctx context.Context, req *domain.PopOKWinRequest) (*domain.PopOKWinResponse, error) + ProcessCancel(ctx context.Context, req *domain.PopOKCancelRequest) (*domain.PopOKCancelResponse, error) GetGameCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error) } diff --git a/internal/services/virtualGame/service.go b/internal/services/virtualGame/service.go index 9fcbb75..b1e28d0 100644 --- a/internal/services/virtualGame/service.go +++ b/internal/services/virtualGame/service.go @@ -19,12 +19,12 @@ import ( ) type service struct { - repo repository.VirtualGameRepository - walletSvc wallet.Service - store *repository.Store + repo repository.VirtualGameRepository + walletSvc wallet.Service + store *repository.Store // virtualGameStore repository.VirtualGameRepository - config *config.Config - logger *slog.Logger + config *config.Config + logger *slog.Logger } func New(repo repository.VirtualGameRepository, walletSvc wallet.Service, store *repository.Store, cfg *config.Config, logger *slog.Logger) VirtualGameService { @@ -60,11 +60,18 @@ func (s *service) GenerateGameLaunchURL(ctx context.Context, userID int64, gameI } params := fmt.Sprintf( - "client_id=%s&game_id=%s¤cy=%s&lang=en&mode=%s&token=%s", - s.config.PopOK.ClientID, gameID, currency, mode, token, + "partnerId=%s&gameId=%s&gameMode=%s&lang=en&platform=%s&externalToken=%s", + s.config.PopOK.ClientID, gameID, mode, s.config.PopOK.Platform, token, ) - signature := s.generateSignature(params) - return fmt.Sprintf("%s/game/launch?%s&signature=%s", s.config.PopOK.BaseURL, params, signature), nil + + // 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 { @@ -139,7 +146,228 @@ func (s *service) HandleCallback(ctx context.Context, callback *domain.PopOKCall return nil } -func (s *service) generateSignature(params string) string { +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)) diff --git a/internal/web_server/handlers/virtual_games_hadlers.go b/internal/web_server/handlers/virtual_games_hadlers.go index 715a051..b47e55f 100644 --- a/internal/web_server/handlers/virtual_games_hadlers.go +++ b/internal/web_server/handlers/virtual_games_hadlers.go @@ -5,14 +5,15 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/gofiber/fiber/v2" ) + type launchVirtualGameReq struct { - GameID string `json:"game_id" validate:"required" example:"crash_001"` - Currency string `json:"currency" validate:"required,len=3" example:"USD"` - Mode string `json:"mode" validate:"required,oneof=REAL DEMO" example:"REAL"` + GameID string `json:"game_id" validate:"required" example:"crash_001"` + Currency string `json:"currency" validate:"required,len=3" example:"USD"` + Mode string `json:"mode" validate:"required,oneof=fun real" example:"real"` } type launchVirtualGameRes struct { - LaunchURL string `json:"launch_url"` + LaunchURL string `json:"launch_url"` } // LaunchVirtualGame godoc @@ -81,3 +82,76 @@ func (h *Handler) HandleVirtualGameCallback(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusOK, "Callback processed successfully", nil, nil) } + +func (h *Handler) HandlePlayerInfo(c *fiber.Ctx) error { + var req domain.PopOKPlayerInfoRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request") + } + + resp, err := h.virtualGameSvc.GetPlayerInfo(c.Context(), &req) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + return response.WriteJSON(c, fiber.StatusOK, "Player info retrieved", resp, nil) +} + +func (h *Handler) HandleBet(c *fiber.Ctx) error { + var req domain.PopOKBetRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid bet request") + } + + resp, err := h.virtualGameSvc.ProcessBet(c.Context(), &req) + if err != nil { + code := fiber.StatusInternalServerError + if err.Error() == "invalid token" { + code = fiber.StatusUnauthorized + } else if err.Error() == "insufficient balance" { + code = fiber.StatusBadRequest + } + return fiber.NewError(code, err.Error()) + } + + return response.WriteJSON(c, fiber.StatusOK, "Bet processed", resp, nil) +} + +func (h *Handler) HandleWin(c *fiber.Ctx) error { + var req domain.PopOKWinRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid win request") + } + + resp, err := h.virtualGameSvc.ProcessWin(c.Context(), &req) + if err != nil { + code := fiber.StatusInternalServerError + if err.Error() == "invalid token" { + code = fiber.StatusUnauthorized + } + return fiber.NewError(code, err.Error()) + } + + return response.WriteJSON(c, fiber.StatusOK, "Win processed", resp, nil) +} + +func (h *Handler) HandleCancel(c *fiber.Ctx) error { + var req domain.PopOKCancelRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid cancel request") + } + + resp, err := h.virtualGameSvc.ProcessCancel(c.Context(), &req) + if err != nil { + code := fiber.StatusInternalServerError + switch err.Error() { + case "invalid token": + code = fiber.StatusUnauthorized + case "original bet not found", "invalid original transaction": + code = fiber.StatusBadRequest + } + return fiber.NewError(code, err.Error()) + } + + return response.WriteJSON(c, fiber.StatusOK, "Cancel processed", resp, nil) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 29f725f..49020b3 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -253,6 +253,10 @@ func (a *App) initAppRoutes() { // Virtual Game Routes a.fiber.Post("/virtual-game/launch", a.authMiddleware, h.LaunchVirtualGame) a.fiber.Post("/virtual-game/callback", h.HandleVirtualGameCallback) + a.fiber.Post("/playerInfo", h.HandlePlayerInfo) + a.fiber.Post("/bet", h.HandleBet) + a.fiber.Post("/win", h.HandleWin) + a.fiber.Post("/cancel", h.HandleCancel) }