package odds import ( "context" "encoding/json" "errors" "fmt" "io" "log" "log/slog" "net/http" "strconv" "sync" "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/ports" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" "go.uber.org/zap" ) type ServiceImpl struct { store ports.OddStore marketSettingStore ports.MarketSettingStore config *config.Config eventSvc *event.Service logger *slog.Logger mongoLogger *zap.Logger client *http.Client } func New(store ports.OddStore, marketSettingStore ports.MarketSettingStore, cfg *config.Config, eventSvc *event.Service, logger *slog.Logger, mongoLogger *zap.Logger) *ServiceImpl { return &ServiceImpl{ store: store, marketSettingStore: marketSettingStore, config: cfg, eventSvc: eventSvc, logger: logger, mongoLogger: mongoLogger, client: &http.Client{Timeout: 10 * time.Second}, } } // TODO Add the optimization to get 10 events at the same time func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { var wg sync.WaitGroup errChan := make(chan error, 2) // wg.Add(2) wg.Add(1) go func() { defer wg.Done() if err := s.ProcessBet365Odds(ctx); err != nil { errChan <- fmt.Errorf("failed while processing bet365 odds error: %w", err) } }() // go func() { // defer wg.Done() // if err := s.fetchBwinOdds(ctx); err != nil { // errChan <- fmt.Errorf("bwin odds fetching error: %w", err) // } // }() go func() { wg.Wait() close(errChan) }() var errs []error for err := range errChan { errs = append(errs, err) } if len(errs) > 0 { return errors.Join(errs...) } return nil } func (s *ServiceImpl) ProcessBet365Odds(ctx context.Context) error { eventIDs, _, err := s.eventSvc.GetAllEvents(ctx, domain.EventFilter{ Status: domain.ValidEventStatus{ Value: domain.STATUS_PENDING, Valid: true, }, Source: domain.ValidEventSource{ Value: domain.EVENT_SOURCE_BET365, }, }) if err != nil { s.mongoLogger.Error( "Failed to fetch upcoming event IDs", zap.Error(err), ) return err } for index, event := range eventIDs { if s.config.Env == "development" { log.Printf("📡 Fetching prematch odds for event ID: %v (%d/%d) ", event.ID, index, len(eventIDs)) } eventLogger := s.mongoLogger.With( zap.Int64("eventID", event.ID), zap.Int32("sportID", event.SportID), ) oddsData, err := s.FetchB365Odds(ctx, event.SourceEventID) if err != nil || oddsData.Success != 1 { eventLogger.Error("Failed to fetch prematch odds", zap.Error(err)) continue } parsedOddSections, err := s.ParseOddSections(ctx, oddsData.Results[0], event.SportID) if err != nil { eventLogger.Error("Failed to parse odd section", zap.Error(err)) continue } parsedOddLogger := eventLogger.With( zap.String("parsedOddSectionFI", parsedOddSections.EventFI), zap.Int("main_sections_count", len(parsedOddSections.Sections)), zap.Int("other_sections_count", len(parsedOddSections.OtherRes)), ) if parsedOddSections.EventFI == "" { parsedOddLogger.Error("Skipping result with no valid Event FI field", zap.Error(err)) continue } if len(parsedOddSections.Sections) == 0 { parsedOddLogger.Warn("Event has no odds in main sections", zap.Error(err)) } for oddCategory, section := range parsedOddSections.Sections { if err := s.storeSection(ctx, event.ID, parsedOddSections.EventFI, oddCategory, section); err != nil { parsedOddLogger.Error("Error storing odd section", zap.String("odd", oddCategory), zap.Error(err)) } } for _, section := range parsedOddSections.OtherRes { if err := s.storeSection(ctx, event.ID, parsedOddSections.EventFI, "others", section); err != nil { parsedOddLogger.Error("Error storing odd other section", zap.Error(err)) continue } } // result := oddsData.Results[0] } return nil } // func (s *ServiceImpl) fetchBwinOdds(ctx context.Context) error { // // getting odds for a specific event is not possible for bwin, most specific we can get is fetch odds on a single sport // // so instead of having event and odds fetched separetly event will also be fetched along with the odds // sportIds := []int{4, 12, 7} // for _, sportId := range sportIds { // url := fmt.Sprintf("https://api.b365api.com/v1/bwin/prematch?sport_id=%d&token=%s", sportId, s.config.Bet365Token) // req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) // if err != nil { // s.mongoLogger.Error( // "Failed to create request for sportId", // zap.Int("sportID", sportId), // zap.Error(err), // ) // continue // } // resp, err := s.client.Do(req) // if err != nil { // s.mongoLogger.Error( // "Failed to fetch request for sportId", // zap.Int("sportID", sportId), // zap.Error(err), // ) // continue // } // defer resp.Body.Close() // body, err := io.ReadAll(resp.Body) // if err != nil { // s.mongoLogger.Error( // "Failed to read response body for sportId", // zap.Int("sportID", sportId), // zap.Error(err), // ) // continue // } // var data struct { // Success int `json:"success"` // Results []map[string]interface{} `json:"results"` // } // if err := json.Unmarshal(body, &data); err != nil || data.Success != 1 { // fmt.Printf("Decode failed for sport_id=%d\nRaw: %s\n", sportId, string(body)) // s.mongoLogger.Error( // "Failed to decode BWin response body", // zap.Int("sportID", sportId), // zap.Error(err), // ) // continue // } // for _, res := range data.Results { // if getInt(res["Id"]) == -1 { // continue // } // event := domain.CreateEvent{ // ID: strconv.Itoa(getInt(res["Id"])), // SportID: int32(getInt(res["SportId"])), // LeagueID: int64(getInt(res["LeagueId"])), // LeagueName: getString(res["Leaguename"]), // HomeTeam: getString(res["HomeTeam"]), // HomeTeamID: int64(getInt(res["HomeTeamId"])), // AwayTeam: getString(res["AwayTeam"]), // AwayTeamID: int64(getInt(res["AwayTeamId"])), // StartTime: time.Now().UTC(), // IsLive: true, // Status: domain.STATUS_IN_PLAY, // Source: domain.EVENT_SOURCE_BWIN, // MatchName: "", // HomeTeamImage: "", // AwayTeamImage: "", // } // if err := s.store.SaveEvent(ctx, event); err != nil { // fmt.Printf("Could not store live event [id=%s]: %v\n", event.ID, err) // s.mongoLogger.Error( // "Could not store live event", // zap.Int("sportID", sportId), // zap.String("eventID", event.ID), // zap.Error(err), // ) // continue // } // for _, market := range []string{"Markets, optionMarkets"} { // for _, m := range getMapArray(res[market]) { // name := getMap(m["name"]) // marketName := getString(name["value"]) // market := domain.CreateOddMarket{ // EventID: event.ID, // MarketID: getString(m["id"]), // MarketCategory: getString(m["category"]), // MarketName: marketName, // } // results := getMapArray(m["results"]) // market.Odds = results // s.store.SaveOddMarket(ctx, market) // } // } // } // } // return nil // } func (s *ServiceImpl) FetchB365Odds(ctx context.Context, eventID string) (domain.BaseNonLiveOddResponse, error) { url := fmt.Sprintf("https://api.b365api.com/v3/bet365/prematch?token=%s&FI=%v", s.config.Bet365Token, eventID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { s.mongoLogger.Error( "Failed to create request for event", zap.String("eventID", eventID), zap.Error(err), ) } resp, err := s.client.Do(req) if err != nil { s.mongoLogger.Error( "Failed to fetch prematch odds for event", zap.String("eventID", eventID), zap.Error(err), ) return domain.BaseNonLiveOddResponse{}, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { s.mongoLogger.Error( "Failed to read response body for event", zap.String("eventID", eventID), zap.Error(err), ) return domain.BaseNonLiveOddResponse{}, err } var oddsData domain.BaseNonLiveOddResponse if err := json.Unmarshal(body, &oddsData); err != nil || oddsData.Success != 1 || len(oddsData.Results) == 0 { s.mongoLogger.Error( "Invalid prematch data for event", zap.String("eventID", eventID), zap.Error(err), ) return domain.BaseNonLiveOddResponse{}, err } return oddsData, nil } func (s *ServiceImpl) ParseOddSections(ctx context.Context, res json.RawMessage, sportID int32) (domain.ParseOddSectionsRes, error) { var sections map[string]domain.OddsSection var OtherRes []domain.OddsSection var eventFI string switch sportID { case domain.FOOTBALL: var footballRes domain.FootballOddsResponse if err := json.Unmarshal(res, &footballRes); err != nil { s.mongoLogger.Error( "Failed to unmarshal football result", zap.Error(err), ) return domain.ParseOddSectionsRes{}, err } eventFI = footballRes.FI OtherRes = footballRes.Others sections = map[string]domain.OddsSection{ "main": footballRes.Main, "asian_lines": footballRes.AsianLines, "goals": footballRes.Goals, "half": footballRes.Half, "cards": footballRes.Cards, "corners": footballRes.Corners, "player": footballRes.Player, "minutes": footballRes.Minutes, "specials": footballRes.Specials, } case domain.BASKETBALL: var basketballRes domain.BasketballOddsResponse if err := json.Unmarshal(res, &basketballRes); err != nil { s.mongoLogger.Error( "Failed to unmarshal basketball result", zap.Error(err), ) return domain.ParseOddSectionsRes{}, err } eventFI = basketballRes.FI OtherRes = basketballRes.Others sections = map[string]domain.OddsSection{ "main": basketballRes.Main, "half_props": basketballRes.HalfProps, "quarter_props": basketballRes.QuarterProps, "team_props": basketballRes.TeamProps, } case domain.ICE_HOCKEY: var iceHockeyRes domain.IceHockeyOddsResponse if err := json.Unmarshal(res, &iceHockeyRes); err != nil { s.mongoLogger.Error( "Failed to unmarshal ice hockey result", zap.Error(err), ) return domain.ParseOddSectionsRes{}, err } eventFI = iceHockeyRes.FI OtherRes = iceHockeyRes.Others sections = map[string]domain.OddsSection{ "main": iceHockeyRes.Main, "main_2": iceHockeyRes.Main2, "1st_period": iceHockeyRes.FirstPeriod, } case domain.CRICKET: var cricketRes domain.CricketOddsResponse if err := json.Unmarshal(res, &cricketRes); err != nil { s.mongoLogger.Error( "Failed to unmarshal cricket result", zap.Error(err), ) return domain.ParseOddSectionsRes{}, err } eventFI = cricketRes.FI OtherRes = cricketRes.Others sections = map[string]domain.OddsSection{ "1st_over": cricketRes.Main, "innings_1": cricketRes.First_Innings, "main": cricketRes.Main, "match": cricketRes.Match, "player": cricketRes.Player, "team": cricketRes.Team, } case domain.VOLLEYBALL: var volleyballRes domain.VolleyballOddsResponse if err := json.Unmarshal(res, &volleyballRes); err != nil { s.mongoLogger.Error( "Failed to unmarshal volleyball result", zap.Error(err), ) return domain.ParseOddSectionsRes{}, err } eventFI = volleyballRes.FI OtherRes = volleyballRes.Others sections = map[string]domain.OddsSection{ "main": volleyballRes.Main, } case domain.DARTS: var dartsRes domain.DartsOddsResponse if err := json.Unmarshal(res, &dartsRes); err != nil { s.mongoLogger.Error( "Failed to unmarshal darts result", zap.Error(err), ) return domain.ParseOddSectionsRes{}, err } eventFI = dartsRes.FI OtherRes = dartsRes.Others sections = map[string]domain.OddsSection{ "180s": dartsRes.OneEightys, "extra": dartsRes.Extra, "leg": dartsRes.Leg, "main": dartsRes.Main, } case domain.FUTSAL: var futsalRes domain.FutsalOddsResponse if err := json.Unmarshal(res, &futsalRes); err != nil { s.mongoLogger.Error( "Failed to unmarshal futsal result", zap.Error(err), ) return domain.ParseOddSectionsRes{}, err } eventFI = futsalRes.FI OtherRes = futsalRes.Others sections = map[string]domain.OddsSection{ "main": futsalRes.Main, "score": futsalRes.Score, } case domain.AMERICAN_FOOTBALL: var americanFootballRes domain.AmericanFootballOddsResponse if err := json.Unmarshal(res, &americanFootballRes); err != nil { s.mongoLogger.Error( "Failed to unmarshal american football result", zap.Error(err), ) return domain.ParseOddSectionsRes{}, err } eventFI = americanFootballRes.FI OtherRes = americanFootballRes.Others sections = map[string]domain.OddsSection{ "half_props": americanFootballRes.HalfProps, "main": americanFootballRes.Main, "quarter_props": americanFootballRes.QuarterProps, } case domain.RUGBY_LEAGUE: var rugbyLeagueRes domain.RugbyLeagueOddsResponse if err := json.Unmarshal(res, &rugbyLeagueRes); err != nil { s.mongoLogger.Error( "Failed to unmarshal rugby league result", zap.Error(err), ) return domain.ParseOddSectionsRes{}, err } eventFI = rugbyLeagueRes.FI OtherRes = rugbyLeagueRes.Others sections = map[string]domain.OddsSection{ "10minute": rugbyLeagueRes.TenMinute, "main": rugbyLeagueRes.Main, "main_2": rugbyLeagueRes.Main2, "player": rugbyLeagueRes.Player, "Score": rugbyLeagueRes.Score, "Team": rugbyLeagueRes.Team, } case domain.RUGBY_UNION: var rugbyUnionRes domain.RugbyUnionOddsResponse if err := json.Unmarshal(res, &rugbyUnionRes); err != nil { s.mongoLogger.Error( "Failed to unmarshal rugby union result", zap.Error(err), ) return domain.ParseOddSectionsRes{}, err } eventFI = rugbyUnionRes.FI OtherRes = rugbyUnionRes.Others sections = map[string]domain.OddsSection{ "main": rugbyUnionRes.Main, "main_2": rugbyUnionRes.Main2, "player": rugbyUnionRes.Player, "Score": rugbyUnionRes.Score, "Team": rugbyUnionRes.Team, } case domain.BASEBALL: var baseballRes domain.BaseballOddsResponse if err := json.Unmarshal(res, &baseballRes); err != nil { s.mongoLogger.Error( "Failed to unmarshal baseball result", zap.Error(err), ) return domain.ParseOddSectionsRes{}, err } eventFI = baseballRes.FI sections = map[string]domain.OddsSection{ "main": baseballRes.Main, "mani_props": baseballRes.MainProps, } } return domain.ParseOddSectionsRes{ Sections: sections, OtherRes: OtherRes, EventFI: eventFI, }, nil } func (s *ServiceImpl) storeSection(ctx context.Context, eventID int64, fi, sectionName string, section domain.OddsSection) error { if len(section.Sp) == 0 { s.mongoLogger.Warn("Event Section is empty", zap.Int64("eventID", eventID), zap.String("sectionName", sectionName), ) return nil } updatedAtUnix, _ := strconv.ParseInt(section.UpdatedAt, 10, 64) updatedAt := time.Unix(updatedAtUnix, 0) var errs []error for marketType, market := range section.Sp { marketLogger := s.mongoLogger.With( zap.Int64("eventID", eventID), zap.String("sectionName", sectionName), zap.String("market_id", string(market.ID)), zap.String("marketType", marketType), zap.String("marketName", market.Name), ) if len(market.Odds) == 0 { // marketLogger.Warn("Skipping market with no odds") continue } marketIDint, err := strconv.ParseInt(string(market.ID), 10, 64) if err != nil { marketLogger.Warn("skipping market section where market_id is not int") continue } isSupported, ok := domain.SupportedMarkets[marketIDint] if !ok || !isSupported { // marketLogger.Warn("skipping market that isn't supported", zap.Bool("is_market_found", ok)) continue } marketOdds, err := convertRawMessage(market.Odds) if err != nil { marketLogger.Error("failed to convert market.Odds to json.RawMessage to []map[string]interface{}", zap.Error(err)) errs = append(errs, err) continue } marketRecord := domain.CreateOddMarket{ EventID: eventID, MarketCategory: sectionName, MarketType: marketType, MarketName: market.Name, MarketID: marketIDint, NumberOfOutcomes: int64(len(market.Odds)), UpdatedAt: updatedAt, Odds: marketOdds, // bwin won't reach this code so bet365 is hardcoded for now } if err := s.CheckAndInsertOddHistory(ctx, marketRecord); err != nil { marketLogger.Error("failed to check and insert odd history", zap.Error(err)) continue } err = s.store.SaveOddMarket(ctx, marketRecord) if err != nil { marketLogger.Error("failed to save market", zap.Error(err)) errs = append(errs, fmt.Errorf("market %v: %w", market.ID, err)) continue } } if len(errs) > 0 { return errors.Join(errs...) } return nil } func (s *ServiceImpl) CheckAndInsertOddHistory(ctx context.Context, market domain.CreateOddMarket) error { isEventMonitored, err := s.eventSvc.IsEventMonitored(ctx, market.EventID) marketLogger := s.mongoLogger.With( zap.Int64("market_id", market.MarketID), zap.String("market_name", market.MarketName), zap.Int64("eventID", market.EventID), ) if err != nil { marketLogger.Error("failed to get is_monitored", zap.Error(err)) } if !isEventMonitored { return nil } oldOdds, err := s.store.GetOddsByMarketID(ctx, market.MarketID, market.EventID) if err != nil { marketLogger.Error("failed to get raw odds by market id", zap.Error(err)) return err } if len(oldOdds.RawOdds) != len(market.Odds) { marketLogger.Error("new odds data does not match old odds data", zap.Error(err)) return fmt.Errorf("new odds data does not match old odds data") } oldRawOdds, err := convertRawMessage(oldOdds.RawOdds) if err != nil { marketLogger.Error("failed to convert raw odds to map", zap.Error(err)) return err } for _, oddData := range market.Odds { newRawOddID := getInt(oddData["id"]) newOddsVal := getFloat(oddData["odds"]) isFound := false for _, oldOddData := range oldRawOdds { oldRawOddID := getInt(oldOddData["id"]) oldOddsVal := getFloat(oldOddData["odds"]) if newRawOddID == oldRawOddID { if newOddsVal != oldOddsVal { _, err := s.store.InsertOddHistory(ctx, domain.CreateOddHistory{ OddID: oldOdds.ID, MarketID: market.MarketID, RawOddID: int64(newRawOddID), EventID: market.EventID, OddValue: newOddsVal, }) if err != nil { s.mongoLogger.Error( "failed to insert odd history", zap.Int64("market_id", market.MarketID), zap.String("market_name", market.MarketName), zap.Int64("eventID", market.EventID), zap.Int64("odd_id", oldOdds.ID), zap.Int("raw_odd_id", newRawOddID), zap.Error(err), ) } } isFound = true } } if !isFound { fmt.Printf("raw odd id %d not found", newRawOddID) } } return nil } func (s *ServiceImpl) GetAllOdds(ctx context.Context, filter domain.OddMarketFilter) ([]domain.OddMarket, error) { return s.store.GetAllOdds(ctx, filter) } func (s *ServiceImpl) GetAllOddsWithSettings(ctx context.Context, companyID int64, filter domain.OddMarketFilter) ([]domain.OddMarketWithSettings, error) { return s.store.GetAllOddsWithSettings(ctx, companyID, filter) } func (s *ServiceImpl) GetOddByID(ctx context.Context, id int64) (domain.OddMarket, error) { return s.store.GetOddByID(ctx, id) } func (s *ServiceImpl) SaveOddsSetting(ctx context.Context, odd domain.CreateOddMarketSettings) error { return s.store.SaveOddsSetting(ctx, odd) } func (s *ServiceImpl) UpdateGlobalOddsSetting(ctx context.Context, odd domain.UpdateGlobalOddMarketSettings) error { return s.store.UpdateGlobalOddsSetting(ctx, odd) } func (s *ServiceImpl) SaveOddsSettingReq(ctx context.Context, companyID int64, req domain.CreateOddMarketSettingsReq) error { odd, err := s.GetOddsWithSettingsByID(ctx, req.OddMarketID, companyID) if err != nil { return err } newOdds, err := convertRawMessage(odd.RawOdds) if err != nil { return err } if len(req.CustomOdd) != 0 { for _, customOdd := range req.CustomOdd { for _, newOdd := range newOdds { oldRawOddID := getInt(newOdd["id"]) if oldRawOddID == int(customOdd.OddID) { newOdd["odds"] = customOdd.OddValue } } } } return s.SaveOddsSetting(ctx, domain.CreateOddMarketSettings{ CompanyID: companyID, OddMarketID: req.OddMarketID, IsActive: domain.ConvertBoolPtr(req.IsActive), CustomRawOdds: newOdds, }) } func (s *ServiceImpl) GetOddsByMarketID(ctx context.Context, marketID int64, eventID int64) (domain.OddMarket, error) { rows, err := s.store.GetOddsByMarketID(ctx, marketID, eventID) if err != nil { return domain.OddMarket{}, err } return rows, nil } func (s *ServiceImpl) GetOddsWithSettingsByMarketID(ctx context.Context, marketID int64, eventID int64, companyID int64) (domain.OddMarketWithSettings, error) { rows, err := s.store.GetOddsWithSettingsByMarketID(ctx, marketID, eventID, companyID) if err != nil { return domain.OddMarketWithSettings{}, err } return rows, nil } func (s *ServiceImpl) GetOddsWithSettingsByID(ctx context.Context, ID int64, companyID int64) (domain.OddMarketWithSettings, error) { return s.store.GetOddsWithSettingsByID(ctx, ID, companyID) } func (s *ServiceImpl) GetOddsByEventID(ctx context.Context, eventID int64, filter domain.OddMarketWithEventFilter) ([]domain.OddMarket, error) { return s.store.GetOddsByEventID(ctx, eventID, filter) } func (s *ServiceImpl) GetOddsWithSettingsByEventID(ctx context.Context, eventID int64, companyID int64, filter domain.OddMarketFilter) ([]domain.OddMarketWithSettings, error) { return s.store.GetOddsWithSettingsByEventID(ctx, eventID, companyID, filter) } func (s *ServiceImpl) DeleteOddsForEvent(ctx context.Context, eventID int64) error { return s.store.DeleteOddsForEvent(ctx, eventID) } func (s *ServiceImpl) DeleteAllCompanyOddsSetting(ctx context.Context, companyID int64) error { return s.store.DeleteAllCompanyOddsSetting(ctx, companyID) } func (s *ServiceImpl) DeleteCompanyOddsSettingByOddMarketID(ctx context.Context, companyID int64, oddMarketID int64) error { return s.store.DeleteCompanyOddsSettingByOddMarketID(ctx, companyID, oddMarketID) } // func getString(v interface{}) string { // if str, ok := v.(string); ok { // return str // } // return "" // } func getInt(v interface{}) int { if n, ok := v.(float64); ok { return int(n) } return -1 } func getFloat(v interface{}) float64 { if n, ok := v.(float64); ok { return n } return 0 } func getMap(v interface{}) map[string]interface{} { if m, ok := v.(map[string]interface{}); ok { return m } return nil } // func getMapArray(v interface{}) []map[string]interface{} { // result := []map[string]interface{}{} // if arr, ok := v.([]interface{}); ok { // for _, item := range arr { // if m, ok := item.(map[string]interface{}); ok { // result = append(result, m) // } // } // } // return result // } func convertRawMessage(rawMessages []json.RawMessage) ([]map[string]interface{}, error) { var result []map[string]interface{} for _, raw := range rawMessages { var m map[string]interface{} if err := json.Unmarshal(raw, &m); err != nil { return nil, err } result = append(result, m) } return result, nil }