package result import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "strconv" "strings" "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" ) type Service struct { repo *repository.Store config *config.Config logger *slog.Logger client *http.Client } func NewService(repo *repository.Store, cfg *config.Config, logger *slog.Logger) *Service { return &Service{ repo: repo, config: cfg, logger: logger, client: &http.Client{Timeout: 10 * time.Second}, } } type ResultCheck struct { } func (s *Service) FetchAndProcessResults(ctx context.Context) error { // TODO: Optimize this because there could be many bet outcomes for the same odd // Take market id and match result as param and update all the bet outcomes at the same time events, err := s.repo.GetExpiredUpcomingEvents(ctx) if err != nil { s.logger.Error("Failed to fetch events") return err } for _, event := range events { eventID, err := strconv.ParseInt(event.ID, 10, 64) if err != nil { s.logger.Error("Failed to parse event id") return err } outcomes, err := s.repo.GetBetOutcomeByEventID(ctx, eventID) if err != nil { s.logger.Error("Failed to get pending bet outcomes", "error", err) return err } for _, outcome := range outcomes { if outcome.Expires.After(time.Now()) { continue } sportID, err := strconv.ParseInt(event.SportID, 10, 64) if err != nil { s.logger.Error("Sport ID is invalid", "event_id", outcome.EventID, "error", err) continue } result, err := s.fetchResult(ctx, outcome.EventID, outcome.OddID, outcome.MarketID, sportID, outcome) if err != nil { s.logger.Error("Failed to fetch result", "event_id", outcome.EventID, "error", err) continue } // _, err = s.repo.CreateResult(ctx, domain.CreateResult{ // BetOutcomeID: outcome.ID, // EventID: outcome.EventID, // OddID: outcome.OddID, // MarketID: outcome.MarketID, // Status: result.Status, // Score: result.Score, // }) // if err != nil { // s.logger.Error("Failed to store result", "bet_outcome_id", outcome.ID, "error", err) // continue // } 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 } } err = s.repo.DeleteEvent(ctx, event.ID) if err != nil { s.logger.Error("Failed to remove event", "event_id", event.ID, "error", err) return err } } return nil } // func (s *Service) FetchAndStoreResult(ctx context.Context, eventID string) error { // url := fmt.Sprintf("https://api.b365api.com/v1/bet365/result?token=%s&event_id=%s", s.config.Bet365Token, eventID) // res, err := s.client.Get(url) // if err != nil { // s.logger.Error("Failed to fetch result", "event_id", eventID, "error", err) // return fmt.Errorf("failed to fetch result: %w", err) // } // defer res.Body.Close() // if res.StatusCode != http.StatusOK { // s.logger.Error("Unexpected status code", "event_id", eventID, "status_code", res.StatusCode) // return fmt.Errorf("unexpected status code: %d", res.StatusCode) // } // var apiResp domain.BaseResultResponse // if err := json.NewDecoder(res.Body).Decode(&apiResp); err != nil { // s.logger.Error("Failed to decode result", "event_id", eventID, "error", err) // return fmt.Errorf("failed to decode result: %w", err) // } // if apiResp.Success != 1 || len(apiResp.Results) == 0 { // s.logger.Error("Invalid API response", "event_id", eventID) // return fmt.Errorf("no result returned from API") // } // r := apiResp.Results[0] // if r.TimeStatus != "3" { // s.logger.Warn("Match not yet completed", "event_id", eventID) // return fmt.Errorf("match not yet completed") // } // eventIDInt, err := strconv.ParseInt(eventID, 10, 64) // if err != nil { // s.logger.Error("Failed to parse event_id", "event_id", eventID, "error", err) // return fmt.Errorf("failed to parse event_id: %w", err) // } // halfScore := "" // if r.Scores.FirstHalf.Home != "" { // halfScore = fmt.Sprintf("%s-%s", r.Scores.FirstHalf.Home, r.Scores.FirstHalf.Away) // } // result := domain.Result{ // EventID: eventIDInt, // Status: domain.OUTCOME_STATUS_PENDING, // Score: r.SS, // FullTimeScore: r.SS, // HalfTimeScore: halfScore, // SS: r.SS, // Scores: make(map[string]domain.Score), // } // for k, v := range map[string]domain.Score{ // "1": r.Scores.FirstHalf, // "2": r.Scores.SecondHalf, // } { // result.Scores[k] = domain.Score{ // Home: v.Home, // Away: v.Away, // } // } // return s.repo.InsertResult(ctx, result) // } func (s *Service) fetchResult(ctx context.Context, eventID, oddID, marketID, sportID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { url := fmt.Sprintf("https://api.b365api.com/v1/bet365/result?token=%s&event_id=%d", s.config.Bet365Token, eventID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { s.logger.Error("Failed to create request", "event_id", eventID, "error", err) return domain.CreateResult{}, err } resp, err := s.client.Do(req) if err != nil { s.logger.Error("Failed to fetch result", "event_id", eventID, "error", err) return domain.CreateResult{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { s.logger.Error("Unexpected status code", "event_id", eventID, "status_code", resp.StatusCode) return domain.CreateResult{}, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } var resultResp domain.BaseResultResponse if err := json.NewDecoder(resp.Body).Decode(&resultResp); err != nil { s.logger.Error("Failed to decode result", "event_id", eventID, "error", err) return domain.CreateResult{}, err } if resultResp.Success != 1 || len(resultResp.Results) == 0 { s.logger.Error("Invalid API response", "event_id", eventID) return domain.CreateResult{}, fmt.Errorf("invalid API response") } var result domain.CreateResult switch sportID { case domain.FOOTBALL: result, err = s.parseFootball(resultResp.Results[0], eventID, oddID, marketID, outcome) if err != nil { s.logger.Error("Failed to parse football", "event_id", eventID, "market_id", marketID, "error", err) return domain.CreateResult{}, err } case domain.BASKETBALL: result, err = s.parseBasketball(resultResp.Results[0], eventID, oddID, marketID, outcome) if err != nil { s.logger.Error("Failed to parse basketball", "event_id", eventID, "market_id", marketID, "error", err) return domain.CreateResult{}, err } default: s.logger.Error("Unsupported sport", "sport", sportID) return domain.CreateResult{}, fmt.Errorf("unsupported sport: %v", sportID) } return result, nil } func (s *Service) parseFootball(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { var fbResp domain.FootballResultResponse if err := json.Unmarshal(resultRes, &fbResp); err != nil { s.logger.Error("Failed to unmarshal football result", "event_id", eventID, "error", err) return domain.CreateResult{}, err } result := fbResp if result.TimeStatus != "3" { s.logger.Warn("Match not yet completed", "event_id", eventID) return domain.CreateResult{}, fmt.Errorf("match not yet completed") } finalScore := parseSS(result.SS) firstHalfScore := parseSS(fmt.Sprintf("%s-%s", result.Scores.FirstHalf.Home, result.Scores.FirstHalf.Away)) corners := parseStats(result.Stats.Corners) status, err := s.evaluateFootballOutcome(outcome, finalScore, firstHalfScore, corners, result.Events) if err != nil { s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err) return domain.CreateResult{}, err } return domain.CreateResult{ BetOutcomeID: 0, EventID: eventID, OddID: oddID, MarketID: marketID, Status: status, Score: result.SS, }, nil } func (s *Service) parseBasketball(response json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { var basketBallRes domain.BasketballResultResponse if err := json.Unmarshal(response, &basketBallRes); err != nil { s.logger.Error("Failed to unmarshal football result", "event_id", eventID, "error", err) return domain.CreateResult{}, err } if basketBallRes.TimeStatus != "3" { s.logger.Warn("Match not yet completed", "event_id", eventID) return domain.CreateResult{}, fmt.Errorf("match not yet completed") } status, err := s.evaluateBasketballOutcome(outcome, basketBallRes) if err != nil { s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err) return domain.CreateResult{}, err } return domain.CreateResult{ BetOutcomeID: 0, EventID: eventID, OddID: oddID, MarketID: marketID, Status: status, Score: basketBallRes.SS, }, nil } func parseScore(home string, away string) struct{ Home, Away int } { homeVal, _ := strconv.Atoi(strings.TrimSpace(home)) awaVal, _ := strconv.Atoi(strings.TrimSpace(away)) return struct{ Home, Away int }{Home: homeVal, Away: awaVal} } func parseSS(scoreStr string) struct{ Home, Away int } { parts := strings.Split(scoreStr, "-") if len(parts) != 2 { return struct{ Home, Away int }{0, 0} } home, _ := strconv.Atoi(strings.TrimSpace(parts[0])) away, _ := strconv.Atoi(strings.TrimSpace(parts[1])) return struct{ Home, Away int }{Home: home, Away: away} } func parseStats(stats []string) struct{ Home, Away int } { if len(stats) != 2 { return struct{ Home, Away int }{0, 0} } home, _ := strconv.Atoi(stats[0]) away, _ := strconv.Atoi(stats[1]) return struct{ Home, Away int }{Home: home, Away: away} } // evaluateOutcome determines the outcome status based on market type and odd func (s *Service) evaluateFootballOutcome(outcome domain.BetOutcome, finalScore, firstHalfScore struct{ Home, Away int }, corners struct{ Home, Away int }, events []map[string]string) (domain.OutcomeStatus, error) { marketConfig := domain.SupportedMarkets["football"] if !marketConfig.MarketTypes[outcome.MarketID] { s.logger.Warn("Unsupported market type", "market_name", outcome.MarketName) return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("unsupported market type: %s", outcome.MarketName) } switch outcome.MarketID { case int64(domain.FOOTBALL_FULL_TIME_RESULT): return evaluateFullTimeResult(outcome, finalScore) case int64(domain.FOOTBALL_GOALS_OVER_UNDER): return evaluateGoalsOverUnder(outcome, finalScore) case int64(domain.FOOTBALL_CORRECT_SCORE): return evaluateCorrectScore(outcome, finalScore) case int64(domain.FOOTBALL_HALF_TIME_RESULT): return evaluateHalfTimeResult(outcome, firstHalfScore) case int64(domain.FOOTBALL_ASIAN_HANDICAP): return evaluateAsianHandicap(outcome, finalScore) case int64(domain.FOOTBALL_GOAL_LINE): return evaluateGoalLine(outcome, finalScore) case int64(domain.FOOTBALL_FIRST_HALF_ASIAN_HANDICAP): return evaluateAsianHandicap(outcome, firstHalfScore) case int64(domain.FOOTBALL_FIRST_HALF_GOAL_LINE): return evaluateGoalLine(outcome, firstHalfScore) case int64(domain.FOOTBALL_FIRST_TEAM_TO_SCORE): return evaluateFirstTeamToScore(outcome, events) case int64(domain.FOOTBALL_GOALS_ODD_EVEN): return evaluateGoalsOddEven(outcome, finalScore) case int64(domain.FOOTBALL_DOUBLE_CHANCE): return evaluateDoubleChance(outcome, finalScore) case int64(domain.FOOTBALL_DRAW_NO_BET): return evaluateDrawNoBet(outcome, finalScore) default: s.logger.Warn("Market type not implemented", "market_name", outcome.MarketName) return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("market type not implemented: %s", outcome.MarketName) } } func (s *Service) evaluateBasketballOutcome(outcome domain.BetOutcome, res domain.BasketballResultResponse) (domain.OutcomeStatus, error) { marketConfig := domain.SupportedMarkets["basketball"] if !marketConfig.MarketTypes[outcome.MarketID] { s.logger.Warn("Unsupported market type", "market_name", outcome.MarketName) return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("unsupported market type: %s", outcome.MarketName) } finalScore := parseSS(res.SS) firstHalfScore := parseScore(res.Scores.FirstHalf.Home, res.Scores.FirstHalf.Away) secondHalfScore := struct{ Home, Away int }{Home: finalScore.Home - firstHalfScore.Home, Away: finalScore.Away - firstHalfScore.Away} firstQuarter := parseScore(res.Scores.FirstQuarter.Home, res.Scores.FirstQuarter.Away) secondQuarter := parseScore(res.Scores.SecondQuarter.Home, res.Scores.SecondQuarter.Away) thirdQuarter := parseScore(res.Scores.ThirdQuarter.Home, res.Scores.ThirdQuarter.Away) fourthQuarter := parseScore(res.Scores.FourthQuarter.Home, res.Scores.FourthQuarter.Away) switch outcome.MarketID { case int64(domain.BASKETBALL_GAME_LINES): return evaluateGameLines(outcome, finalScore) case int64(domain.BASKETBALL_RESULT_AND_BOTH_TEAMS_TO_SCORE_X_POINTS): return evaluateResultAndBTTSX(outcome, finalScore) case int64(domain.BASKETBALL_DOUBLE_RESULT): return evaluateDoubleResult(outcome, firstHalfScore, secondHalfScore) case int64(domain.BASKETBALL_MATCH_RESULT_AND_TOTAL): return evaluateResultAndTotal(outcome, finalScore) case int64(domain.BASKETBALL_MATCH_HANDICAP_AND_TOTAL): return evaluateHandicapAndTotal(outcome, finalScore) case int64(domain.BASKETBALL_GAME_TOTAL_ODD_EVEN): return evaluateGoalsOddEven(outcome, finalScore) case int64(domain.BASKETBALL_TEAM_TOTALS): return evaluateGoalsOddEven(outcome, finalScore) case int64(domain.BASKETBALL_FIRST_HALF): return evaluateGameLines(outcome, firstHalfScore) case int64(domain.BASKETBALL_FIRST_HALF_TEAM_TOTALS): return evaluateTeamTotal(outcome, firstHalfScore) case int64(domain.BASKETBALL_FIRST_HALF_HANDICAP_AND_TOTAL): return evaluateHandicapAndTotal(outcome, firstHalfScore) case int64(domain.BASKETBALL_FIRST_HALF_BOTH_TEAMS_TO_SCORE_X_POINTS): return evaluateBTTSX(outcome, firstHalfScore) case int64(domain.BASKETBALL_FIRST_HALF_DOUBLE_CHANCE): return evaluateDoubleChance(outcome, firstHalfScore) case int64(domain.BASKETBALL_FIRST_HALF_TOTAL_ODD_EVEN): return evaluateGoalsOddEven(outcome, firstHalfScore) case int64(domain.BASKETBALL_FIRST_HALF_MONEY_LINE_3_WAY): return evaluateMoneyLine3Way(outcome, firstHalfScore) case int64(domain.BASKETBALL_HIGHEST_SCORING_HALF): return evaluateHighestScoringHalf(outcome, firstHalfScore, secondHalfScore) case int64(domain.BASKETBALL_FIRST_QUARTER): return evaluateGameLines(outcome, firstQuarter) case int64(domain.BASKETBALL_FIRST_QUARTER_TEAM_TOTALS): return evaluateTeamTotal(outcome, firstQuarter) case int64(domain.BASKETBALL_FIRST_QUARTER_TOTAL_ODD_EVEN): return evaluateGoalsOddEven(outcome, firstQuarter) case int64(domain.BASKETBALL_FIRST_QUARTER_HANDICAP_AND_TOTAL): return evaluateHandicapAndTotal(outcome, firstQuarter) case int64(domain.BASKETBALL_FIRST_QUARTER_DOUBLE_CHANCE): return evaluateDoubleChance(outcome, firstQuarter) case int64(domain.BASKETBALL_HIGHEST_SCORING_QUARTER): return evaluateHighestScoringQuarter(outcome, firstQuarter, secondQuarter, thirdQuarter, fourthQuarter) default: s.logger.Warn("Market type not implemented", "market_name", outcome.MarketName) return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("market type not implemented: %s", outcome.MarketName) } }