package bet import ( "context" "crypto/rand" "encoding/json" "errors" "fmt" "log/slog" "math/big" random "math/rand" "strconv" "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" ) 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") ) type Service struct { betStore BetStore eventSvc event.Service prematchSvc odds.Service walletSvc wallet.Service branchSvc branch.Service logger *slog.Logger } func NewService(betStore BetStore, eventSvc event.Service, prematchSvc odds.Service, walletSvc wallet.Service, branchSvc branch.Service, logger *slog.Logger) *Service { return &Service{ betStore: betStore, eventSvc: eventSvc, prematchSvc: prematchSvc, walletSvc: walletSvc, branchSvc: branchSvc, logger: logger, } } var ( 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") ) 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) (domain.CreateBetOutcome, error) { // TODO: Change this when you refactor the database code 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 { return domain.CreateBetOutcome{}, ErrEventHasBeenRemoved } currentTime := time.Now() if event.StartTime.Before(currentTime) { return domain.CreateBetOutcome{}, ErrEventHasNotEnded } odds, err := s.prematchSvc.GetRawOddsByMarketID(ctx, marketIDStr, eventIDStr) if err != nil { return domain.CreateBetOutcome{}, err } type rawOddType struct { ID string Name string Odds string Header string Handicap string } var selectedOdd rawOddType var isOddFound bool = false for _, raw := range odds.RawOdds { var rawOdd rawOddType rawBytes, err := json.Marshal(raw) err = json.Unmarshal(rawBytes, &rawOdd) if err != nil { fmt.Printf("Failed to unmarshal raw odd %v", err) continue } if rawOdd.ID == oddIDStr { selectedOdd = rawOdd isOddFound = true } } if !isOddFound { return domain.CreateBetOutcome{}, ErrRawOddInvalid } parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) if err != nil { 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) (domain.CreateBetRes, error) { // You can move the loop over req.Outcomes and all the business logic here. if len(req.Outcomes) > 30 { 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 { return domain.CreateBetRes{}, err } totalOdds = totalOdds * float32(newOutcome.Odd) outcomes = append(outcomes, newOutcome) } // Handle role-specific logic and wallet deduction if needed. var cashoutID string cashoutID, err := s.GenerateCashoutID() if err != nil { return domain.CreateBetRes{}, err } newBet := domain.CreateBet{ Amount: domain.ToCurrency(req.Amount), TotalOdds: totalOdds, Status: req.Status, FullName: req.FullName, PhoneNumber: req.PhoneNumber, CashoutID: cashoutID, } switch role { case domain.RoleCashier: branch, err := s.branchSvc.GetBranchByCashier(ctx, userID) if err != nil { return domain.CreateBetRes{}, err } // Deduct from wallet: // TODO: Make this percentage come from the company var deductedAmount = req.Amount / 10 err = s.walletSvc.DeductFromWallet(ctx, branch.WalletID, domain.ToCurrency(deductedAmount)) if err != nil { return domain.CreateBetRes{}, err } newBet.BranchID = domain.ValidInt64{ Value: branch.ID, Valid: true, } newBet.CompanyID = domain.ValidInt64{ Value: branch.CompanyID, Valid: true, } newBet.UserID = domain.ValidInt64{ Value: userID, Valid: true, } newBet.IsShopBet = true // bet, err = s.betStore.CreateBet(ctx) case domain.RoleBranchManager, domain.RoleAdmin, domain.RoleSuperAdmin: // TODO: restrict the Branch ID of Admin and Branch Manager to only the branches within their own company // If a non cashier wants to create a bet, they will need to provide the Branch ID if req.BranchID == nil { return domain.CreateBetRes{}, ErrBranchIDRequired } branch, err := s.branchSvc.GetBranchByID(ctx, *req.BranchID) if err != nil { return domain.CreateBetRes{}, err } // Deduct from wallet: // TODO: Make this percentage come from the company var deductedAmount = req.Amount / 10 err = s.walletSvc.DeductFromWallet(ctx, branch.WalletID, domain.ToCurrency(deductedAmount)) if err != nil { return domain.CreateBetRes{}, err } newBet.BranchID = domain.ValidInt64{ Value: branch.ID, Valid: true, } newBet.CompanyID = domain.ValidInt64{ Value: branch.CompanyID, Valid: true, } newBet.UserID = domain.ValidInt64{ Value: userID, Valid: true, } newBet.IsShopBet = true case domain.RoleCustomer: // Get User Wallet wallet, err := s.walletSvc.GetWalletsByUser(ctx, userID) if err != nil { return domain.CreateBetRes{}, err } userWallet := wallet[0] err = s.walletSvc.DeductFromWallet(ctx, userWallet.ID, domain.ToCurrency(req.Amount)) if err != nil { return domain.CreateBetRes{}, err } newBet.UserID = domain.ValidInt64{ Value: userID, Valid: true, } newBet.IsShopBet = false default: return domain.CreateBetRes{}, fmt.Errorf("Unknown Role Type") } bet, err := s.CreateBet(ctx, newBet) if err != nil { return domain.CreateBetRes{}, err } // Associate outcomes with the bet. for i := range outcomes { outcomes[i].BetID = bet.ID } rows, err := s.betStore.CreateBetOutcome(ctx, outcomes) if err != nil { return domain.CreateBetRes{}, err } res := domain.ConvertCreateBet(bet, rows) return res, 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.GetPrematchOddsByUpcomingID(ctx, eventID) if err != nil { s.logger.Error("failed to get odds for event", "event id", eventID, "error", err) return nil, 0, err } if len(markets) == 0 { s.logger.Error("empty odds for event", "event id", eventID) return nil, 0, fmt.Errorf("empty odds or event %v", eventID) } var selectedMarkets []domain.Odd 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 { fmt.Printf("Failed to unmarshal raw odd %v", err) continue } parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) if err != nil { s.logger.Error("Failed to parse odd", "error", err) continue } eventID, err := strconv.ParseInt(eventID, 10, 64) if err != nil { s.logger.Error("Failed to get event id", "error", err) continue } oddID, err := strconv.ParseInt(selectedOdd.ID, 10, 64) if err != nil { s.logger.Error("Failed to get odd id", "error", err) continue } marketID, err := strconv.ParseInt(market.MarketID, 10, 64) if err != nil { s.logger.Error("Failed to get odd id", "error", err) continue } marketName := market.MarketName newOdds = append(newOdds, domain.CreateBetOutcome{ EventID: eventID, 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 = totalOdds * float32(parsedOdd) } if len(newOdds) == 0 { s.logger.Error("Bet Outcomes is empty for market", "selectedMarket", selectedMarkets[0].MarketName) return nil, 0, ErrGenerateRandomOutcome } return newOdds, totalOdds, nil } func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, leagueID, sportID domain.ValidInt32, firstStartTime, lastStartTime domain.ValidTime) (domain.CreateBetRes, error) { // Get a unexpired event id events, _, err := s.eventSvc.GetPaginatedUpcomingEvents(ctx, domain.ValidInt64{}, domain.ValidInt64{}, leagueID, sportID, firstStartTime, lastStartTime) if err != nil { return domain.CreateBetRes{}, err } if len(events) == 0 { return domain.CreateBetRes{}, ErrNoEventsAvailable } // TODO: Add the option of passing number of created events var selectedUpcomingEvents []domain.UpcomingEvent 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) continue } randomOdds = append(randomOdds, newOdds...) totalOdds = totalOdds * total } if len(randomOdds) == 0 { s.logger.Error("Failed to generate random any outcomes for all events") return domain.CreateBetRes{}, ErrGenerateRandomOutcome } // s.logger.Info("Generated Random bet Outcome", "randomOdds", len(randomOdds)) var cashoutID string cashoutID, err = s.GenerateCashoutID() if err != nil { return domain.CreateBetRes{}, err } randomNumber := strconv.FormatInt(int64(random.Intn(100000000000)), 10) newBet := domain.CreateBet{ Amount: domain.ToCurrency(123.5), TotalOdds: totalOdds, Status: domain.OUTCOME_STATUS_PENDING, FullName: "test" + randomNumber, PhoneNumber: "0900000000", CashoutID: cashoutID, BranchID: domain.ValidInt64{Valid: true, Value: branchID}, UserID: domain.ValidInt64{Valid: true, Value: userID}, } bet, err := s.CreateBet(ctx, newBet) if err != nil { return domain.CreateBetRes{}, err } for i := range randomOdds { randomOdds[i].BetID = bet.ID } rows, err := s.betStore.CreateBetOutcome(ctx, randomOdds) if err != nil { return domain.CreateBetRes{}, err } res := domain.ConvertCreateBet(bet, rows) 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) GetBetByCashoutID(ctx context.Context, id string) (domain.GetBet, error) { return s.betStore.GetBetByCashoutID(ctx, id) } func (s *Service) GetAllBets(ctx context.Context, filter domain.BetFilter) ([]domain.GetBet, error) { return s.betStore.GetAllBets(ctx, filter) } func (s *Service) GetBetByBranchID(ctx context.Context, branchID int64) ([]domain.GetBet, error) { return s.betStore.GetBetByBranchID(ctx, branchID) } func (s *Service) GetBetByUserID(ctx context.Context, UserID int64) ([]domain.GetBet, error) { return s.betStore.GetBetByUserID(ctx, UserID) } 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.logger.Error("Failed to update bet status. Invalid bet id") return err } if bet.IsShopBet || status == domain.OUTCOME_STATUS_ERROR || status == domain.OUTCOME_STATUS_PENDING || status == domain.OUTCOME_STATUS_LOSS { return s.betStore.UpdateStatus(ctx, id, status) } customerWallet, err := s.walletSvc.GetCustomerWallet(ctx, id) if err != nil { s.logger.Error("Failed to update bet status. Invalid customer wallet id") return err } var amount domain.Currency if status == domain.OUTCOME_STATUS_WIN { amount = domain.CalculateWinnings(bet.Amount, bet.TotalOdds) } else if status == domain.OUTCOME_STATUS_HALF { amount = (domain.CalculateWinnings(bet.Amount, bet.TotalOdds)) / 2 } else { amount = bet.Amount } err = s.walletSvc.AddToWallet(ctx, customerWallet.RegularID, amount) if err != nil { s.logger.Error("Failed to update bet status. Failed to update user wallet") return err } return s.betStore.UpdateStatus(ctx, id, status) } func (s *Service) CheckBetOutcomeForBet(ctx context.Context, betID int64) (domain.OutcomeStatus, error) { betOutcomes, err := s.betStore.GetBetOutcomeByBetID(ctx, betID) if err != nil { return domain.OUTCOME_STATUS_PENDING, err } status := domain.OUTCOME_STATUS_PENDING for _, betOutcome := range betOutcomes { // If any of the bet outcomes are pending return if betOutcome.Status == domain.OUTCOME_STATUS_PENDING { return domain.OUTCOME_STATUS_PENDING, ErrOutcomesNotCompleted } if betOutcome.Status == domain.OUTCOME_STATUS_ERROR { return domain.OUTCOME_STATUS_ERROR, nil } // The bet status can only be updated if its not lost or error // If all the bet outcomes are a win, then set the bet status to win // If even one of the bet outcomes is a loss then set the bet status to loss // If even one of the bet outcomes is an error, then set the bet status to error switch status { case domain.OUTCOME_STATUS_PENDING: status = betOutcome.Status case domain.OUTCOME_STATUS_WIN: if betOutcome.Status == domain.OUTCOME_STATUS_LOSS { status = domain.OUTCOME_STATUS_LOSS } else if betOutcome.Status == domain.OUTCOME_STATUS_HALF { status = domain.OUTCOME_STATUS_HALF } else if betOutcome.Status == domain.OUTCOME_STATUS_WIN { status = domain.OUTCOME_STATUS_WIN } else if betOutcome.Status == domain.OUTCOME_STATUS_VOID { status = domain.OUTCOME_STATUS_VOID } else { status = domain.OUTCOME_STATUS_ERROR } case domain.OUTCOME_STATUS_LOSS: if betOutcome.Status == domain.OUTCOME_STATUS_LOSS { status = domain.OUTCOME_STATUS_LOSS } else if betOutcome.Status == domain.OUTCOME_STATUS_HALF { status = domain.OUTCOME_STATUS_LOSS } else if betOutcome.Status == domain.OUTCOME_STATUS_WIN { status = domain.OUTCOME_STATUS_LOSS } else if betOutcome.Status == domain.OUTCOME_STATUS_VOID { status = domain.OUTCOME_STATUS_LOSS } else { status = domain.OUTCOME_STATUS_ERROR } case domain.OUTCOME_STATUS_VOID: if betOutcome.Status == domain.OUTCOME_STATUS_VOID || betOutcome.Status == domain.OUTCOME_STATUS_WIN || betOutcome.Status == domain.OUTCOME_STATUS_HALF { status = domain.OUTCOME_STATUS_VOID } else if betOutcome.Status == domain.OUTCOME_STATUS_LOSS { status = domain.OUTCOME_STATUS_LOSS } else { status = domain.OUTCOME_STATUS_ERROR } case domain.OUTCOME_STATUS_HALF: if betOutcome.Status == domain.OUTCOME_STATUS_HALF || betOutcome.Status == domain.OUTCOME_STATUS_WIN { status = domain.OUTCOME_STATUS_HALF } else if betOutcome.Status == domain.OUTCOME_STATUS_LOSS { status = domain.OUTCOME_STATUS_LOSS } else if betOutcome.Status == domain.OUTCOME_STATUS_VOID { status = domain.OUTCOME_STATUS_VOID } else { status = domain.OUTCOME_STATUS_ERROR } default: // If the status is not pending, win, loss or error, then set the status to error status = domain.OUTCOME_STATUS_ERROR } } if status == domain.OUTCOME_STATUS_PENDING || status == domain.OUTCOME_STATUS_ERROR { // If the status is pending or error, then we don't need to update the bet s.logger.Info("bet not updated", "bet id", betID, "status", status) return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("Error when processing bet outcomes") } 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 { return domain.BetOutcome{}, err } return betOutcome, err } func (s *Service) DeleteBet(ctx context.Context, id int64) error { return s.betStore.DeleteBet(ctx, id) }