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}, } } var supportedMarkets = map[string]domain.MarketConfig{ "football": { Sport: "football", MarketCategories: map[string]bool{ "main": true, "asian_lines": true, "goals": true, "half": true, }, MarketTypes: map[string]bool{ "full_time_result": true, "double_chance": true, "goals_over_under": true, "correct_score": true, "asian_handicap": true, "goal_line": true, "half_time_result": true, "1st_half_asian_handicap": true, "1st_half_goal_line": true, "first_team_to_score": true, "goals_odd_even": true, "draw_no_bet": true, }, }, } func (s *Service) FetchAndProcessResults(ctx context.Context) error { outcomes, err := s.repo.GetPendingBetOutcomes(ctx) 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 } result, err := s.fetchResult(ctx, outcome.EventID, outcome.OddID, outcome.MarketID, 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 } } 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.ResultResponse 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 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.ResultResponse 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") } result := resultResp.Results[0] if result.TimeStatus != "3" { s.logger.Warn("Match not yet completed", "event_id", eventID) return domain.CreateResult{}, fmt.Errorf("match not yet completed") } finalScore := parseScore(result.SS) firstHalfScore := parseScore(fmt.Sprintf("%s-%s", result.Scores.FirstHalf.Home, result.Scores.FirstHalf.Away)) corners := parseStats(result.Stats.Corners) status, err := s.evaluateOutcome(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 parseScore(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) evaluateOutcome(outcome domain.BetOutcome, finalScore, firstHalfScore struct{ Home, Away int }, corners struct{ Home, Away int }, events []map[string]string) (domain.OutcomeStatus, error) { marketConfig := supportedMarkets["football"] if !marketConfig.MarketTypes[outcome.MarketName] { 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.MarketName { case "full_time_result": return evaluateFullTimeResult(outcome, finalScore) case "goals_over_under": return evaluateGoalsOverUnder(outcome, finalScore) case "correct_score": return evaluateCorrectScore(outcome, finalScore) case "half_time_result": return evaluateHalfTimeResult(outcome, firstHalfScore) case "asian_handicap": return evaluateAsianHandicap(outcome, finalScore) case "goal_line": return evaluateGoalLine(outcome, finalScore) case "1st_half_asian_handicap": return evaluateAsianHandicap(outcome, firstHalfScore) case "1st_half_goal_line": return evaluateGoalLine(outcome, firstHalfScore) case "first_team_to_score": return evaluateFirstTeamToScore(outcome, events) case "goals_odd_even": return evaluateGoalsOddEven(outcome, finalScore) case "double_chance": return evaluateDoubleChance(outcome, finalScore) case "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 evaluateFullTimeResult(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { switch outcome.OddName { case "1": // Home win if score.Home > score.Away { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil case "Draw": if score.Home == score.Away { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil case "2": // Away win if score.Away > score.Home { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil default: return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) } } func evaluateGoalsOverUnder(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { totalGoals := float64(score.Home + score.Away) threshold, err := strconv.ParseFloat(outcome.OddName, 64) if err != nil { return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) } if outcome.OddHeader == "Over" { if totalGoals > threshold { return domain.OUTCOME_STATUS_WIN, nil } else if totalGoals == threshold { return domain.OUTCOME_STATUS_VOID, nil } return domain.OUTCOME_STATUS_LOSS, nil } else if outcome.OddHeader == "Under" { if totalGoals < threshold { return domain.OUTCOME_STATUS_WIN, nil } else if totalGoals == threshold { return domain.OUTCOME_STATUS_VOID, nil } return domain.OUTCOME_STATUS_LOSS, nil } return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) } func evaluateCorrectScore(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { expectedScore := fmt.Sprintf("%d-%d", score.Home, score.Away) if outcome.OddName == expectedScore { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil } func evaluateHalfTimeResult(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { return evaluateFullTimeResult(outcome, score) } func evaluateAsianHandicap(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { handicap, err := strconv.ParseFloat(outcome.OddHandicap, 64) if err != nil { return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid handicap: %s", outcome.OddHandicap) } adjustedHomeScore := float64(score.Home) adjustedAwayScore := float64(score.Away) if outcome.OddHeader == "1" { // Home team adjustedHomeScore += handicap } else if outcome.OddHeader == "2" { // Away team adjustedAwayScore += handicap } else { return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) } if adjustedHomeScore > adjustedAwayScore { if outcome.OddHeader == "1" { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil } else if adjustedHomeScore < adjustedAwayScore { if outcome.OddHeader == "2" { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil } return domain.OUTCOME_STATUS_VOID, nil } func evaluateGoalLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { return evaluateGoalsOverUnder(outcome, score) } func evaluateFirstTeamToScore(outcome domain.BetOutcome, events []map[string]string) (domain.OutcomeStatus, error) { for _, event := range events { if strings.Contains(event["text"], "1st Goal") { if strings.Contains(event["text"], outcome.HomeTeamName) && outcome.OddName == "1" { return domain.OUTCOME_STATUS_WIN, nil } else if strings.Contains(event["text"], outcome.AwayTeamName) && outcome.OddName == "2" { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil } } return domain.OUTCOME_STATUS_VOID, nil // No goals scored } func evaluateGoalsOddEven(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { totalGoals := score.Home + score.Away isOdd := totalGoals%2 == 1 if outcome.OddName == "Odd" && isOdd { return domain.OUTCOME_STATUS_WIN, nil } else if outcome.OddName == "Even" && !isOdd { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil } func evaluateDoubleChance(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { isHomeWin := score.Home > score.Away isDraw := score.Home == score.Away isAwayWin := score.Away > score.Home switch outcome.OddName { case "1 or Draw": if isHomeWin || isDraw { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil case "Draw or 2": if isDraw || isAwayWin { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil case "1 or 2": if isHomeWin || isAwayWin { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil default: return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) } } func evaluateDrawNoBet(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { if score.Home == score.Away { return domain.OUTCOME_STATUS_VOID, nil } if outcome.OddName == "1" && score.Home > score.Away { return domain.OUTCOME_STATUS_WIN, nil } else if outcome.OddName == "2" && score.Away > score.Home { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil }