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") ErrEventHasBeenDisabled = errors.New("event has been disabled") ErrEventHasNotEnded = errors.New("event has not ended yet") ErrOddHasBeenDisabled = errors.New("odd has been disabled") 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") ErrTooManyUnsettled = errors.New("too many unsettled bets") 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") ErrCompanyDeductedPercentInvalid = errors.New("invalid company deducted percentage") ) 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 { return "", err } result[i] = chars[index.Int64()] } return string(result), nil } func (s *Service) GenerateBetOutcome(ctx context.Context, eventID int64, marketID int64, oddID int64, companyID int64) (domain.CreateBetOutcome, error) { oddIDStr := strconv.FormatInt(oddID, 10) event, err := s.eventSvc.GetEventWithSettingByID(ctx, eventID, companyID) 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 } if !event.IsActive { s.mongoLogger.Warn("attempting to create bet with disabled event", zap.Int64("event_id", eventID), zap.Error(err), ) return domain.CreateBetOutcome{}, ErrEventHasBeenDisabled } 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.GetOddsWithSettingsByMarketID(ctx, marketID, eventID, companyID) 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 } if !odds.IsActive { 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{}, ErrOddHasBeenDisabled } 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 int64) (domain.CreateBetRes, error) { settingsList, err := s.settingSvc.GetOverrideSettingsList(ctx, companyID) if err != nil { return domain.CreateBetRes{}, err } _, totalUnsettledBets, err := s.GetAllBets(ctx, domain.BetFilter{ Status: domain.ValidOutcomeStatus{ Value: domain.OUTCOME_STATUS_ERROR, Valid: true, }, }) if err != nil { return domain.CreateBetRes{}, err } if totalUnsettledBets > settingsList.MaxUnsettledBets { s.mongoLogger.Error("System block bet creation until unsettled bets fixed", zap.Int64("total_unsettled_bets", totalUnsettledBets), zap.Int64("user_id", userID), ) return domain.CreateBetRes{}, ErrTooManyUnsettled } if req.Amount < settingsList.MinimumBetAmount.Float32() { 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, companyID) 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 role == domain.RoleCustomer && count >= settingsList.BetDuplicateLimit { return domain.CreateBetRes{}, fmt.Errorf("max user limit for duplicate bet") } fastCode := helpers.GenerateFastCode() // accumulator := calculateAccumulator(len(outcomes)) // amount := req.Amount + (req.Amount * accumulator) amount := req.Amount newBet := domain.CreateBet{ Amount: domain.ToCurrency(amount), TotalOdds: totalOdds, Status: domain.OUTCOME_STATUS_PENDING, OutcomesHash: outcomesHash, FastCode: fastCode, UserID: userID, CompanyID: companyID, } 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 } // For 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 role == domain.RoleBranchManager { 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 branch.CompanyID != companyID { s.mongoLogger.Warn("unauthorized company", zap.Int64("branch_id", *req.BranchID), zap.Int64("branch_company_id", branch.CompanyID), zap.Int64("company_id", companyID), 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.Warn("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), ) } } if totalWinnings > settingsList.TotalWinningNotify.Float32() { err = s.SendAdminLargeBetNotification(ctx, bet.ID, totalWinnings, "", companyID) if err != nil { s.mongoLogger.Error("Failed to send large bet notification", zap.Int64("betID", bet.ID), zap.Int64("companyID", companyID), zap.Float32("totalWinnings", totalWinnings)) } } res := domain.ConvertCreateBetRes(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 } if company.DeductedPercentage > 1 { s.mongoLogger.Error("Invalid company deducted percentage", zap.Int64("wallet_id", walletID), zap.Float32("amount", company.DeductedPercentage), zap.Error(err), ) return ErrCompanyDeductedPercentInvalid } // This is the amount that we take from a company/tenant when they // create a bet. I.e. if its 5% (0.05), then thats the percentage we take every deductedAmount := amount * company.DeductedPercentage if deductedAmount == 0 { s.mongoLogger.Fatal("Amount", zap.Int64("wallet_id", walletID), zap.Float32("amount", deductedAmount), zap.Error(err), ) return err } _, 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.ToCurrency(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 int64, sportID int32, HomeTeam, AwayTeam string, StartTime time.Time, numMarkets int) ([]domain.CreateBetOutcome, float32, error) { var newOdds []domain.CreateBetOutcome var totalOdds float32 = 1 eventLogger := s.mongoLogger.With( zap.Int64("eventID", eventID), zap.Int32("sportID", sportID), zap.String("homeTeam", HomeTeam), zap.String("awayTeam", AwayTeam), ) markets, err := s.prematchSvc.GetOddsByEventID(ctx, eventID, domain.OddMarketWithEventFilter{}) if err != nil { eventLogger.Error("failed to get odds for event", zap.Error(err)) return nil, 0, err } if len(markets) == 0 { eventLogger.Warn("empty odds for event") 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 { eventLogger.Warn("Failed to unmarshal raw odd", zap.Error(err)) continue } parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) if err != nil { eventLogger.Warn("Failed to parse odd", zap.String("oddValue", selectedOdd.Odds), zap.Error(err)) continue } oddID, err := strconv.ParseInt(selectedOdd.ID, 10, 64) if err != nil { eventLogger.Warn("Failed to parse oddID", zap.String("oddID", selectedOdd.ID), zap.Error(err)) continue } marketName := market.MarketName newOdds = append(newOdds, domain.CreateBetOutcome{ EventID: eventID, OddID: oddID, MarketID: market.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 { eventLogger.Error("Bet Outcomes is empty for market", zap.Int("selectedMarkets", len(selectedMarkets))) return nil, 0, ErrGenerateRandomOutcome } // ✅ Final success log (optional) eventLogger.Info("Random bet outcomes generated successfully", zap.Int("numOutcomes", len(newOdds)), zap.Float32("totalOdds", totalOdds)) return newOdds, totalOdds, nil } func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID, companyID int64, leagueID domain.ValidInt64, sportID domain.ValidInt32, firstStartTime, lastStartTime domain.ValidTime) (domain.CreateBetRes, error) { // Get a unexpired event id randomBetLogger := s.mongoLogger.With( zap.Int64("userID", userID), zap.Int64("branchID", branchID), zap.Int64("companyID", companyID), zap.Any("leagueID", leagueID), zap.Any("sportID", sportID), zap.Any("firstStartTime", firstStartTime), zap.Any("lastStartTime", lastStartTime), ) events, _, err := s.eventSvc.GetAllEvents(ctx, domain.EventFilter{ SportID: sportID, LeagueID: leagueID, FirstStartTime: firstStartTime, LastStartTime: lastStartTime, Status: domain.ValidEventStatus{ Value: domain.STATUS_PENDING, }, }) if err != nil { randomBetLogger.Error("failed to get paginated upcoming events", zap.Error(err)) return domain.CreateBetRes{}, err } if len(events) == 0 { randomBetLogger.Warn("no events available for random bet") 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.mongoLogger.Error("failed to generate random bet outcome", zap.Int64("eventID", event.ID), zap.Error(err)) continue } randomOdds = append(randomOdds, newOdds...) totalOdds = totalOdds * total } if len(randomOdds) == 0 { randomBetLogger.Error("Failed to generate random any outcomes for all events") return domain.CreateBetRes{}, ErrGenerateRandomOutcome } // s.logger.Info("Generated Random bet Outcome", "randomOdds", len(randomOdds)) outcomesHash, err := generateOutcomeHash(randomOdds) if err != nil { randomBetLogger.Error("failed to generate outcome hash", zap.Error(err)) return domain.CreateBetRes{}, err } count, err := s.GetBetCountByUserID(ctx, userID, outcomesHash) if err != nil { randomBetLogger.Error("failed to get bet count", 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, CompanyID: companyID, IsShopBet: true, FastCode: fastCode, } bet, err := s.CreateBet(ctx, newBet) if err != nil { randomBetLogger.Error("Failed to create a new random bet", zap.Error(err)) return domain.CreateBetRes{}, err } for i := range randomOdds { randomOdds[i].BetID = bet.ID } rows, err := s.betStore.CreateBetOutcome(ctx, randomOdds) if err != nil { randomBetLogger.Error("Failed to create a new random bet outcome", zap.Any("randomOdds", randomOdds)) return domain.CreateBetRes{}, err } res := domain.ConvertCreateBetRes(bet, rows) randomBetLogger.Info("Random bets placed successfully") 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, int64, 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) GetBetOutcomeViewByEventID(ctx context.Context, eventID int64, filter domain.BetOutcomeViewFilter) ([]domain.BetOutcomeViewRes, int64, error) { return s.betStore.GetBetOutcomeViewByEventID(ctx, eventID, filter) } 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, betId int64, status domain.OutcomeStatus) error { updateLogger := s.mongoLogger.With( zap.Int64("bet_id", betId), zap.String("status", status.String()), ) bet, err := s.GetBetByID(ctx, betId) if err != nil { updateLogger.Error("failed to update bet status: invalid bet ID", zap.Error(err)) return err } settingsList, err := s.settingSvc.GetOverrideSettingsList(ctx, bet.CompanyID) if err != nil { updateLogger.Error("failed to get settings", zap.Error(err)) return err } if status == domain.OUTCOME_STATUS_ERROR || status == domain.OUTCOME_STATUS_PENDING { if err := s.SendAdminErrorNotification(ctx, betId, status, "", bet.CompanyID); err != nil { updateLogger.Error("failed to send admin notification", zap.Error(err)) return err } if err := s.SendErrorStatusNotification(ctx, betId, status, bet.UserID, ""); err != nil { updateLogger.Error("failed to send error notification to user", zap.Error(err)) return err } updateLogger.Error("bet entered error/pending state") return s.betStore.UpdateStatus(ctx, betId, status) } if bet.IsShopBet { return s.betStore.UpdateStatus(ctx, betId, status) } // After this point the bet is known to be a online customer bet customerWallet, err := s.walletSvc.GetCustomerWallet(ctx, bet.UserID) if err != nil { updateLogger.Error("failed to get customer wallet", zap.Error(err)) return err } resultNotification := SendResultNotificationParam{ BetID: betId, Status: status, UserID: bet.UserID, SendEmail: settingsList.SendEmailOnBetFinish, SendSMS: settingsList.SendSMSOnBetFinish, } var amount domain.Currency switch status { case domain.OUTCOME_STATUS_LOSS: err := s.SendLosingStatusNotification(ctx, resultNotification) if err != nil { updateLogger.Error("failed to send notification", zap.Error(err)) return err } return s.betStore.UpdateStatus(ctx, betId, status) case domain.OUTCOME_STATUS_WIN: amount = domain.CalculateWinnings(bet.Amount, bet.TotalOdds) case domain.OUTCOME_STATUS_HALF: amount = domain.CalculateWinnings(bet.Amount, bet.TotalOdds) / 2 case domain.OUTCOME_STATUS_VOID: amount = bet.Amount default: updateLogger.Error("invalid outcome status") 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 { updateLogger.Error("failed to add winnings to wallet", zap.Int64("wallet_id", customerWallet.RegularID), zap.Float32("amount", float32(amount)), zap.Error(err), ) return err } if err := s.betStore.UpdateStatus(ctx, betId, status); err != nil { updateLogger.Error("failed to update bet status", zap.String("status", status.String()), zap.Error(err), ) return err } resultNotification.WinningAmount = amount if err := s.SendWinningStatusNotification(ctx, resultNotification); err != nil { updateLogger.Error("failed to send winning notification", zap.Error(err), ) 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) UpdateBetOutcomeStatusForOddId(ctx context.Context, oddID int64, status domain.OutcomeStatus) ([]domain.BetOutcome, error) { outcomes, err := s.betStore.UpdateBetOutcomeStatusForOddId(ctx, oddID, status) if err != nil { s.mongoLogger.Error("failed to update bet outcome status", zap.Int64("oddID", oddID), zap.Error(err), ) return nil, err } return outcomes, nil } func (s *Service) BulkUpdateBetOutcomeStatusForOddIds(ctx context.Context, oddID []int64, status domain.OutcomeStatus) error { err := s.betStore.BulkUpdateBetOutcomeStatusForOddIds(ctx, oddID, status) if err != nil { s.mongoLogger.Error("failed to update bet outcome status by oddIds", zap.Int64s("oddID", oddID), zap.Error(err), ) return err } return 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 { 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 } settingsList, err := s.settingSvc.GetOverrideSettingsList(ctx, bet.CompanyID) if err != nil { s.mongoLogger.Error("Failed to get settings", zap.Int64("userID", bet.UserID), zap.Error(err)) return err } 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 }