Yimaru-BackEnd/internal/services/bet/service.go
2025-06-06 03:43:41 +03:00

640 lines
19 KiB
Go

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)
}