diff --git a/cmd/main.go b/cmd/main.go index d9b1089..364cb70 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -70,14 +70,13 @@ func main() { eventSvc := event.New(cfg.Bet365Token, store) oddsSvc := odds.New(store, cfg, logger) - resultSvc := result.NewService(store, cfg, logger) ticketSvc := ticket.NewService(store) - betSvc := bet.NewService(store) walletSvc := wallet.NewService(store, store) transactionSvc := transaction.NewService(store) branchSvc := branch.NewService(store) companySvc := company.NewService(store) - + betSvc := bet.NewService(store, eventSvc, oddsSvc, *walletSvc, *branchSvc, logger) + resultSvc := result.NewService(store, cfg, logger, *betSvc) notificationRepo := repository.NewNotificationRepository(store) referalRepo := repository.NewReferralRepository(store) vitualGameRepo := repository.NewVirtualGameRepository(store) diff --git a/db/query/bet.sql b/db/query/bet.sql index 42db5a7..aed3aa4 100644 --- a/db/query/bet.sql +++ b/db/query/bet.sql @@ -62,16 +62,16 @@ WHERE branch_id = $1; SELECT * FROM bet_outcomes WHERE event_id = $1; - -- name: UpdateCashOut :exec UPDATE bets SET cashed_out = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1; --- name: UpdateBetOutcomeStatus :exec +-- name: UpdateBetOutcomeStatus :one UPDATE bet_outcomes SET status = $1 -WHERE id = $2; +WHERE id = $2 +RETURNING *; -- name: UpdateStatus :exec UPDATE bets SET status = $2, diff --git a/gen/db/bet.sql.go b/gen/db/bet.sql.go index e236690..0f10df6 100644 --- a/gen/db/bet.sql.go +++ b/gen/db/bet.sql.go @@ -285,10 +285,11 @@ func (q *Queries) GetBetOutcomeByEventID(ctx context.Context, eventID int64) ([] return items, nil } -const UpdateBetOutcomeStatus = `-- name: UpdateBetOutcomeStatus :exec +const UpdateBetOutcomeStatus = `-- name: UpdateBetOutcomeStatus :one UPDATE bet_outcomes SET status = $1 WHERE id = $2 +RETURNING id, bet_id, sport_id, event_id, odd_id, home_team_name, away_team_name, market_id, market_name, odd, odd_name, odd_header, odd_handicap, status, expires ` type UpdateBetOutcomeStatusParams struct { @@ -296,9 +297,27 @@ type UpdateBetOutcomeStatusParams struct { ID int64 `json:"id"` } -func (q *Queries) UpdateBetOutcomeStatus(ctx context.Context, arg UpdateBetOutcomeStatusParams) error { - _, err := q.db.Exec(ctx, UpdateBetOutcomeStatus, arg.Status, arg.ID) - return err +func (q *Queries) UpdateBetOutcomeStatus(ctx context.Context, arg UpdateBetOutcomeStatusParams) (BetOutcome, error) { + row := q.db.QueryRow(ctx, UpdateBetOutcomeStatus, arg.Status, arg.ID) + var i BetOutcome + err := row.Scan( + &i.ID, + &i.BetID, + &i.SportID, + &i.EventID, + &i.OddID, + &i.HomeTeamName, + &i.AwayTeamName, + &i.MarketID, + &i.MarketName, + &i.Odd, + &i.OddName, + &i.OddHeader, + &i.OddHandicap, + &i.Status, + &i.Expires, + ) + return i, err } const UpdateCashOut = `-- name: UpdateCashOut :exec diff --git a/internal/domain/bet.go b/internal/domain/bet.go index 6e2d81a..93392cf 100644 --- a/internal/domain/bet.go +++ b/internal/domain/bet.go @@ -80,3 +80,82 @@ type CreateBet struct { IsShopBet bool CashoutID string } + +type CreateBetOutcomeReq struct { + EventID int64 `json:"event_id" example:"1"` + OddID int64 `json:"odd_id" example:"1"` + MarketID int64 `json:"market_id" example:"1"` +} + +type CreateBetReq struct { + Outcomes []CreateBetOutcomeReq `json:"outcomes"` + Amount float32 `json:"amount" example:"100.0"` + Status OutcomeStatus `json:"status" example:"1"` + FullName string `json:"full_name" example:"John"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + BranchID *int64 `json:"branch_id,omitempty" example:"1"` +} + +type RandomBetReq struct { + BranchID int64 `json:"branch_id,omitempty" example:"1"` +} + +type CreateBetRes struct { + ID int64 `json:"id" example:"1"` + Amount float32 `json:"amount" example:"100.0"` + TotalOdds float32 `json:"total_odds" example:"4.22"` + Status OutcomeStatus `json:"status" example:"1"` + FullName string `json:"full_name" example:"John"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + BranchID int64 `json:"branch_id" example:"2"` + UserID int64 `json:"user_id" example:"2"` + IsShopBet bool `json:"is_shop_bet" example:"false"` + CreatedNumber int64 `json:"created_number" example:"2"` + CashedID string `json:"cashed_id" example:"21234"` +} +type BetRes struct { + ID int64 `json:"id" example:"1"` + Outcomes []BetOutcome `json:"outcomes"` + Amount float32 `json:"amount" example:"100.0"` + TotalOdds float32 `json:"total_odds" example:"4.22"` + Status OutcomeStatus `json:"status" example:"1"` + FullName string `json:"full_name" example:"John"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + BranchID int64 `json:"branch_id" example:"2"` + UserID int64 `json:"user_id" example:"2"` + IsShopBet bool `json:"is_shop_bet" example:"false"` + CashedOut bool `json:"cashed_out" example:"false"` + CashedID string `json:"cashed_id" example:"21234"` +} + +func ConvertCreateBet(bet Bet, createdNumber int64) CreateBetRes { + return CreateBetRes{ + ID: bet.ID, + Amount: bet.Amount.Float32(), + TotalOdds: bet.TotalOdds, + Status: bet.Status, + FullName: bet.FullName, + PhoneNumber: bet.PhoneNumber, + BranchID: bet.BranchID.Value, + UserID: bet.UserID.Value, + CreatedNumber: createdNumber, + CashedID: bet.CashoutID, + } +} + +func ConvertBet(bet GetBet) BetRes { + return BetRes{ + ID: bet.ID, + Amount: bet.Amount.Float32(), + TotalOdds: bet.TotalOdds, + Status: bet.Status, + FullName: bet.FullName, + PhoneNumber: bet.PhoneNumber, + BranchID: bet.BranchID.Value, + UserID: bet.UserID.Value, + Outcomes: bet.Outcomes, + IsShopBet: bet.IsShopBet, + CashedOut: bet.CashedOut, + CashedID: bet.CashoutID, + } +} diff --git a/internal/repository/bet.go b/internal/repository/bet.go index 5ff779f..6788a27 100644 --- a/internal/repository/bet.go +++ b/internal/repository/bet.go @@ -225,12 +225,13 @@ func (s *Store) GetBetOutcomeByEventID(ctx context.Context, eventID int64) ([]do } return result, nil } -func (s *Store) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error { - err := s.queries.UpdateBetOutcomeStatus(ctx, dbgen.UpdateBetOutcomeStatusParams{ +func (s *Store) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error) { + update, err := s.queries.UpdateBetOutcomeStatus(ctx, dbgen.UpdateBetOutcomeStatusParams{ Status: int32(status), ID: id, }) - return err + res := convertDBBetOutcomes(update) + return res, err } func (s *Store) DeleteBet(ctx context.Context, id int64) error { diff --git a/internal/services/bet/port.go b/internal/services/bet/port.go index d5ea609..2e8cf24 100644 --- a/internal/services/bet/port.go +++ b/internal/services/bet/port.go @@ -13,8 +13,10 @@ type BetStore interface { GetBetByID(ctx context.Context, id int64) (domain.GetBet, error) GetAllBets(ctx context.Context) ([]domain.GetBet, error) GetBetByBranchID(ctx context.Context, BranchID int64) ([]domain.GetBet, error) + GetBetOutcomeByEventID(ctx context.Context, eventID int64) ([]domain.BetOutcome, error) UpdateCashOut(ctx context.Context, id int64, cashedOut bool) error UpdateStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error - UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error + UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error) DeleteBet(ctx context.Context, id int64) error } + diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go index 1a2cb8d..793618d 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -3,21 +3,50 @@ package bet import ( "context" "crypto/rand" + "encoding/json" + "errors" + "fmt" + "log/slog" "math/big" + random "math/rand" + "slices" + "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" ) type Service struct { - betStore BetStore + betStore BetStore + eventSvc event.Service + prematchSvc odds.Service + walletSvc wallet.Service + branchSvc branch.Service + logger *slog.Logger } -func NewService(betStore BetStore) *Service { +func NewService(betStore BetStore, eventSvc event.Service, prematchSvc odds.Service, walletSvc wallet.Service, branchSvc branch.Service, logger *slog.Logger) *Service { return &Service{ - betStore: betStore, + 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 @@ -33,8 +62,365 @@ func (s *Service) GenerateCashoutID() (string, error) { return string(result), nil } -func (s *Service) CreateBet(ctx context.Context, bet domain.CreateBet) (domain.Bet, error) { +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{}, err + } + + 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 + } + sportID, err := strconv.ParseInt(event.SportID, 10, 64) + if err != nil { + return domain.CreateBetOutcome{}, err + } + newOutcome := domain.CreateBetOutcome{ + EventID: eventID, + OddID: oddID, + MarketID: marketID, + SportID: 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.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 + } + + newBet.BranchID = domain.ValidInt64{ + Value: *req.BranchID, + Valid: true, + } + newBet.UserID = domain.ValidInt64{ + Value: userID, + Valid: true, + } + newBet.IsShopBet = true + case domain.RoleCustomer: + return domain.CreateBetRes{}, fmt.Errorf("Not yet implemented") + 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, sportID, HomeTeam, AwayTeam string, StartTime time.Time) ([]domain.CreateBetOutcome, float32, error) { + + var newOdds []domain.CreateBetOutcome + var totalOdds float32 = 1 + + markets, err := s.prematchSvc.GetPrematchOdds(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", "event id", eventID) + } + + var numMarkets = min(5, len(markets)) + var randIndex []int = make([]int, numMarkets) + for i := 0; i < numMarkets; i++ { + // Guarantee that the odd is unique + var newRandMarket int + count := 0 + for { + newRandMarket = random.Intn(len(markets)) + if !slices.Contains(randIndex, newRandMarket) { + break + } + // just in case + if count >= 5 { + s.logger.Warn("market overload", "event id", eventID) + break + } + count++ + } + + randIndex[i] = newRandMarket + + rawOdds := markets[i].RawOdds + randomRawOdd := rawOdds[random.Intn(len(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 + } + sportID, err := strconv.ParseInt(sportID, 10, 64) + if err != nil { + s.logger.Error("Failed to get sport id", "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(markets[i].MarketID, 10, 64) + if err != nil { + s.logger.Error("Failed to get odd id", "error", err) + continue + } + + marketName := markets[i].MarketName + + newOdds = append(newOdds, domain.CreateBetOutcome{ + EventID: eventID, + OddID: oddID, + MarketID: marketID, + SportID: 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("Failed to generate random outcomes") + return nil, 0, nil + } + + return newOdds, totalOdds, nil +} + +func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64) (domain.CreateBetRes, error) { + + // Get a unexpired event id + events, _, err := s.eventSvc.GetPaginatedUpcomingEvents(ctx, 5, 0, domain.ValidString{}, domain.ValidString{}) + + if err != nil { + return domain.CreateBetRes{}, err + } + + // Get market and odds for that + var randomOdds []domain.CreateBetOutcome + var totalOdds float32 = 1 + for _, event := range events { + + newOdds, total, err := s.GenerateRandomBetOutcomes(ctx, event.ID, event.SportID, event.HomeTeam, event.AwayTeam, event.StartTime) + + 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 outcomes") + return domain.CreateBetRes{}, nil + } + + var cashoutID string + + cashoutID, err = s.GenerateCashoutID() + if err != nil { + return domain.CreateBetRes{}, err + } + + randomNumber := strconv.FormatInt(int64(random.Intn(10)), 10) + newBet := domain.CreateBet{ + Amount: 123, + TotalOdds: totalOdds, + Status: domain.OUTCOME_STATUS_PENDING, + FullName: "test" + randomNumber, + PhoneNumber: randomNumber, + 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) } @@ -64,8 +450,43 @@ func (s *Service) UpdateStatus(ctx context.Context, id int64, status domain.Outc return s.betStore.UpdateStatus(ctx, id, status) } +func (s *Service) checkBetOutcomeForBet(ctx context.Context, eventID int64) error { + betOutcomes, err := s.betStore.GetBetOutcomeByEventID(ctx, eventID) + if err != nil { + return err + } + status := domain.OUTCOME_STATUS_PENDING + + for _, betOutcome := range betOutcomes { + // Check if any of them are pending + if betOutcome.Status == domain.OUTCOME_STATUS_PENDING { + return nil + } + + if status == domain.OUTCOME_STATUS_PENDING { + status = betOutcome.Status + } else if status == domain.OUTCOME_STATUS_WIN { + status = betOutcome.Status + } else if status == domain.OUTCOME_STATUS_LOSS { + continue + } + } + + if status != domain.OUTCOME_STATUS_PENDING { + return nil + } + + return s.UpdateStatus(ctx, eventID, status) + +} + func (s *Service) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error { - return s.betStore.UpdateBetOutcomeStatus(ctx, id, status) + betOutcome, err := s.betStore.UpdateBetOutcomeStatus(ctx, id, status) + if err != nil { + return err + } + return s.checkBetOutcomeForBet(ctx, betOutcome.EventID) + } func (s *Service) DeleteBet(ctx context.Context, id int64) error { diff --git a/internal/services/result/service.go b/internal/services/result/service.go index c959f00..c2ef4b1 100644 --- a/internal/services/result/service.go +++ b/internal/services/result/service.go @@ -13,6 +13,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" ) type Service struct { @@ -20,14 +21,16 @@ type Service struct { config *config.Config logger *slog.Logger client *http.Client + betSvc bet.Service } -func NewService(repo *repository.Store, cfg *config.Config, logger *slog.Logger) *Service { +func NewService(repo *repository.Store, cfg *config.Config, logger *slog.Logger, betSvc bet.Service) *Service { return &Service{ repo: repo, config: cfg, logger: logger, client: &http.Client{Timeout: 10 * time.Second}, + betSvc: betSvc, } } @@ -85,7 +88,7 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { // continue // } - err = s.repo.UpdateBetOutcomeStatus(ctx, outcome.ID, result.Status) + _, err = s.repo.UpdateBetOutcomeStatus(ctx, outcome.ID, result.Status) if err != nil { s.logger.Error("Failed to update bet outcome status", "bet_outcome_id", outcome.ID, "error", err) continue diff --git a/internal/web_server/handlers/bet_handler.go b/internal/web_server/handlers/bet_handler.go index 268fbb3..d6952a1 100644 --- a/internal/web_server/handlers/bet_handler.go +++ b/internal/web_server/handlers/bet_handler.go @@ -1,8 +1,6 @@ package handlers import ( - "encoding/json" - "log/slog" "strconv" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" @@ -10,88 +8,13 @@ import ( "github.com/gofiber/fiber/v2" ) -type CreateBetOutcomeReq struct { - EventID int64 `json:"event_id" example:"1"` - OddID int64 `json:"odd_id" example:"1"` - MarketID int64 `json:"market_id" example:"1"` -} - -type CreateBetReq struct { - Outcomes []CreateBetOutcomeReq `json:"outcomes"` - Amount float32 `json:"amount" example:"100.0"` - Status domain.OutcomeStatus `json:"status" example:"1"` - FullName string `json:"full_name" example:"John"` - PhoneNumber string `json:"phone_number" example:"1234567890"` - BranchID *int64 `json:"branch_id,omitempty" example:"1"` -} - -type CreateBetRes struct { - ID int64 `json:"id" example:"1"` - Amount float32 `json:"amount" example:"100.0"` - TotalOdds float32 `json:"total_odds" example:"4.22"` - Status domain.OutcomeStatus `json:"status" example:"1"` - FullName string `json:"full_name" example:"John"` - PhoneNumber string `json:"phone_number" example:"1234567890"` - BranchID int64 `json:"branch_id" example:"2"` - UserID int64 `json:"user_id" example:"2"` - IsShopBet bool `json:"is_shop_bet" example:"false"` - CreatedNumber int64 `json:"created_number" example:"2"` - CashedID string `json:"cashed_id" example:"21234"` -} -type BetRes struct { - ID int64 `json:"id" example:"1"` - Outcomes []domain.BetOutcome `json:"outcomes"` - Amount float32 `json:"amount" example:"100.0"` - TotalOdds float32 `json:"total_odds" example:"4.22"` - Status domain.OutcomeStatus `json:"status" example:"1"` - FullName string `json:"full_name" example:"John"` - PhoneNumber string `json:"phone_number" example:"1234567890"` - BranchID int64 `json:"branch_id" example:"2"` - UserID int64 `json:"user_id" example:"2"` - IsShopBet bool `json:"is_shop_bet" example:"false"` - CashedOut bool `json:"cashed_out" example:"false"` - CashedID string `json:"cashed_id" example:"21234"` -} - -func convertCreateBet(bet domain.Bet, createdNumber int64) CreateBetRes { - return CreateBetRes{ - ID: bet.ID, - Amount: bet.Amount.Float32(), - TotalOdds: bet.TotalOdds, - Status: bet.Status, - FullName: bet.FullName, - PhoneNumber: bet.PhoneNumber, - BranchID: bet.BranchID.Value, - UserID: bet.UserID.Value, - CreatedNumber: createdNumber, - CashedID: bet.CashoutID, - } -} - -func convertBet(bet domain.GetBet) BetRes { - return BetRes{ - ID: bet.ID, - Amount: bet.Amount.Float32(), - TotalOdds: bet.TotalOdds, - Status: bet.Status, - FullName: bet.FullName, - PhoneNumber: bet.PhoneNumber, - BranchID: bet.BranchID.Value, - UserID: bet.UserID.Value, - Outcomes: bet.Outcomes, - IsShopBet: bet.IsShopBet, - CashedOut: bet.CashedOut, - CashedID: bet.CashoutID, - } -} - // CreateBet godoc // @Summary Create a bet // @Description Creates a bet // @Tags bet // @Accept json // @Produce json -// @Param createBet body CreateBetReq true "Creates bet" +// @Param createBet body domain.CreateBetReq true "Creates bet" // @Success 200 {object} BetRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse @@ -102,7 +25,7 @@ func (h *Handler) CreateBet(c *fiber.Ctx) error { userID := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) - var req CreateBetReq + var req domain.CreateBetReq if err := c.BodyParser(&req); err != nil { h.logger.Error("Failed to parse CreateBet request", "error", err) return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") @@ -113,199 +36,52 @@ func (h *Handler) CreateBet(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) } - // TODO Validate Outcomes Here and make sure they didn't expire - // Validation for creating tickets - if len(req.Outcomes) > 30 { - return response.WriteJSON(c, fiber.StatusBadRequest, "Too many odds/outcomes selected", nil, nil) - } - var outcomes []domain.CreateBetOutcome = make([]domain.CreateBetOutcome, 0, len(req.Outcomes)) - var totalOdds float32 = 1 - for _, outcome := range req.Outcomes { - eventIDStr := strconv.FormatInt(outcome.EventID, 10) - marketIDStr := strconv.FormatInt(outcome.MarketID, 10) - oddIDStr := strconv.FormatInt(outcome.OddID, 10) - event, err := h.eventSvc.GetUpcomingEventByID(c.Context(), eventIDStr) - if err != nil { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid event id", err, nil) - } - - // Checking to make sure the event hasn't already started - // currentTime := time.Now() - // if event.StartTime.Before(currentTime) { - // return response.WriteJSON(c, fiber.StatusBadRequest, "The event has already expired", nil, nil) - // } - - odds, err := h.prematchSvc.GetRawOddsByMarketID(c.Context(), marketIDStr, eventIDStr) - - if err != nil { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid market id", err, nil) - } - 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 { - h.logger.Error("Failed to unmarshal raw odd", "error", err) - continue - } - if rawOdd.ID == oddIDStr { - selectedOdd = rawOdd - isOddFound = true - } - } - - if !isOddFound { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid odd id", nil, nil) - } - - parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) - totalOdds = totalOdds * float32(parsedOdd) - - sportID, err := strconv.ParseInt(event.SportID, 10, 64) - if err != nil { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid sport id", nil, nil) - } - - h.logger.Info("Create Bet", slog.Int64("sportId", sportID)) - - outcomes = append(outcomes, domain.CreateBetOutcome{ - EventID: outcome.EventID, - OddID: outcome.OddID, - MarketID: outcome.MarketID, - SportID: 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, - }) - } - - // Validating user by role - // Differentiating between offline and online bets - cashoutID, err := h.betSvc.GenerateCashoutID() - if err != nil { - h.logger.Error("CreateBetReq failed, unable to create cashout id") - return response.WriteJSON(c, fiber.StatusInternalServerError, "Invalid request", err, nil) - } - var bet domain.Bet - if role == domain.RoleCashier { - - // Get the branch from the branch ID - branch, err := h.branchSvc.GetBranchByCashier(c.Context(), userID) - if err != nil { - h.logger.Error("CreateBetReq failed, branch id invalid") - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", err, nil) - } - - // Deduct a percentage of the amount - // TODO move to service layer. Make it fetch dynamically from company - var deductedAmount = req.Amount / 10 - err = h.walletSvc.DeductFromWallet(c.Context(), branch.WalletID, domain.ToCurrency(deductedAmount)) - - if err != nil { - h.logger.Error("CreateBetReq failed, unable to deduct from WalletID") - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", err, nil) - } - - bet, err = h.betSvc.CreateBet(c.Context(), domain.CreateBet{ - Amount: domain.ToCurrency(req.Amount), - TotalOdds: totalOdds, - Status: req.Status, - FullName: req.FullName, - PhoneNumber: req.PhoneNumber, - - BranchID: domain.ValidInt64{ - Value: branch.ID, - Valid: true, - }, - UserID: domain.ValidInt64{ - Value: userID, - Valid: false, - }, - IsShopBet: true, - CashoutID: cashoutID, - }) - } else if role == domain.RoleSuperAdmin || role == domain.RoleAdmin || role == domain.RoleBranchManager { - // If a non cashier wants to create a bet, they will need to provide the Branch ID - // TODO: restrict the Branch ID of Admin and Branch Manager to only the branches within their own company - if req.BranchID == nil { - h.logger.Error("CreateBetReq failed, Branch ID is required for this type of user") - return response.WriteJSON(c, fiber.StatusBadRequest, "Branch ID is required for this type of user", nil, nil) - } - // h.logger.Info("Branch ID", slog.Int64("branch_id", *req.BranchID)) - bet, err = h.betSvc.CreateBet(c.Context(), domain.CreateBet{ - Amount: domain.ToCurrency(req.Amount), - TotalOdds: totalOdds, - Status: req.Status, - FullName: req.FullName, - PhoneNumber: req.PhoneNumber, - BranchID: domain.ValidInt64{ - Value: *req.BranchID, - Valid: true, - }, - UserID: domain.ValidInt64{ - Value: userID, - Valid: true, - }, - IsShopBet: true, - CashoutID: cashoutID, - }) - } else { - // TODO if user is customer, get id from the token then get the wallet id from there and reduce the amount - bet, err = h.betSvc.CreateBet(c.Context(), domain.CreateBet{ - Amount: domain.ToCurrency(req.Amount), - TotalOdds: totalOdds, - Status: req.Status, - FullName: req.FullName, - PhoneNumber: req.PhoneNumber, - - BranchID: domain.ValidInt64{ - Value: 0, - Valid: false, - }, - UserID: domain.ValidInt64{ - Value: userID, - Valid: true, - }, - IsShopBet: false, - CashoutID: cashoutID, - }) - } + res, err := h.betSvc.PlaceBet(c.Context(), req, userID, role) if err != nil { - h.logger.Error("CreateBetReq failed", "error", err) - return response.WriteJSON(c, fiber.StatusInternalServerError, "Internal Server Error", err, nil) + h.logger.Error("PlaceBet failed", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Unable to create bet") } - // Updating the bet id for outcomes - for index := range outcomes { - outcomes[index].BetID = bet.ID + return response.WriteJSON(c, fiber.StatusOK, "Bet Created", res, nil) + +} + +// RandomBet godoc +// @Summary Generate a random bet +// @Description Generate a random bet +// @Tags bet +// @Accept json +// @Produce json +// @Param createBet body domain.RandomBetReq true "Create Random bet" +// @Success 200 {object} BetRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /random/bet [post] +func (h *Handler) RandomBet(c *fiber.Ctx) error { + + // Get user_id from middleware + userID := c.Locals("user_id").(int64) + // role := c.Locals("role").(domain.Role) + + var req domain.RandomBetReq + if err := c.BodyParser(&req); err != nil { + h.logger.Error("Failed to parse RandomBet request", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - rows, err := h.betSvc.CreateBetOutcome(c.Context(), outcomes) + valErrs, ok := h.validator.Validate(c, req) + if !ok { + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + } + + res, err := h.betSvc.PlaceRandomBet(c.Context(), userID, req.BranchID) if err != nil { - h.logger.Error("CreateBetReq failed to create outcomes", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Internal server error", - }) + h.logger.Error("Random Bet failed", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Unable to create random bet") } - res := convertCreateBet(bet, rows) - return response.WriteJSON(c, fiber.StatusOK, "Bet Created", res, nil) } @@ -327,9 +103,9 @@ func (h *Handler) GetAllBet(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve bets") } - res := make([]BetRes, len(bets)) + res := make([]domain.BetRes, len(bets)) for i, bet := range bets { - res[i] = convertBet(bet) + res[i] = domain.ConvertBet(bet) } return response.WriteJSON(c, fiber.StatusOK, "All bets retrieved successfully", res, nil) @@ -360,7 +136,7 @@ func (h *Handler) GetBetByID(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve bet") } - res := convertBet(bet) + res := domain.ConvertBet(bet) return response.WriteJSON(c, fiber.StatusOK, "Bet retrieved successfully", res, nil) @@ -392,7 +168,7 @@ func (h *Handler) GetBetByCashoutID(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve bet", err, nil) } - res := convertBet(bet) + res := domain.ConvertBet(bet) return response.WriteJSON(c, fiber.StatusOK, "Bet retrieved successfully", res, nil) diff --git a/internal/web_server/handlers/branch_handler.go b/internal/web_server/handlers/branch_handler.go index f261c4f..905da0b 100644 --- a/internal/web_server/handlers/branch_handler.go +++ b/internal/web_server/handlers/branch_handler.go @@ -517,9 +517,9 @@ func (h *Handler) GetBetByBranchID(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve bets", err, nil) } - var res []BetRes = make([]BetRes, 0, len(bets)) + var res []domain.BetRes = make([]domain.BetRes, 0, len(bets)) for _, bet := range bets { - res = append(res, convertBet(bet)) + res = append(res, domain.ConvertBet(bet)) } return response.WriteJSON(c, fiber.StatusOK, "Branch Bets Retrieved", res, nil) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 7b7e22a..0623d62 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -149,7 +149,9 @@ func (a *App) initAppRoutes() { a.fiber.Get("/bet/cashout/:id", a.authMiddleware, h.GetBetByCashoutID) a.fiber.Patch("/bet/:id", a.authMiddleware, h.UpdateCashOut) a.fiber.Delete("/bet/:id", a.authMiddleware, h.DeleteBet) - + + a.fiber.Post("/random/bet", a.authMiddleware, h.RandomBet) + // Wallet a.fiber.Get("/wallet", h.GetAllWallets) a.fiber.Get("/wallet/:id", h.GetWalletByID) @@ -176,6 +178,7 @@ func (a *App) initAppRoutes() { // Virtual Game Routes a.fiber.Post("/virtual-game/launch", a.authMiddleware, h.LaunchVirtualGame) a.fiber.Post("/virtual-game/callback", h.HandleVirtualGameCallback) + } ///user/profile get