Yimaru-BackEnd/internal/services/bet/service.go
2025-07-15 15:47:07 +03:00

1280 lines
36 KiB
Go

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