package bet import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "log/slog" "math" "math/big" random "math/rand" "sort" "strconv" "strings" "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/pkgs/helpers" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/company" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notification" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/settings" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "go.uber.org/zap" ) var ( ErrNoEventsAvailable = errors.New("Not enough events available with the given filters") ErrGenerateRandomOutcome = errors.New("Failed to generate any random outcome for events") ErrOutcomesNotCompleted = errors.New("Some bet outcomes are still pending") ErrEventHasBeenRemoved = errors.New("Event has been removed") ErrEventHasNotEnded = errors.New("Event has not ended yet") ErrRawOddInvalid = errors.New("Prematch Raw Odd is Invalid") ErrBranchIDRequired = errors.New("Branch ID required for this role") ErrOutcomeLimit = errors.New("Too many outcomes on a single bet") ErrTotalBalanceNotEnough = errors.New("Total Wallet balance is insufficient to create bet") ErrInvalidAmount = errors.New("Invalid amount") ErrBetAmountTooHigh = errors.New("Cannot create a bet with an amount above limit") ErrBetWinningTooHigh = errors.New("Total Winnings over set limit") ) type Service struct { betStore BetStore eventSvc event.Service prematchSvc odds.ServiceImpl walletSvc wallet.Service branchSvc branch.Service companySvc company.Service settingSvc settings.Service userSvc user.Service notificationSvc *notificationservice.Service logger *slog.Logger mongoLogger *zap.Logger } func NewService( betStore BetStore, eventSvc event.Service, prematchSvc odds.ServiceImpl, walletSvc wallet.Service, branchSvc branch.Service, companySvc company.Service, settingSvc settings.Service, userSvc user.Service, notificationSvc *notificationservice.Service, logger *slog.Logger, mongoLogger *zap.Logger, ) *Service { return &Service{ betStore: betStore, eventSvc: eventSvc, prematchSvc: prematchSvc, walletSvc: walletSvc, branchSvc: branchSvc, companySvc: companySvc, settingSvc: settingSvc, notificationSvc: notificationSvc, logger: logger, mongoLogger: mongoLogger, } } func (s *Service) GenerateCashoutID() (string, error) { const chars = "abcdefghijklmnopqrstuvwxyz0123456789" const length int = 13 charLen := big.NewInt(int64(len(chars))) result := make([]byte, length) for i := 0; i < length; i++ { index, err := rand.Int(rand.Reader, charLen) if err != nil { s.mongoLogger.Error("failed to generate random index for cashout ID", zap.Int("position", i), zap.Error(err), ) return "", err } result[i] = chars[index.Int64()] } return string(result), nil } func (s *Service) GenerateBetOutcome(ctx context.Context, eventID int64, marketID int64, oddID int64) (domain.CreateBetOutcome, error) { eventIDStr := strconv.FormatInt(eventID, 10) marketIDStr := strconv.FormatInt(marketID, 10) oddIDStr := strconv.FormatInt(oddID, 10) event, err := s.eventSvc.GetUpcomingEventByID(ctx, eventIDStr) if err != nil { s.mongoLogger.Error("failed to fetch upcoming event by ID", zap.Int64("event_id", eventID), zap.Error(err), ) return domain.CreateBetOutcome{}, ErrEventHasBeenRemoved } currentTime := time.Now() if event.StartTime.Before(currentTime) { s.mongoLogger.Error("event has already started", zap.Int64("event_id", eventID), zap.Time("event_start_time", event.StartTime), zap.Time("current_time", currentTime), ) return domain.CreateBetOutcome{}, ErrEventHasNotEnded } odds, err := s.prematchSvc.GetOddsByMarketID(ctx, marketIDStr, eventIDStr) if err != nil { s.mongoLogger.Error("failed to get raw odds by market ID", zap.Int64("event_id", eventID), zap.Int64("market_id", marketID), zap.Error(err), ) return domain.CreateBetOutcome{}, err } type rawOddType struct { ID string Name string Odds string Header string Handicap string } var selectedOdd rawOddType var isOddFound bool for _, raw := range odds.RawOdds { var rawOdd rawOddType rawBytes, err := json.Marshal(raw) if err != nil { s.mongoLogger.Error("failed to marshal raw odd", zap.Any("raw", raw), zap.Error(err), ) continue } err = json.Unmarshal(rawBytes, &rawOdd) if err != nil { s.mongoLogger.Error("failed to unmarshal raw odd", zap.ByteString("raw_bytes", rawBytes), zap.Error(err), ) continue } if rawOdd.ID == oddIDStr { selectedOdd = rawOdd isOddFound = true break } } if !isOddFound { s.mongoLogger.Error("odd ID not found in raw odds", zap.Int64("odd_id", oddID), zap.Int64("market_id", marketID), zap.Int64("event_id", eventID), ) return domain.CreateBetOutcome{}, ErrRawOddInvalid } parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) if err != nil { s.mongoLogger.Error("failed to parse selected odd value", zap.String("odd", selectedOdd.Odds), zap.Int64("odd_id", oddID), zap.Error(err), ) return domain.CreateBetOutcome{}, err } newOutcome := domain.CreateBetOutcome{ EventID: eventID, OddID: oddID, MarketID: marketID, SportID: int64(event.SportID), HomeTeamName: event.HomeTeam, AwayTeamName: event.AwayTeam, MarketName: odds.MarketName, Odd: float32(parsedOdd), OddName: selectedOdd.Name, OddHeader: selectedOdd.Header, OddHandicap: selectedOdd.Handicap, Expires: event.StartTime, } return newOutcome, nil } func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID int64, role domain.Role, companyID domain.ValidInt64) (domain.CreateBetRes, error) { settingsList, err := s.settingSvc.GetSettingList(ctx) if err != nil { return domain.CreateBetRes{}, err } if req.Amount < 1 { return domain.CreateBetRes{}, ErrInvalidAmount } if req.Amount > settingsList.BetAmountLimit.Float32() { return domain.CreateBetRes{}, ErrBetAmountTooHigh } if len(req.Outcomes) > int(settingsList.MaxNumberOfOutcomes) { s.mongoLogger.Info("too many outcomes", zap.Int("count", len(req.Outcomes)), zap.Int64("user_id", userID), ) return domain.CreateBetRes{}, ErrOutcomeLimit } var outcomes []domain.CreateBetOutcome = make([]domain.CreateBetOutcome, 0, len(req.Outcomes)) var totalOdds float32 = 1 for _, outcomeReq := range req.Outcomes { newOutcome, err := s.GenerateBetOutcome(ctx, outcomeReq.EventID, outcomeReq.MarketID, outcomeReq.OddID) if err != nil { s.mongoLogger.Error("failed to generate outcome", zap.Int64("event_id", outcomeReq.EventID), zap.Int64("market_id", outcomeReq.MarketID), zap.Int64("odd_id", outcomeReq.OddID), zap.Int64("user_id", userID), zap.Error(err), ) return domain.CreateBetRes{}, err } totalOdds *= float32(newOutcome.Odd) outcomes = append(outcomes, newOutcome) } totalWinnings := req.Amount * totalOdds if totalWinnings > settingsList.TotalWinningLimit.Float32() { s.mongoLogger.Info("Total Winnings over limit", zap.Float32("Total Odds", totalOdds), zap.Float32("amount", req.Amount), zap.Float32("limit", settingsList.TotalWinningLimit.Float32())) return domain.CreateBetRes{}, ErrBetWinningTooHigh } outcomesHash, err := generateOutcomeHash(outcomes) if err != nil { s.mongoLogger.Error("failed to generate outcome hash", zap.Int64("user_id", userID), zap.Error(err), ) return domain.CreateBetRes{}, err } count, err := s.GetBetCountByUserID(ctx, userID, outcomesHash) if err != nil { s.mongoLogger.Error("failed to generate cashout ID", zap.Int64("user_id", userID), zap.Error(err), ) return domain.CreateBetRes{}, err } if count >= 2 { return domain.CreateBetRes{}, fmt.Errorf("bet already placed twice") } fastCode := helpers.GenerateFastCode() amount := req.Amount + (req.Amount * calculateAccumulator(len(outcomes))) newBet := domain.CreateBet{ Amount: domain.ToCurrency(amount), TotalOdds: totalOdds, Status: domain.OUTCOME_STATUS_PENDING, OutcomesHash: outcomesHash, FastCode: fastCode, UserID: userID, } switch role { case domain.RoleCashier: newBet.IsShopBet = true branch, err := s.branchSvc.GetBranchByCashier(ctx, userID) if err != nil { s.mongoLogger.Error("failed to get branch by cashier", zap.Int64("user_id", userID), zap.Error(err), ) return domain.CreateBetRes{}, err } err = s.DeductBetFromBranchWallet(ctx, req.Amount, branch.WalletID, branch.CompanyID, userID) if err != nil { s.mongoLogger.Error("wallet deduction for bet failed", zap.String("role", string(role)), zap.Error(err), ) return domain.CreateBetRes{}, err } case domain.RoleBranchManager, domain.RoleAdmin, domain.RoleSuperAdmin: newBet.IsShopBet = true // Branch Manager, Admin and Super Admin are required to pass a branch id if they want to create a bet if req.BranchID == nil { s.mongoLogger.Warn("branch ID required for admin/manager", zap.Int64("user_id", userID), ) return domain.CreateBetRes{}, ErrBranchIDRequired } branch, err := s.branchSvc.GetBranchByID(ctx, *req.BranchID) if err != nil { s.mongoLogger.Error("failed to get branch by ID", zap.Int64("branch_id", *req.BranchID), zap.Error(err), ) return domain.CreateBetRes{}, err } if branch.BranchManagerID != userID { s.mongoLogger.Warn("unauthorized branch for branch manager", zap.Int64("branch_id", *req.BranchID), zap.Error(err), ) return domain.CreateBetRes{}, err } if companyID.Valid && branch.CompanyID == companyID.Value { s.mongoLogger.Warn("unauthorized company", zap.Int64("branch_id", *req.BranchID), zap.Error(err), ) } err = s.DeductBetFromBranchWallet(ctx, req.Amount, branch.WalletID, branch.CompanyID, userID) if err != nil { s.mongoLogger.Error("wallet deduction for bet failed", zap.String("role", string(role)), zap.Error(err), ) return domain.CreateBetRes{}, err } case domain.RoleCustomer: // Only the customer is able to create a online bet newBet.IsShopBet = false err = s.DeductBetFromCustomerWallet(ctx, req.Amount, userID) if err != nil { s.mongoLogger.Error("customer wallet deduction failed", zap.Float32("amount", req.Amount), zap.Int64("user_id", userID), zap.Error(err), ) return domain.CreateBetRes{}, err } default: s.mongoLogger.Error("unknown role type", zap.String("role", string(role)), zap.Int64("user_id", userID), ) return domain.CreateBetRes{}, fmt.Errorf("Unknown Role Type") } bet, err := s.CreateBet(ctx, newBet) if err != nil { s.mongoLogger.Error("failed to create bet", zap.Int64("user_id", userID), zap.Error(err), ) return domain.CreateBetRes{}, err } for i := range outcomes { outcomes[i].BetID = bet.ID } rows, err := s.betStore.CreateBetOutcome(ctx, outcomes) if err != nil { s.mongoLogger.Error("failed to create bet outcomes", zap.Int64("bet_id", bet.ID), zap.Error(err), ) return domain.CreateBetRes{}, err } for i := range outcomes { // flag odds with large amount of users betting on them count, err := s.betStore.GetBetOutcomeCountByOddID(ctx, outcomes[i].OddID) if err != nil { s.mongoLogger.Error("failed to get count of bet outcome", zap.Int64("bet_id", bet.ID), zap.Int64("odd_id", outcomes[i].OddID), zap.Error(err), ) return domain.CreateBetRes{}, err } // TODO: fetch cap from settings in db if count > 20 { flag := domain.CreateFlagReq{ BetID: 0, OddID: outcomes[i].OddID, Reason: fmt.Sprintf("too many users targeting odd - (%d)", outcomes[i].OddID), } _, err := s.betStore.CreateFlag(ctx, flag) if err != nil { s.mongoLogger.Error("failed to create flag for bet", zap.Int64("bet_id", bet.ID), zap.Error(err), ) } } } // flag bets that have more than three outcomes if len(outcomes) > 3 { flag := domain.CreateFlagReq{ BetID: bet.ID, OddID: 0, Reason: fmt.Sprintf("too many outcomes - (%d)", len(outcomes)), } _, err := s.betStore.CreateFlag(ctx, flag) if err != nil { s.mongoLogger.Error("failed to create flag for bet", zap.Int64("bet_id", bet.ID), zap.Error(err), ) } } // large amount of users betting on the same bet_outcomes total_bet_count, err := s.betStore.GetBetCountByOutcomesHash(ctx, outcomesHash) if err != nil { s.mongoLogger.Error("failed to get bet outcomes count", zap.String("outcomes_hash", outcomesHash), zap.Error(err), ) return domain.CreateBetRes{}, err } if total_bet_count > 10 { flag := domain.CreateFlagReq{ BetID: bet.ID, OddID: 0, Reason: fmt.Sprintf("too many users bet on same outcomes - (%s)", outcomesHash), } _, err := s.betStore.CreateFlag(ctx, flag) if err != nil { s.mongoLogger.Error("failed to get bet outcomes count", zap.String("outcomes_hash", outcomesHash), zap.Error(err), ) } } res := domain.ConvertCreateBet(bet, rows) return res, nil } func (s *Service) DeductBetFromBranchWallet(ctx context.Context, amount float32, walletID int64, companyID int64, userID int64) error { company, err := s.companySvc.GetCompanyByID(ctx, companyID) if err != nil { s.mongoLogger.Error("failed to get company", zap.Int64("company_id", companyID), zap.Error(err), ) return err } deductedAmount := amount * company.DeductedPercentage _, err = s.walletSvc.DeductFromWallet(ctx, walletID, domain.ToCurrency(deductedAmount), domain.ValidInt64{ Value: userID, Valid: true, }, domain.TRANSFER_DIRECT, fmt.Sprintf("Deducted %v amount from wallet by system while placing bet", deductedAmount)) if err != nil { s.mongoLogger.Error("failed to deduct from wallet", zap.Int64("wallet_id", walletID), zap.Float32("amount", deductedAmount), zap.Error(err), ) return err } return nil } func (s *Service) DeductBetFromCustomerWallet(ctx context.Context, amount float32, userID int64) error { wallets, err := s.walletSvc.GetCustomerWallet(ctx, userID) if err != nil { s.mongoLogger.Error("failed to get customer wallets", zap.Int64("user_id", userID), zap.Error(err), ) return err } if amount < wallets.RegularBalance.Float32() { _, err = s.walletSvc.DeductFromWallet(ctx, wallets.RegularID, domain.ToCurrency(amount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, fmt.Sprintf("Deducted %v amount from wallet by system while placing bet", amount)) if err != nil { s.mongoLogger.Error("wallet deduction failed for customer regular wallet", zap.Int64("customer_id", wallets.CustomerID), zap.Int64("customer_wallet_id", wallets.ID), zap.Int64("regular wallet_id", wallets.RegularID), zap.Float32("amount", amount), zap.Error(err), ) return err } } else { combinedBalance := wallets.RegularBalance + wallets.StaticBalance if amount > combinedBalance.Float32() { return ErrTotalBalanceNotEnough } // Empty the regular balance _, err = s.walletSvc.DeductFromWallet(ctx, wallets.RegularID, wallets.RegularBalance, domain.ValidInt64{}, domain.TRANSFER_DIRECT, fmt.Sprintf("Deducted %v amount from wallet by system while placing bet", wallets.RegularBalance.Float32())) if err != nil { s.mongoLogger.Error("wallet deduction failed for customer regular wallet", zap.Int64("customer_id", wallets.CustomerID), zap.Int64("customer_wallet_id", wallets.ID), zap.Int64("regular wallet_id", wallets.RegularID), zap.Float32("amount", amount), zap.Error(err), ) return err } // Empty remaining from static balance remainingAmount := wallets.RegularBalance - domain.Currency(amount) _, err = s.walletSvc.DeductFromWallet(ctx, wallets.StaticID, remainingAmount, domain.ValidInt64{}, domain.TRANSFER_DIRECT, fmt.Sprintf("Deducted %v amount from wallet by system while placing bet", remainingAmount.Float32())) if err != nil { s.mongoLogger.Error("wallet deduction failed for customer static wallet", zap.Int64("customer_id", wallets.CustomerID), zap.Int64("customer_wallet_id", wallets.ID), zap.Int64("static wallet_id", wallets.StaticID), zap.Float32("amount", amount), zap.Error(err), ) return err } } return nil } func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID string, sportID int32, HomeTeam, AwayTeam string, StartTime time.Time, numMarkets int) ([]domain.CreateBetOutcome, float32, error) { var newOdds []domain.CreateBetOutcome var totalOdds float32 = 1 markets, err := s.prematchSvc.GetOddsByEventID(ctx, eventID, domain.OddMarketWithEventFilter{}) if err != nil { s.logger.Error("failed to get odds for event", "event id", eventID, "error", err) s.mongoLogger.Error("failed to get odds for event", zap.String("eventID", eventID), zap.Int32("sportID", sportID), zap.String("homeTeam", HomeTeam), zap.String("awayTeam", AwayTeam), zap.Error(err)) return nil, 0, err } if len(markets) == 0 { s.logger.Error("empty odds for event", "event id", eventID) s.mongoLogger.Warn("empty odds for event", zap.String("eventID", eventID), zap.Int32("sportID", sportID), zap.String("homeTeam", HomeTeam), zap.String("awayTeam", AwayTeam)) return nil, 0, fmt.Errorf("empty odds or event %v", eventID) } var selectedMarkets []domain.OddMarket numMarkets = min(numMarkets, len(markets)) for i := 0; i < numMarkets; i++ { randomIndex := random.Intn(len(markets)) selectedMarkets = append(selectedMarkets, markets[randomIndex]) markets = append(markets[:randomIndex], markets[randomIndex+1:]...) } for _, market := range selectedMarkets { randomRawOdd := market.RawOdds[random.Intn(len(market.RawOdds))] type rawOddType struct { ID string Name string Odds string Header string Handicap string } var selectedOdd rawOddType rawBytes, err := json.Marshal(randomRawOdd) err = json.Unmarshal(rawBytes, &selectedOdd) if err != nil { s.logger.Error("Failed to unmarshal raw odd", "error", err) s.mongoLogger.Warn("Failed to unmarshal raw odd", zap.String("eventID", eventID), zap.Int32("sportID", sportID), zap.Error(err)) continue } parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) if err != nil { s.logger.Error("Failed to parse odd", "error", err) s.mongoLogger.Warn("Failed to parse odd", zap.String("eventID", eventID), zap.String("oddValue", selectedOdd.Odds), zap.Error(err)) continue } eventIDInt, err := strconv.ParseInt(eventID, 10, 64) if err != nil { s.logger.Error("Failed to parse eventID", "error", err) s.mongoLogger.Warn("Failed to parse eventID", zap.String("eventID", eventID), zap.Error(err)) continue } oddID, err := strconv.ParseInt(selectedOdd.ID, 10, 64) if err != nil { s.logger.Error("Failed to parse oddID", "error", err) s.mongoLogger.Warn("Failed to parse oddID", zap.String("oddID", selectedOdd.ID), zap.Error(err)) continue } marketID, err := strconv.ParseInt(market.MarketID, 10, 64) if err != nil { s.logger.Error("Failed to parse marketID", "error", err) s.mongoLogger.Warn("Failed to parse marketID", zap.String("marketID", market.MarketID), zap.Error(err)) continue } marketName := market.MarketName newOdds = append(newOdds, domain.CreateBetOutcome{ EventID: eventIDInt, OddID: oddID, MarketID: marketID, SportID: int64(sportID), HomeTeamName: HomeTeam, AwayTeamName: AwayTeam, MarketName: marketName, Odd: float32(parsedOdd), OddName: selectedOdd.Name, OddHeader: selectedOdd.Header, OddHandicap: selectedOdd.Handicap, Expires: StartTime, }) totalOdds *= float32(parsedOdd) } if len(newOdds) == 0 { s.logger.Error("Bet Outcomes is empty for market", "selectedMarkets", len(selectedMarkets)) s.mongoLogger.Error("Bet Outcomes is empty for market", zap.String("eventID", eventID), zap.Int32("sportID", sportID), zap.String("homeTeam", HomeTeam), zap.String("awayTeam", AwayTeam), zap.Int("selectedMarkets", len(selectedMarkets))) return nil, 0, ErrGenerateRandomOutcome } // ✅ Final success log (optional) s.mongoLogger.Info("Random bet outcomes generated successfully", zap.String("eventID", eventID), zap.Int32("sportID", sportID), zap.Int("numOutcomes", len(newOdds)), zap.Float32("totalOdds", totalOdds)) return newOdds, totalOdds, nil } func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, leagueID domain.ValidInt64, sportID domain.ValidInt32, firstStartTime, lastStartTime domain.ValidTime) (domain.CreateBetRes, error) { // Get a unexpired event id events, _, err := s.eventSvc.GetPaginatedUpcomingEvents(ctx, domain.EventFilter{ SportID: sportID, LeagueID: leagueID, FirstStartTime: firstStartTime, LastStartTime: lastStartTime, }) if err != nil { s.mongoLogger.Error("failed to get paginated upcoming events", zap.Int64("userID", userID), zap.Int64("branchID", branchID), zap.Error(err)) return domain.CreateBetRes{}, err } if len(events) == 0 { s.mongoLogger.Warn("no events available for random bet", zap.Int64("userID", userID), zap.Int64("branchID", branchID)) return domain.CreateBetRes{}, ErrNoEventsAvailable } // TODO: Add the option of passing number of created events var selectedUpcomingEvents []domain.BaseEvent numEventsPerBet := min(random.Intn(4)+1, len(events)) //Eliminate the option of 0 for i := 0; i < int(numEventsPerBet); i++ { randomIndex := random.Intn(len(events)) selectedUpcomingEvents = append(selectedUpcomingEvents, events[randomIndex]) events = append(events[:randomIndex], events[randomIndex+1:]...) } // s.logger.Info("Generating random bet events", "selectedUpcomingEvents", len(selectedUpcomingEvents)) // Get market and odds for that var randomOdds []domain.CreateBetOutcome var totalOdds float32 = 1 numMarketsPerBet := random.Intn(2) + 1 for _, event := range selectedUpcomingEvents { newOdds, total, err := s.GenerateRandomBetOutcomes(ctx, event.ID, event.SportID, event.HomeTeam, event.AwayTeam, event.StartTime, numMarketsPerBet) if err != nil { s.logger.Error("failed to generate random bet outcome", "event id", event.ID, "error", err) s.mongoLogger.Error("failed to generate random bet outcome", zap.Int64("userID", userID), zap.Int64("branchID", branchID), zap.String("eventID", event.ID), zap.String("error", fmt.Sprintf("%v", err))) continue } randomOdds = append(randomOdds, newOdds...) totalOdds = totalOdds * total } if len(randomOdds) == 0 { s.logger.Error("Failed to generate random any outcomes for all events") s.mongoLogger.Error("Failed to generate random any outcomes for all events", zap.Int64("userID", userID), zap.Int64("branchID", branchID)) return domain.CreateBetRes{}, ErrGenerateRandomOutcome } // s.logger.Info("Generated Random bet Outcome", "randomOdds", len(randomOdds)) outcomesHash, err := generateOutcomeHash(randomOdds) if err != nil { s.mongoLogger.Error("failed to generate outcome hash", zap.Int64("user_id", userID), zap.Error(err), ) return domain.CreateBetRes{}, err } count, err := s.GetBetCountByUserID(ctx, userID, outcomesHash) if err != nil { s.mongoLogger.Error("failed to get bet count", zap.Int64("user_id", userID), zap.String("outcome_hash", outcomesHash), zap.Error(err), ) return domain.CreateBetRes{}, err } if count >= 2 { return domain.CreateBetRes{}, fmt.Errorf("bet already placed twice") } fastCode := helpers.GenerateFastCode() newBet := domain.CreateBet{ Amount: domain.ToCurrency(123.5), TotalOdds: totalOdds, Status: domain.OUTCOME_STATUS_PENDING, UserID: userID, IsShopBet: true, FastCode: fastCode, } bet, err := s.CreateBet(ctx, newBet) if err != nil { s.mongoLogger.Error("Failed to create a new random bet", zap.Int64("userID", userID), zap.Int64("branchID", branchID), zap.String("bet", fmt.Sprintf("%+v", newBet))) return domain.CreateBetRes{}, err } for i := range randomOdds { randomOdds[i].BetID = bet.ID } rows, err := s.betStore.CreateBetOutcome(ctx, randomOdds) if err != nil { s.mongoLogger.Error("Failed to create a new random bet outcome", zap.Int64("userID", userID), zap.Int64("branchID", branchID), zap.String("randomOdds", fmt.Sprintf("%+v", randomOdds))) return domain.CreateBetRes{}, err } res := domain.ConvertCreateBet(bet, rows) s.mongoLogger.Info("Random bets placed successfully", zap.Int64("userID", userID), zap.Int64("branchID", branchID), zap.String("response", fmt.Sprintf("%+v", res))) return res, nil } func (s *Service) CreateBet(ctx context.Context, bet domain.CreateBet) (domain.Bet, error) { return s.betStore.CreateBet(ctx, bet) } func (s *Service) CreateBetOutcome(ctx context.Context, outcomes []domain.CreateBetOutcome) (int64, error) { return s.betStore.CreateBetOutcome(ctx, outcomes) } func (s *Service) GetBetByID(ctx context.Context, id int64) (domain.GetBet, error) { return s.betStore.GetBetByID(ctx, id) } func (s *Service) GetAllBets(ctx context.Context, filter domain.BetFilter) ([]domain.GetBet, error) { return s.betStore.GetAllBets(ctx, filter) } func (s *Service) GetBetByUserID(ctx context.Context, UserID int64) ([]domain.GetBet, error) { return s.betStore.GetBetByUserID(ctx, UserID) } func (s *Service) GetBetOutcomeByBetID(ctx context.Context, UserID int64) ([]domain.BetOutcome, error) { return s.betStore.GetBetOutcomeByBetID(ctx, UserID) } func (s *Service) GetBetOutcomeByEventID(ctx context.Context, eventID int64, is_filtered bool) ([]domain.BetOutcome, error) { return s.betStore.GetBetOutcomeByEventID(ctx, eventID, is_filtered) } func (s *Service) GetBetByFastCode(ctx context.Context, fastcode string) (domain.GetBet, error) { return s.betStore.GetBetByFastCode(ctx, fastcode) } func (s *Service) GetBetCountByUserID(ctx context.Context, UserID int64, outcomesHash string) (int64, error) { return s.betStore.GetBetCountByUserID(ctx, UserID, outcomesHash) } func (s *Service) GetBetCountByOutcomesHash(ctx context.Context, outcomesHash string) (int64, error) { return s.betStore.GetBetCountByOutcomesHash(ctx, outcomesHash) } func (s *Service) UpdateCashOut(ctx context.Context, id int64, cashedOut bool) error { return s.betStore.UpdateCashOut(ctx, id, cashedOut) } func (s *Service) UpdateStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error { bet, err := s.GetBetByID(ctx, id) if err != nil { s.mongoLogger.Error("failed to update bet status: invalid bet ID", zap.Int64("bet_id", id), zap.Error(err), ) return err } if status == domain.OUTCOME_STATUS_ERROR || status == domain.OUTCOME_STATUS_PENDING { s.SendAdminErrorAlertNotification(ctx, status, "") s.SendErrorStatusNotification(ctx, status, bet.UserID, "") s.mongoLogger.Error("Bet Status is error", zap.Int64("bet_id", id), zap.Error(err), ) return s.betStore.UpdateStatus(ctx, id, status) } if bet.IsShopBet { return s.betStore.UpdateStatus(ctx, id, status) } customerWallet, err := s.walletSvc.GetCustomerWallet(ctx, id) if err != nil { s.mongoLogger.Error("failed to get customer wallet", zap.Int64("bet_id", id), zap.Error(err), ) return err } var amount domain.Currency switch status { case domain.OUTCOME_STATUS_LOSS: s.SendLosingStatusNotification(ctx, status, bet.UserID, "") return s.betStore.UpdateStatus(ctx, id, status) case domain.OUTCOME_STATUS_WIN: amount = domain.CalculateWinnings(bet.Amount, bet.TotalOdds) s.SendWinningStatusNotification(ctx, status, bet.UserID, amount, "") case domain.OUTCOME_STATUS_HALF: amount = domain.CalculateWinnings(bet.Amount, bet.TotalOdds) / 2 s.SendWinningStatusNotification(ctx, status, bet.UserID, amount, "") case domain.OUTCOME_STATUS_VOID: amount = bet.Amount s.SendWinningStatusNotification(ctx, status, bet.UserID, amount, "") default: return fmt.Errorf("invalid outcome status") } _, err = s.walletSvc.AddToWallet(ctx, customerWallet.RegularID, amount, domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, fmt.Sprintf("Added %v to wallet by system for winning a bet", amount.Float32())) if err != nil { s.mongoLogger.Error("failed to add winnings to wallet", zap.Int64("wallet_id", customerWallet.RegularID), zap.Float32("amount", float32(amount)), zap.Error(err), ) return err } return s.betStore.UpdateStatus(ctx, id, status) } func (s *Service) SendWinningStatusNotification(ctx context.Context, status domain.OutcomeStatus, userID int64, winningAmount domain.Currency, extra string) error { var headline string var message string switch status { case domain.OUTCOME_STATUS_WIN: headline = "You Bet Has Won!" message = fmt.Sprintf( "You have been awarded %.2f", winningAmount.Float32(), ) case domain.OUTCOME_STATUS_HALF: headline = "You have a half win" message = fmt.Sprintf( "You have been awarded %.2f", winningAmount.Float32(), ) case domain.OUTCOME_STATUS_VOID: headline = "Your bet has been refunded" message = fmt.Sprintf( "You have been awarded %.2f", winningAmount.Float32(), ) } betNotification := &domain.Notification{ RecipientID: userID, DeliveryStatus: domain.DeliveryStatusPending, IsRead: false, Type: domain.NOTIFICATION_TYPE_BET_RESULT, Level: domain.NotificationLevelSuccess, Reciever: domain.NotificationRecieverSideCustomer, DeliveryChannel: domain.DeliveryChannelInApp, Payload: domain.NotificationPayload{ Headline: headline, Message: message, }, Priority: 2, Metadata: fmt.Appendf(nil, `{ "winning_amount":%.2f, "status":%v "more": %v }`, winningAmount.Float32(), status, extra), } if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { return err } betNotification.DeliveryChannel = domain.DeliveryChannelEmail if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { return err } return nil } func (s *Service) SendLosingStatusNotification(ctx context.Context, status domain.OutcomeStatus, userID int64, extra string) error { var headline string var message string switch status { case domain.OUTCOME_STATUS_LOSS: headline = "Your bet has lost" message = "Better luck next time" } betNotification := &domain.Notification{ RecipientID: userID, DeliveryStatus: domain.DeliveryStatusPending, IsRead: false, Type: domain.NOTIFICATION_TYPE_BET_RESULT, Level: domain.NotificationLevelSuccess, Reciever: domain.NotificationRecieverSideCustomer, DeliveryChannel: domain.DeliveryChannelInApp, Payload: domain.NotificationPayload{ Headline: headline, Message: message, }, Priority: 2, Metadata: fmt.Appendf(nil, `{ "status":%v "more": %v }`, status, extra), } if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { return err } betNotification.DeliveryChannel = domain.DeliveryChannelEmail if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { return err } return nil } func (s *Service) SendErrorStatusNotification(ctx context.Context, status domain.OutcomeStatus, userID int64, extra string) error { var headline string var message string switch status { case domain.OUTCOME_STATUS_ERROR, domain.OUTCOME_STATUS_PENDING: headline = "There was an error with your bet" message = "We have encounter an error with your bet. We will fix it as soon as we can" } errorSeverityLevel := domain.NotificationErrorSeverityFatal betNotification := &domain.Notification{ RecipientID: userID, DeliveryStatus: domain.DeliveryStatusPending, IsRead: false, Type: domain.NOTIFICATION_TYPE_BET_RESULT, Level: domain.NotificationLevelSuccess, Reciever: domain.NotificationRecieverSideCustomer, DeliveryChannel: domain.DeliveryChannelInApp, Payload: domain.NotificationPayload{ Headline: headline, Message: message, }, Priority: 1, ErrorSeverity: &errorSeverityLevel, Metadata: fmt.Appendf(nil, `{ "status":%v "more": %v }`, status, extra), } if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { return err } betNotification.DeliveryChannel = domain.DeliveryChannelEmail if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { return err } return nil } func (s *Service) SendAdminErrorAlertNotification(ctx context.Context, status domain.OutcomeStatus, extra string) error { var headline string var message string switch status { case domain.OUTCOME_STATUS_ERROR, domain.OUTCOME_STATUS_PENDING: headline = "There was an error processing bet" message = "We have encounter an error with bet. We will fix it as soon as we can" } errorSeverity := domain.NotificationErrorSeverityHigh betNotification := &domain.Notification{ ErrorSeverity: &errorSeverity, DeliveryStatus: domain.DeliveryStatusPending, IsRead: false, Type: domain.NOTIFICATION_TYPE_BET_RESULT, Level: domain.NotificationLevelSuccess, Reciever: domain.NotificationRecieverSideCustomer, DeliveryChannel: domain.DeliveryChannelEmail, Payload: domain.NotificationPayload{ Headline: headline, Message: message, }, Priority: 2, Metadata: fmt.Appendf(nil, `{ "status":%v "more": %v }`, status, extra), } super_admin_users, _, err := s.userSvc.GetAllUsers(ctx, domain.UserFilter{ Role: string(domain.RoleSuperAdmin), }) if err != nil { s.mongoLogger.Error("failed to get super_admin recipients", zap.Error(err), zap.Time("timestamp", time.Now()), ) return err } admin_users, _, err := s.userSvc.GetAllUsers(ctx, domain.UserFilter{ Role: string(domain.RoleAdmin), }) if err != nil { s.mongoLogger.Error("failed to get admin recipients", zap.Error(err), zap.Time("timestamp", time.Now()), ) return err } users := append(super_admin_users, admin_users...) for _, user := range users { betNotification.RecipientID = user.ID if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { s.mongoLogger.Error("failed to send admin notification", zap.Int64("admin_id", user.ID), zap.Error(err), zap.Time("timestamp", time.Now()), ) return err } betNotification.DeliveryChannel = domain.DeliveryChannelEmail if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { s.mongoLogger.Error("failed to send email admin notification", zap.Int64("admin_id", user.ID), zap.Error(err), zap.Time("timestamp", time.Now()), ) return err } } return nil } func (s *Service) CheckBetOutcomeForBet(ctx context.Context, betID int64) (domain.OutcomeStatus, error) { betOutcomes, err := s.betStore.GetBetOutcomeByBetID(ctx, betID) if err != nil { s.mongoLogger.Error("failed to get bet outcomes", zap.Int64("bet_id", betID), zap.Error(err), ) return domain.OUTCOME_STATUS_PENDING, err } status := domain.OUTCOME_STATUS_PENDING for _, betOutcome := range betOutcomes { if betOutcome.Status == domain.OUTCOME_STATUS_PENDING { s.mongoLogger.Info("outcome still pending", zap.Int64("bet_id", betID), ) return domain.OUTCOME_STATUS_PENDING, ErrOutcomesNotCompleted } if betOutcome.Status == domain.OUTCOME_STATUS_ERROR { s.mongoLogger.Info("outcome contains error", zap.Int64("bet_id", betID), ) return domain.OUTCOME_STATUS_ERROR, nil } switch status { case domain.OUTCOME_STATUS_PENDING: status = betOutcome.Status case domain.OUTCOME_STATUS_WIN: switch betOutcome.Status { case domain.OUTCOME_STATUS_LOSS: status = domain.OUTCOME_STATUS_LOSS case domain.OUTCOME_STATUS_HALF: status = domain.OUTCOME_STATUS_HALF case domain.OUTCOME_STATUS_VOID: status = domain.OUTCOME_STATUS_VOID case domain.OUTCOME_STATUS_WIN: // remain win default: status = domain.OUTCOME_STATUS_ERROR } case domain.OUTCOME_STATUS_LOSS: // stay as LOSS regardless of others case domain.OUTCOME_STATUS_VOID: switch betOutcome.Status { case domain.OUTCOME_STATUS_LOSS: status = domain.OUTCOME_STATUS_LOSS case domain.OUTCOME_STATUS_WIN, domain.OUTCOME_STATUS_HALF, domain.OUTCOME_STATUS_VOID: // remain VOID default: status = domain.OUTCOME_STATUS_ERROR } case domain.OUTCOME_STATUS_HALF: switch betOutcome.Status { case domain.OUTCOME_STATUS_LOSS: status = domain.OUTCOME_STATUS_LOSS case domain.OUTCOME_STATUS_VOID: status = domain.OUTCOME_STATUS_VOID case domain.OUTCOME_STATUS_HALF, domain.OUTCOME_STATUS_WIN: // remain HALF default: status = domain.OUTCOME_STATUS_ERROR } default: status = domain.OUTCOME_STATUS_ERROR } } if status == domain.OUTCOME_STATUS_PENDING || status == domain.OUTCOME_STATUS_ERROR { s.mongoLogger.Info("bet status not updated due to status", zap.Int64("bet_id", betID), zap.String("final_status", string(status)), ) } return status, nil } func (s *Service) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error) { betOutcome, err := s.betStore.UpdateBetOutcomeStatus(ctx, id, status) if err != nil { s.mongoLogger.Error("failed to update bet outcome status", zap.Int64("betID", id), zap.Error(err), ) return domain.BetOutcome{}, err } return betOutcome, err } func (s *Service) UpdateBetOutcomeStatusForEvent(ctx context.Context, eventID int64, status domain.OutcomeStatus) ([]domain.BetOutcome, error) { outcomes, err := s.betStore.UpdateBetOutcomeStatusForEvent(ctx, eventID, status) if err != nil { s.mongoLogger.Error("failed to update bet outcome status", zap.Int64("eventID", eventID), zap.Error(err), ) return nil, err } return outcomes, nil } func (s *Service) SetBetToRemoved(ctx context.Context, id int64) error { _, err := s.betStore.UpdateBetOutcomeStatusByBetID(ctx, id, domain.OUTCOME_STATUS_VOID) if err != nil { s.mongoLogger.Error("failed to update bet outcome to void", zap.Int64("id", id), zap.Error(err), ) return err } err = s.betStore.UpdateStatus(ctx, id, domain.OUTCOME_STATUS_VOID) if err != nil { s.mongoLogger.Error("failed to update bet to void", zap.Int64("id", id), zap.Error(err), ) return err } return nil } func (s *Service) ProcessBetCashback(ctx context.Context) error { settingsList, err := s.settingSvc.GetSettingList(ctx) bets, err := s.betStore.GetBetsForCashback(ctx) if err != nil { s.mongoLogger.Error("failed to fetch bets", zap.Error(err), ) return err } for _, bet := range bets { shouldProcess := true loseCount := 0 for _, outcome := range bet.Outcomes { // stop if other outcomes exists in bet outcomes if outcome.Status != domain.OUTCOME_STATUS_LOSS && outcome.Status != domain.OUTCOME_STATUS_WIN { shouldProcess = false break } if outcome.Status == domain.OUTCOME_STATUS_LOSS { loseCount++ // only process caseback if bet is lost by one if loseCount > 1 { break } } } if !shouldProcess || loseCount != 1 { continue } if err := s.betStore.UpdateBetWithCashback(ctx, bet.ID, true); err != nil { s.mongoLogger.Error("failed to process cashback for bet", zap.Int64("betID", bet.ID), zap.Error(err), ) continue } wallets, err := s.walletSvc.GetCustomerWallet(ctx, bet.UserID) if err != nil { s.mongoLogger.Error("failed to get wallets of a user", zap.Int64("userID", bet.UserID), zap.Error(err), ) continue } cashbackAmount := math.Min(float64(settingsList.CashbackAmountCap.Float32()), float64(calculateCashbackAmount(bet.Amount.Float32(), bet.TotalOdds))) _, err = s.walletSvc.AddToWallet(ctx, wallets.StaticID, domain.ToCurrency(float32(cashbackAmount)), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, fmt.Sprintf("cashback amount of %f added to users static wallet", cashbackAmount)) if err != nil { s.mongoLogger.Error("Failed to update wallet for user", zap.Int64("userID", bet.UserID), zap.Error(err)) } } return nil } func generateOutcomeHash(outcomes []domain.CreateBetOutcome) (string, error) { // should always be in the same order for producing the same hash sort.Slice(outcomes, func(i, j int) bool { if outcomes[i].EventID != outcomes[j].EventID { return outcomes[i].EventID < outcomes[j].EventID } if outcomes[i].MarketID != outcomes[j].MarketID { return outcomes[i].MarketID < outcomes[j].MarketID } return outcomes[i].OddID < outcomes[j].OddID }) var sb strings.Builder for _, o := range outcomes { sb.WriteString(fmt.Sprintf("%d-%d-%d;", o.EventID, o.MarketID, o.OddID)) } sum := sha256.Sum256([]byte(sb.String())) return hex.EncodeToString(sum[:]), nil } func calculateCashbackAmount(amount, total_odds float32) float32 { var multiplier float32 if total_odds < 18 { multiplier = 0 } else if total_odds >= 18 && total_odds <= 35 { multiplier = 1 } else if total_odds > 35 && total_odds <= 55 { multiplier = 2 } else if total_odds > 55 && total_odds <= 95 { multiplier = 3 } else if total_odds > 95 && total_odds <= 250 { multiplier = 5 } else if total_odds > 250 && total_odds <= 450 { multiplier = 10 } else if total_odds > 450 && total_odds <= 1000 { multiplier = 50 } else if total_odds > 1000 && total_odds <= 2000 { multiplier = 100 } else { multiplier = 500 } return amount * multiplier } func calculateAccumulator(outcomesCount int) float32 { switch outcomesCount { case 3: return 0.05 case 4: return 0.08 case 5: return 0.09 case 6: return 0.10 case 7: return 0.15 case 8: return 0.20 case 9: return 0.25 case 10: return 0.30 case 11: return 0.35 case 12: return 0.40 case 13: return 0.45 case 14: return 0.50 case 15: return 0.55 case 16: return 0.60 case 17: return 0.65 case 18: return 0.70 case 19: return 0.75 case 20: return 0.80 case 21: return 0.85 case 22: return 0.90 case 23: return 0.95 case 24: return 1.00 case 25: return 1.10 case 26: return 1.30 case 27: return 1.50 case 28: return 1.70 case 29: return 2.00 case 30: return 2.10 case 31: return 2.30 case 32: return 2.50 case 33: return 2.70 case 34: return 2.90 case 35: return 3.10 case 36: return 3.20 case 37: return 3.40 case 38: return 5.00 case 39: return 6.00 case 40: return 10.00 default: return 0 } } func (s *Service) CheckIfBetError(err error) bool { betErrors := []error{ ErrNoEventsAvailable, ErrGenerateRandomOutcome, ErrOutcomesNotCompleted, ErrEventHasBeenRemoved, ErrEventHasNotEnded, ErrRawOddInvalid, ErrBranchIDRequired, ErrOutcomeLimit, ErrTotalBalanceNotEnough, ErrInvalidAmount, ErrBetAmountTooHigh, ErrBetWinningTooHigh, } for _, e := range betErrors { if errors.Is(err, e) { return true } } return false }