package veli import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/ports" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" virtualgameservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/google/uuid" "go.uber.org/zap" ) var ( ErrPlayerNotFound = errors.New("PLAYER_NOT_FOUND") ErrSessionExpired = errors.New("SESSION_EXPIRED") ErrInsufficientBalance = errors.New("INSUFFICIENT_BALANCE") ErrDuplicateTransaction = errors.New("DUPLICATE_TRANSACTION") ) type Service struct { virtualGameSvc virtualgameservice.VirtualGameService repo repository.VirtualGameRepository client *Client walletSvc *wallet.Service transfetStore ports.TransferStore mongoLogger *zap.Logger cfg *config.Config } func New( virtualGameSvc virtualgameservice.VirtualGameService, repo repository.VirtualGameRepository, client *Client, walletSvc *wallet.Service, transferStore ports.TransferStore, mongoLogger *zap.Logger, cfg *config.Config, ) *Service { return &Service{ virtualGameSvc: virtualGameSvc, repo: repo, client: client, walletSvc: walletSvc, transfetStore: transferStore, mongoLogger: mongoLogger, cfg: cfg, } } func (s *Service) GetAtlasVGames(ctx context.Context) ([]domain.AtlasGameEntity, error) { // 1. Compose URL (could be configurable) url := "https://atlas-v.com/partner/35fr5784dbgr4dfw234wsdsw" + "?hash=b3596faa6185180e9b2ca01cb5a052d316511872×tamp=1700244963080" // 2. Create a dedicated HTTP client with timeout client := &http.Client{Timeout: 15 * time.Second} // 3. Prepare request with context req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } // 4. Execute request resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("calling Atlas-V API: %w", err) } defer resp.Body.Close() // 5. Check response status if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("Atlas-V API error: status %d, body: %s", resp.StatusCode, body) } // 6. Decode response into slice of GameEntity var games []domain.AtlasGameEntity if err := json.NewDecoder(resp.Body).Decode(&games); err != nil { return nil, fmt.Errorf("decoding Atlas-V games: %w", err) } return games, nil } func (s *Service) GetProviders(ctx context.Context, req domain.ProviderRequest) (*domain.ProviderResponse, error) { // Always mirror request body fields into sigParams sigParams := map[string]any{ "brandId": req.BrandID, } // Optional fields sigParams["extraData"] = fmt.Sprintf("%t", req.ExtraData) // false is still included if req.Size > 0 { sigParams["size"] = fmt.Sprintf("%d", req.Size) } else { sigParams["size"] = "" // keep empty if not set } if req.Page > 0 { sigParams["page"] = fmt.Sprintf("%d", req.Page) } else { sigParams["page"] = "" } var res domain.ProviderResponse err := s.client.Post(ctx, "/game-lists/public/providers", req, sigParams, &res) return &res, err } func (s *Service) GetGames(ctx context.Context, req domain.GameListRequest) ([]domain.GameEntity, error) { // 1. Check if provider is enabled in DB // provider, err := s.repo.GetVirtualGameProviderByID(ctx, req.ProviderID) // if err != nil { // return nil, fmt.Errorf("failed to check provider %s: %w", req.ProviderID, err) // } // if !provider.Enabled { // // Provider exists but is disabled → return empty list (or error if you prefer) // return nil, fmt.Errorf("provider %s is disabled", req.ProviderID) // } // 2. Prepare signature params sigParams := map[string]any{ "brandId": req.BrandID, "providerId": req.ProviderID, "size": req.Size, "page": req.Page, } // 3. Call external API var res struct { Items []domain.GameEntity `json:"items"` } if err := s.client.Post(ctx, "/game-lists/public/games", req, sigParams, &res); err != nil { return nil, fmt.Errorf("failed to fetch games for provider %s: %w", req.ProviderID, err) } return res.Items, nil } func (s *Service) StartGame(ctx context.Context, req domain.GameStartRequest) (*domain.GameStartResponse, error) { // 1. Check if provider is enabled in DB // provider, err := s.repo.GetVirtualGameProviderByID(ctx, req.ProviderID) // if err != nil { // return nil, fmt.Errorf("failed to check provider %s: %w", req.ProviderID, err) // } // if !provider.Enabled { // // Provider exists but is disabled → return error // return nil, fmt.Errorf("provider %s is disabled", req.ProviderID) // } // 2. Prepare signature params sigParams := map[string]any{ "sessionId": req.SessionID, "providerId": req.ProviderID, "gameId": req.GameID, "language": req.Language, "playerId": req.PlayerID, "currency": req.Currency, "deviceType": req.DeviceType, "country": req.Country, "ip": req.IP, "brandId": req.BrandID, } // 3. Call external API var res domain.GameStartResponse if err := s.client.Post(ctx, "/unified-api/public/start-game", req, sigParams, &res); err != nil { return nil, fmt.Errorf("failed to start game with provider %s: %w", req.ProviderID, err) } playerIDInt64, err := strconv.ParseInt(req.PlayerID, 10, 64) if err != nil { return nil, fmt.Errorf("invalid PlayerID: %w", err) } session := &domain.VirtualGameSession{ UserID: playerIDInt64, GameID: req.GameID, SessionToken: uuid.NewString(), } if err := s.repo.CreateVirtualGameSession(ctx, session); err != nil { return nil, fmt.Errorf("failed to create virtual game session: %w", err) } return &res, nil } func (s *Service) StartDemoGame(ctx context.Context, req domain.DemoGameRequest) (*domain.GameStartResponse, error) { // 1. Check if provider is enabled in DB // provider, err := s.repo.GetVirtualGameProviderByID(ctx, req.ProviderID) // if err != nil { // return nil, fmt.Errorf("failed to check provider %s: %w", req.ProviderID, err) // } // if !provider.Enabled { // // Provider exists but is disabled → return error // return nil, fmt.Errorf("provider %s is disabled", req.ProviderID) // } // 2. Prepare signature params sigParams := map[string]any{ "providerId": req.ProviderID, "gameId": req.GameID, "language": req.Language, "deviceType": req.DeviceType, "ip": req.IP, "brandId": req.BrandID, } // 3. Call external API var res domain.GameStartResponse if err := s.client.Post(ctx, "/unified-api/public/start-demo-game", req, sigParams, &res); err != nil { return nil, fmt.Errorf("failed to start demo game with provider %s: %w", req.ProviderID, err) } return &res, nil } func (s *Service) GetBalance(ctx context.Context, req domain.BalanceRequest) (*domain.BalanceResponse, error) { // Retrieve player's real balance from wallet Service playerIDInt64, err := strconv.ParseInt(req.PlayerID, 10, 64) if err != nil { return nil, fmt.Errorf("invalid PlayerID: %w", err) } // playerWallets, err := s.walletSvc.GetWalletsByUser(ctx, playerIDInt64) // if err != nil { // return nil, fmt.Errorf("failed to get real balance: %w", err) // } // if len(playerWallets) == 0 { // return nil, fmt.Errorf("PLAYER_NOT_FOUND: no wallet found for player %s", req.PlayerID) // } wallet, err := s.walletSvc.GetCustomerWallet(ctx, playerIDInt64) if err != nil { return nil, fmt.Errorf("failed to read user wallets") } // realBalance := playerWallets[0].Balance // Retrieve bonus balance if applicable // var bonusBalance float64 // bonusBalance := float64(wallet.StaticBalance) // Build the response res := &domain.BalanceResponse{ Real: struct { Currency string `json:"currency"` Amount float64 `json:"amount"` }{ Currency: req.Currency, Amount: (float64(wallet.RegularBalance.Float32())), }, } // if bonusBalance > 0 { // res.Bonus = &struct { // Currency string `json:"currency"` // Amount float64 `json:"amount"` // }{ // Currency: req.Currency, // Amount: bonusBalance, // } // } return res, nil } func (s *Service) ProcessBet(ctx context.Context, req domain.BetRequest) (*domain.BetResponse, error) { // --- 1. Validate PlayerID --- playerIDInt64, err := strconv.ParseInt(req.PlayerID, 10, 64) if err != nil { return nil, fmt.Errorf("BAD_REQUEST: invalid PlayerID %s", req.PlayerID) } wallet, err := s.walletSvc.GetCustomerWallet(ctx, playerIDInt64) if err != nil { return nil, fmt.Errorf("failed to read user wallets") } bonusBalance := float64(wallet.StaticBalance.Float32()) // --- 4. Check sufficient balance --- if float64(wallet.RegularBalance.Float32()) < req.Amount.Amount { return nil, fmt.Errorf("INSUFFICIENT_BALANCE") } // --- 6. Persist wallet deductions --- _, err = s.walletSvc.DeductFromWallet(ctx, wallet.RegularID, domain.ToCurrency(float32(req.Amount.Amount)), domain.ValidInt64{}, domain.TRANSFER_DIRECT, fmt.Sprintf("Deduct amount %.2f for bet %s", req.Amount.Amount, req.TransactionID), ) if err != nil { return nil, fmt.Errorf("bonus deduction failed: %w", err) } // --- 7. Build response --- res := &domain.BetResponse{ Real: domain.BalanceDetail{ Currency: req.Amount.Currency, Amount: float64(wallet.RegularBalance.Float32()) - req.Amount.Amount, }, WalletTransactionID: req.TransactionID, UsedRealAmount: req.Amount.Amount, UsedBonusAmount: 0, } if bonusBalance > 0 { res.Bonus = &domain.BalanceDetail{ Currency: req.Amount.Currency, Amount: bonusBalance, } } return res, nil } func (s *Service) ProcessWin(ctx context.Context, req domain.WinRequest) (*domain.WinResponse, error) { // --- 1. Validate PlayerID --- playerIDInt64, err := strconv.ParseInt(req.PlayerID, 10, 64) if err != nil { return nil, fmt.Errorf("BAD_REQUEST: invalid PlayerID %s", req.PlayerID) } // --- 2. Get player wallets --- wallet, err := s.walletSvc.GetCustomerWallet(ctx, playerIDInt64) if err != nil { return nil, fmt.Errorf("failed to read user wallets") } // --- 3. Convert balances safely using Float32() --- realBalance := float64(wallet.RegularBalance.Float32()) bonusBalance := float64(wallet.StaticBalance.Float32()) // --- 4. Apply winnings --- winAmount := req.Amount.Amount usedReal := winAmount usedBonus := 0.0 // Future extension: split winnings between bonus and real wallets if needed _, err = s.walletSvc.AddToWallet( ctx, wallet.RegularID, domain.ToCurrency(float32(winAmount)), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, fmt.Sprintf("Win %.2f for transaction %s", winAmount, req.TransactionID), ) if err != nil { return nil, fmt.Errorf("failed to credit real wallet: %w", err) } // --- 5. Build response --- res := &domain.WinResponse{ Real: domain.BalanceDetail{ Currency: req.Amount.Currency, Amount: realBalance + winAmount, // reflect the credited win amount }, WalletTransactionID: req.TransactionID, UsedRealAmount: usedReal, UsedBonusAmount: usedBonus, } if bonusBalance > 0 { res.Bonus = &domain.BalanceDetail{ Currency: req.Amount.Currency, Amount: bonusBalance, } } return res, nil } func (s *Service) ProcessCancel(ctx context.Context, req domain.CancelRequest) (*domain.CancelResponse, error) { // --- 1. Validate PlayerID --- playerIDInt64, err := strconv.ParseInt(req.PlayerID, 10, 64) if err != nil { return nil, fmt.Errorf("BAD_REQUEST: invalid PlayerID %q", req.PlayerID) } // --- 2. Get player wallets --- wallet, err := s.walletSvc.GetCustomerWallet(ctx, playerIDInt64) if err != nil { return nil, fmt.Errorf("failed to read user wallets") } // --- 3. Convert balances using Float32() --- realBalance := float64(wallet.RegularBalance.Float32()) bonusBalance := float64(wallet.StaticBalance.Float32()) // --- 4. Determine refund amount --- var refundAmount float64 if req.IsAdjustment { if req.AdjustmentRefund.Amount <= 0 { return nil, fmt.Errorf("missing adjustmentRefund for adjustment cancel") } refundAmount = req.AdjustmentRefund.Amount } else { originalTransfer, err := s.transfetStore.GetTransferByReference(ctx, req.RefTransactionID) if err != nil { return nil, fmt.Errorf("failed to get original bet for cancellation: %w", err) } refundAmount = float64(originalTransfer.Amount) } // --- 5. Refund to wallet --- usedReal := refundAmount usedBonus := 0.0 _, err = s.walletSvc.AddToWallet( ctx, wallet.RegularID, domain.ToCurrency(float32(refundAmount)), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{ ReferenceNumber: domain.ValidString{ Value: req.TransactionID, Valid: true, }, BankNumber: domain.ValidString{}, }, fmt.Sprintf("Cancel %s refunded %.2f for transaction %s", req.CancelType, refundAmount, req.RefTransactionID), ) if err != nil { return nil, fmt.Errorf("failed to refund wallet: %w", err) } // --- 6. Build response --- res := &domain.CancelResponse{ WalletTransactionID: req.TransactionID, Real: domain.BalanceDetail{ Currency: req.AdjustmentRefund.Currency, Amount: realBalance + refundAmount, // reflect refunded balance }, UsedRealAmount: usedReal, UsedBonusAmount: usedBonus, } if bonusBalance > 0 { res.Bonus = &domain.BalanceDetail{ Currency: req.AdjustmentRefund.Currency, Amount: bonusBalance, } } return res, nil } func (s *Service) GetGamingActivity(ctx context.Context, req domain.GamingActivityRequest) (*domain.GamingActivityResponse, error) { // --- Signature Params (flattened strings for signing) --- sigParams := map[string]any{ "fromDate": req.FromDate, "toDate": req.ToDate, "brandId": s.cfg.VeliGames.BrandID, } // Optional filters if req.ProviderID != "" { sigParams["providerId"] = req.ProviderID } if len(req.PlayerIDs) > 0 { sigParams["playerIds"] = req.PlayerIDs // pass as []string, not joined } if len(req.GameIDs) > 0 { sigParams["gameIds"] = req.GameIDs // pass as []string } if len(req.Currencies) > 0 { sigParams["currencies"] = req.Currencies // pass as []string } if req.Page > 0 { sigParams["page"] = req.Page } else { sigParams["page"] = 1 req.Page = 1 } if req.Size > 0 { sigParams["size"] = req.Size } else { sigParams["size"] = 100 req.Size = 100 } if req.ExcludeFreeWin != nil { sigParams["excludeFreeWin"] = *req.ExcludeFreeWin } // --- Actual API Call --- var res domain.GamingActivityResponse err := s.client.Post(ctx, "/report-api/public/gaming-activity", req, sigParams, &res) if err != nil { return nil, err } // --- Return parsed response --- return &res, nil } func (s *Service) GetHugeWins(ctx context.Context, req domain.HugeWinsRequest) (*domain.HugeWinsResponse, error) { // --- Signature Params (flattened strings for signing) --- sigParams := map[string]any{ "fromDate": req.FromDate, "toDate": req.ToDate, "brandId": req.BrandID, } if req.ProviderID != "" { sigParams["providerId"] = req.ProviderID } if len(req.GameIDs) > 0 { sigParams["gameIds"] = req.GameIDs // pass slice directly } if len(req.Currencies) > 0 { sigParams["currencies"] = req.Currencies // pass slice directly } if req.Page > 0 { sigParams["page"] = req.Page } else { sigParams["page"] = 1 req.Page = 1 } if req.Size > 0 { sigParams["size"] = req.Size } else { sigParams["size"] = 100 req.Size = 100 } // --- Actual API Call --- var res domain.HugeWinsResponse err := s.client.Post(ctx, "/report-api/public/gaming-activity/huge-wins", req, sigParams, &res) if err != nil { return nil, err } return &res, nil } func (s *Service) GetCreditBalances(ctx context.Context, brandID string) ([]domain.CreditBalance, error) { if brandID == "" { return nil, fmt.Errorf("brandID cannot be empty") } // Prepare request body body := map[string]any{ "brandId": brandID, } // Call the VeliGames API var res struct { Credits []domain.CreditBalance `json:"credits"` } if err := s.client.Post(ctx, "/report-api/public/credit/balances", body, nil, &res); err != nil { return nil, fmt.Errorf("failed to fetch credit balances: %w", err) } return res.Credits, nil }