diff --git a/go.mod b/go.mod index a510af6..308822c 100644 --- a/go.mod +++ b/go.mod @@ -7,23 +7,27 @@ require ( github.com/bytedance/sonic v1.13.2 github.com/go-playground/validator/v10 v10.26.0 github.com/gofiber/fiber/v2 v2.52.6 - github.com/gofiber/websocket/v2 v2.2.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.4 github.com/joho/godotenv v1.5.1 github.com/robfig/cron/v3 v3.0.1 + github.com/stretchr/testify v1.10.0 github.com/swaggo/fiber-swagger v1.3.0 github.com/swaggo/swag v1.16.4 golang.org/x/crypto v0.36.0 ) +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect +) + require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect - github.com/fasthttp/websocket v1.5.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect @@ -31,7 +35,7 @@ require ( github.com/go-openapi/swag v0.23.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/gofiber/contrib/websocket v1.3.4 + github.com/gorilla/websocket v1.5.3 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -45,11 +49,10 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.59.0 // indirect + github.com/valyala/fasthttp v1.59.0 golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/sync v0.12.0 // indirect diff --git a/go.sum b/go.sum index 9e77972..ab1ac26 100644 --- a/go.sum +++ b/go.sum @@ -22,10 +22,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek= -github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs= -github.com/fasthttp/websocket v1.5.8 h1:k5DpirKkftIF/w1R8ZzjSgARJrs54Je9YJK37DL/Ah8= -github.com/fasthttp/websocket v1.5.8/go.mod h1:d08g8WaT6nnyvg9uMm8K9zMYyDjfKyj3170AtPRuVU0= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -51,18 +47,16 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/gofiber/contrib/websocket v1.3.4 h1:tWeBdbJ8q0WFQXariLN4dBIbGH9KBU75s0s7YXplOSg= -github.com/gofiber/contrib/websocket v1.3.4/go.mod h1:kTFBPC6YENCnKfKx0BoOFjgXxdz7E85/STdkmZPEmPs= github.com/gofiber/fiber/v2 v2.32.0/go.mod h1:CMy5ZLiXkn6qwthrl03YMyW1NLfj0rhxz2LKl4t7ZTY= github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= -github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w= -github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -118,10 +112,6 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= -github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= -github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8= -github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= diff --git a/internal/domain/sportmarket.go b/internal/domain/sportmarket.go index b6fde09..4002f88 100644 --- a/internal/domain/sportmarket.go +++ b/internal/domain/sportmarket.go @@ -91,6 +91,38 @@ const ( ICE_HOCKEY_ALTERNATIVE_TOTAL_TWO_WAY IceHockeyMarket = 170240 ) +type AmericanFootballMarket int64 + +const ( + // Main + AMERICAN_FOOTBALL_MONEY_LINE AmericanFootballMarket = 170001 + AMERICAN_FOOTBALL_SPREAD AmericanFootballMarket = 170002 + AMERICAN_FOOTBALL_TOTAL_POINTS AmericanFootballMarket = 170003 +) + +type RugbyMarket int64 + +const ( + // Main + RUGBY_MONEY_LINE RugbyMarket = 180001 + RUGBY_SPREAD RugbyMarket = 180002 + RUGBY_TOTAL_POINTS RugbyMarket = 180003 + RUGBY_HANDICAP RugbyMarket = 180004 + RUGBY_FIRST_HALF RugbyMarket = 180005 + RUGBY_SECOND_HALF RugbyMarket = 180006 +) + +type BaseballMarket int64 + +const ( + // Main + BASEBALL_MONEY_LINE BaseballMarket = 190001 + BASEBALL_SPREAD BaseballMarket = 190002 + BASEBALL_TOTAL_RUNS BaseballMarket = 190003 + BASEBALL_FIRST_INNING BaseballMarket = 190004 + BASEBALL_FIRST_5_INNINGS BaseballMarket = 190005 +) + // TODO: Move this into the database so that it can be modified dynamically var SupportedMarkets = map[int64]bool{ @@ -164,4 +196,24 @@ var SupportedMarkets = map[int64]bool{ int64(ICE_HOCKEY_ALTERNATIVE_PUCK_LINE_TWO_WAY): false, int64(ICE_HOCKEY_ALTERNATIVE_TOTAL_TWO_WAY): false, + + // American Football Markets + int64(AMERICAN_FOOTBALL_MONEY_LINE): true, + int64(AMERICAN_FOOTBALL_SPREAD): true, + int64(AMERICAN_FOOTBALL_TOTAL_POINTS): true, + + // Rugby Markets + int64(RUGBY_MONEY_LINE): true, + int64(RUGBY_SPREAD): true, + int64(RUGBY_TOTAL_POINTS): true, + int64(RUGBY_HANDICAP): true, + int64(RUGBY_FIRST_HALF): true, + int64(RUGBY_SECOND_HALF): true, + + // Baseball Markets + int64(BASEBALL_MONEY_LINE): true, + int64(BASEBALL_SPREAD): true, + int64(BASEBALL_TOTAL_RUNS): true, + int64(BASEBALL_FIRST_INNING): true, + int64(BASEBALL_FIRST_5_INNINGS): true, } diff --git a/internal/domain/sports_result.go b/internal/domain/sports_result.go new file mode 100644 index 0000000..448c4de --- /dev/null +++ b/internal/domain/sports_result.go @@ -0,0 +1,290 @@ +package domain + +import ( + "encoding/json" + "strconv" + "strings" +) + +// NFLResultResponse represents the structure for NFL game results +type NFLResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League struct { + ID string `json:"id"` + Name string `json:"name"` + CC string `json:"cc"` + } `json:"league"` + Home struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"home"` + Away struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstQuarter Score `json:"1"` + SecondQuarter Score `json:"2"` + ThirdQuarter Score `json:"3"` + FourthQuarter Score `json:"4"` + Overtime Score `json:"5"` + TotalScore Score `json:"7"` + } `json:"scores"` + Stats struct { + FirstDowns []string `json:"first_downs"` + TotalYards []string `json:"total_yards"` + PassingYards []string `json:"passing_yards"` + RushingYards []string `json:"rushing_yards"` + Turnovers []string `json:"turnovers"` + TimeOfPossession []string `json:"time_of_possession"` + ThirdDownEfficiency []string `json:"third_down_efficiency"` + FourthDownEfficiency []string `json:"fourth_down_efficiency"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + StadiumData map[string]string `json:"stadium_data"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} + +// RugbyResultResponse represents the structure for Rugby game results +type RugbyResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League struct { + ID string `json:"id"` + Name string `json:"name"` + CC string `json:"cc"` + } `json:"league"` + Home struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"home"` + Away struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstHalf Score `json:"1"` + SecondHalf Score `json:"2"` + TotalScore Score `json:"7"` + } `json:"scores"` + Stats struct { + Tries []string `json:"tries"` + Conversions []string `json:"conversions"` + Penalties []string `json:"penalties"` + DropGoals []string `json:"drop_goals"` + Possession []string `json:"possession"` + Territory []string `json:"territory"` + Lineouts []string `json:"lineouts"` + Scrums []string `json:"scrums"` + PenaltiesConceded []string `json:"penalties_conceded"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + StadiumData map[string]string `json:"stadium_data"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} + +// BaseballResultResponse represents the structure for Baseball game results +type BaseballResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League struct { + ID string `json:"id"` + Name string `json:"name"` + CC string `json:"cc"` + } `json:"league"` + Home struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"home"` + Away struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstInning Score `json:"1"` + SecondInning Score `json:"2"` + ThirdInning Score `json:"3"` + FourthInning Score `json:"4"` + FifthInning Score `json:"5"` + SixthInning Score `json:"6"` + SeventhInning Score `json:"7"` + EighthInning Score `json:"8"` + NinthInning Score `json:"9"` + ExtraInnings Score `json:"10"` + TotalScore Score `json:"7"` + } `json:"scores"` + Stats struct { + Hits []string `json:"hits"` + Errors []string `json:"errors"` + LeftOnBase []string `json:"left_on_base"` + Strikeouts []string `json:"strikeouts"` + Walks []string `json:"walks"` + HomeRuns []string `json:"home_runs"` + TotalBases []string `json:"total_bases"` + BattingAverage []string `json:"batting_average"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + StadiumData map[string]string `json:"stadium_data"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} + +// ParseNFLResult parses NFL result from raw JSON data +func ParseNFLResult(data json.RawMessage) (*NFLResultResponse, error) { + var result NFLResultResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ParseRugbyResult parses Rugby result from raw JSON data +func ParseRugbyResult(data json.RawMessage) (*RugbyResultResponse, error) { + var result RugbyResultResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ParseRugbyUnionResult parses Rugby Union result from raw JSON data +func ParseRugbyUnionResult(data json.RawMessage) (*RugbyResultResponse, error) { + return ParseRugbyResult(data) +} + +// ParseRugbyLeagueResult parses Rugby League result from raw JSON data +func ParseRugbyLeagueResult(data json.RawMessage) (*RugbyResultResponse, error) { + return ParseRugbyResult(data) +} + +// ParseBaseballResult parses Baseball result from raw JSON data +func ParseBaseballResult(data json.RawMessage) (*BaseballResultResponse, error) { + var result BaseballResultResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetNFLWinner determines the winner of an NFL game +func GetNFLWinner(result *NFLResultResponse) (string, error) { + homeScore, err := strconv.Atoi(result.Scores.TotalScore.Home) + if err != nil { + return "", err + } + awayScore, err := strconv.Atoi(result.Scores.TotalScore.Away) + if err != nil { + return "", err + } + + if homeScore > awayScore { + return result.Home.Name, nil + } else if awayScore > homeScore { + return result.Away.Name, nil + } + return "Draw", nil +} + +// GetRugbyWinner determines the winner of a Rugby game +func GetRugbyWinner(result *RugbyResultResponse) (string, error) { + homeScore, err := strconv.Atoi(result.Scores.TotalScore.Home) + if err != nil { + return "", err + } + awayScore, err := strconv.Atoi(result.Scores.TotalScore.Away) + if err != nil { + return "", err + } + + if homeScore > awayScore { + return result.Home.Name, nil + } else if awayScore > homeScore { + return result.Away.Name, nil + } + return "Draw", nil +} + +// GetBaseballWinner determines the winner of a Baseball game +func GetBaseballWinner(result *BaseballResultResponse) (string, error) { + homeScore, err := strconv.Atoi(result.Scores.TotalScore.Home) + if err != nil { + return "", err + } + awayScore, err := strconv.Atoi(result.Scores.TotalScore.Away) + if err != nil { + return "", err + } + + if homeScore > awayScore { + return result.Home.Name, nil + } else if awayScore > homeScore { + return result.Away.Name, nil + } + return "Draw", nil +} + +// FormatNFLScore formats the NFL score in a readable format +func FormatNFLScore(result *NFLResultResponse) string { + return strings.Join([]string{ + result.Home.Name + " " + result.Scores.TotalScore.Home, + result.Away.Name + " " + result.Scores.TotalScore.Away, + }, " - ") +} + +// FormatRugbyScore formats the Rugby score in a readable format +func FormatRugbyScore(result *RugbyResultResponse) string { + return strings.Join([]string{ + result.Home.Name + " " + result.Scores.TotalScore.Home, + result.Away.Name + " " + result.Scores.TotalScore.Away, + }, " - ") +} + +// FormatBaseballScore formats the Baseball score in a readable format +func FormatBaseballScore(result *BaseballResultResponse) string { + return strings.Join([]string{ + result.Home.Name + " " + result.Scores.TotalScore.Home, + result.Away.Name + " " + result.Scores.TotalScore.Away, + }, " - ") +} diff --git a/internal/services/notfication/service.go b/internal/services/notfication/service.go index 9c5597e..368e637 100644 --- a/internal/services/notfication/service.go +++ b/internal/services/notfication/service.go @@ -266,3 +266,4 @@ func (s *Service) retryFailedNotifications() { }(notification) } } + diff --git a/internal/services/result/eval.go b/internal/services/result/eval.go index 8096e3a..1abce5d 100644 --- a/internal/services/result/eval.go +++ b/internal/services/result/eval.go @@ -709,3 +709,34 @@ func evaluateTiedAfterRegulation(outcome domain.BetOutcome, scores []struct{ Hom return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", outcome.OddName) } +// evaluateRugbyOutcome evaluates the outcome of a Rugby bet +func evaluateRugbyOutcome(outcome domain.BetOutcome, result *domain.RugbyResultResponse) (domain.OutcomeStatus, error) { + finalScore := parseSS(result.SS) + + switch outcome.MarketName { + case "Money Line": + return evaluateRugbyMoneyLine(outcome, finalScore) + case "Spread": + return evaluateRugbySpread(outcome, finalScore) + case "Total Points": + return evaluateRugbyTotalPoints(outcome, finalScore) + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("unsupported rugby market: %s", outcome.MarketName) + } +} + +// evaluateBaseballOutcome evaluates the outcome of a Baseball bet +func evaluateBaseballOutcome(outcome domain.BetOutcome, result *domain.BaseballResultResponse) (domain.OutcomeStatus, error) { + finalScore := parseSS(result.SS) + + switch outcome.MarketName { + case "Money Line": + return evaluateBaseballMoneyLine(outcome, finalScore) + case "Spread": + return evaluateBaseballSpread(outcome, finalScore) + case "Total Runs": + return evaluateBaseballTotalRuns(outcome, finalScore) + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("unsupported baseball market: %s", outcome.MarketName) + } +} diff --git a/internal/services/result/service.go b/internal/services/result/service.go index 74983cb..b0cef8e 100644 --- a/internal/services/result/service.go +++ b/internal/services/result/service.go @@ -202,30 +202,24 @@ func (s *Service) fetchResult(ctx context.Context, eventID, oddID, marketID, spo 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 - } case domain.ICE_HOCKEY: result, err = s.parseIceHockey(resultResp.Results[0], eventID, oddID, marketID, outcome) - if err != nil { - s.logger.Error("Failed to parse ice hockey", "event id", eventID, "market_id", marketID, "error", err) - return domain.CreateResult{}, err - } + case domain.AMERICAN_FOOTBALL: + result, err = s.parseNFL(resultResp.Results[0], eventID, oddID, marketID, outcome) + case domain.RUGBY_UNION: + result, err = s.parseRugbyUnion(resultResp.Results[0], eventID, oddID, marketID, outcome) + case domain.RUGBY_LEAGUE: + result, err = s.parseRugbyLeague(resultResp.Results[0], eventID, oddID, marketID, outcome) + case domain.BASEBALL: + result, err = s.parseBaseball(resultResp.Results[0], eventID, oddID, marketID, outcome) 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) { @@ -317,6 +311,124 @@ func (s *Service) parseIceHockey(response json.RawMessage, eventID, oddID, marke } +func (s *Service) parseNFL(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { + var nflResp domain.NFLResultResponse + if err := json.Unmarshal(resultRes, &nflResp); err != nil { + s.logger.Error("Failed to unmarshal NFL result", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + + if nflResp.TimeStatus != "3" { + s.logger.Warn("Match not yet completed", "event_id", eventID) + return domain.CreateResult{}, fmt.Errorf("match not yet completed") + } + + finalScore := parseSS(nflResp.SS) + + var status domain.OutcomeStatus + var err error + + switch outcome.MarketName { + case "Money Line": + status, err = evaluateNFLMoneyLine(outcome, finalScore) + case "Spread": + status, err = evaluateNFLSpread(outcome, finalScore) + case "Total Points": + status, err = evaluateNFLTotalPoints(outcome, finalScore) + default: + return domain.CreateResult{}, fmt.Errorf("unsupported market: %s", outcome.MarketName) + } + + 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: outcome.ID, + EventID: eventID, + OddID: oddID, + MarketID: marketID, + Status: status, + Score: nflResp.SS, + }, nil +} + +func (s *Service) parseRugbyUnion(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { + var rugbyResp domain.RugbyResultResponse + if err := json.Unmarshal(resultRes, &rugbyResp); err != nil { + s.logger.Error("Failed to unmarshal Rugby Union result", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + if rugbyResp.TimeStatus != "3" { + s.logger.Warn("Match not yet completed", "event_id", eventID) + return domain.CreateResult{}, fmt.Errorf("match not yet completed") + } + status, err := evaluateRugbyOutcome(outcome, &rugbyResp) + 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: outcome.ID, + EventID: eventID, + OddID: oddID, + MarketID: marketID, + Status: status, + Score: rugbyResp.SS, + }, nil +} + +func (s *Service) parseRugbyLeague(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { + var rugbyResp domain.RugbyResultResponse + if err := json.Unmarshal(resultRes, &rugbyResp); err != nil { + s.logger.Error("Failed to unmarshal Rugby League result", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + if rugbyResp.TimeStatus != "3" { + s.logger.Warn("Match not yet completed", "event_id", eventID) + return domain.CreateResult{}, fmt.Errorf("match not yet completed") + } + status, err := evaluateRugbyOutcome(outcome, &rugbyResp) + 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: outcome.ID, + EventID: eventID, + OddID: oddID, + MarketID: marketID, + Status: status, + Score: rugbyResp.SS, + }, nil +} + +func (s *Service) parseBaseball(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { + var baseballResp domain.BaseballResultResponse + if err := json.Unmarshal(resultRes, &baseballResp); err != nil { + s.logger.Error("Failed to unmarshal Baseball result", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + if baseballResp.TimeStatus != "3" { + s.logger.Warn("Match not yet completed", "event_id", eventID) + return domain.CreateResult{}, fmt.Errorf("match not yet completed") + } + status, err := evaluateBaseballOutcome(outcome, &baseballResp) + 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: outcome.ID, + EventID: eventID, + OddID: oddID, + MarketID: marketID, + Status: status, + Score: baseballResp.SS, + }, nil +} + func parseScore(home string, away string) struct{ Home, Away int } { homeVal, _ := strconv.Atoi(strings.TrimSpace(home)) awaVal, _ := strconv.Atoi(strings.TrimSpace(away)) @@ -487,3 +599,22 @@ func (s *Service) evaluateIceHockeyOutcome(outcome domain.BetOutcome, res domain return domain.OUTCOME_STATUS_PENDING, nil } + +func (s *Service) evaluateNFLOutcome(outcome domain.BetOutcome, finalScore struct{ Home, Away int }) (domain.OutcomeStatus, error) { + if !domain.SupportedMarkets[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.AMERICAN_FOOTBALL_MONEY_LINE): + return evaluateNFLMoneyLine(outcome, finalScore) + case int64(domain.AMERICAN_FOOTBALL_SPREAD): + return evaluateNFLSpread(outcome, finalScore) + case int64(domain.AMERICAN_FOOTBALL_TOTAL_POINTS): + return evaluateNFLTotalPoints(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) + } +} diff --git a/internal/services/result/sports_eval.go b/internal/services/result/sports_eval.go new file mode 100644 index 0000000..eeb23f7 --- /dev/null +++ b/internal/services/result/sports_eval.go @@ -0,0 +1,280 @@ +package result + +import ( + "fmt" + "strconv" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +// NFL evaluations +func evaluateNFLMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + switch outcome.OddHeader { + case "1": + if score.Home > score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "2": + 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 header: %s", outcome.OddHeader) + } +} + +func evaluateNFLSpread(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" { + adjustedHomeScore += handicap + } else if outcome.OddHeader == "2" { + 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 evaluateNFLTotalPoints(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + totalPoints := 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 totalPoints > threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalPoints == threshold { + return domain.OUTCOME_STATUS_VOID, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if outcome.OddHeader == "Under" { + if totalPoints < threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalPoints == 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) +} + +// evaluateRugbyMoneyLine evaluates Rugby money line bets +func evaluateRugbyMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + switch outcome.OddHeader { + case "1": + if score.Home > score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "2": + 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 header: %s", outcome.OddHeader) + } +} + +// evaluateRugbySpread evaluates Rugby spread bets +func evaluateRugbySpread(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" { + adjustedHomeScore += handicap + } else if outcome.OddHeader == "2" { + 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 +} + +// evaluateRugbyTotalPoints evaluates Rugby total points bets +func evaluateRugbyTotalPoints(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + totalPoints := 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 totalPoints > threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalPoints == threshold { + return domain.OUTCOME_STATUS_VOID, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if outcome.OddHeader == "Under" { + if totalPoints < threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalPoints == 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) +} + +// evaluateBaseballMoneyLine evaluates Baseball money line bets +func evaluateBaseballMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + switch outcome.OddHeader { + case "1": + if score.Home > score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "2": + 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 header: %s", outcome.OddHeader) + } +} + +// evaluateBaseballSpread evaluates Baseball spread bets +func evaluateBaseballSpread(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" { + adjustedHomeScore += handicap + } else if outcome.OddHeader == "2" { + 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 +} + +// evaluateBaseballTotalRuns evaluates Baseball total runs bets +func evaluateBaseballTotalRuns(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + totalRuns := 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 totalRuns > threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalRuns == threshold { + return domain.OUTCOME_STATUS_VOID, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if outcome.OddHeader == "Under" { + if totalRuns < threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalRuns == 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) +} + +// evaluateBaseballFirstInning evaluates Baseball first inning bets +func evaluateBaseballFirstInning(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + switch outcome.OddHeader { + case "1": + if score.Home > score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "2": + if score.Away > score.Home { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "X": + if score.Home == score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } +} + +// evaluateBaseballFirst5Innings evaluates Baseball first 5 innings bets +func evaluateBaseballFirst5Innings(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + switch outcome.OddHeader { + case "1": + if score.Home > score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "2": + if score.Away > score.Home { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "X": + if score.Home == score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } +} diff --git a/internal/services/result/sports_eval_test.go b/internal/services/result/sports_eval_test.go new file mode 100644 index 0000000..f300879 --- /dev/null +++ b/internal/services/result/sports_eval_test.go @@ -0,0 +1,303 @@ +package result + +import ( + "testing" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/stretchr/testify/assert" +) + +// TestNFLMarkets covers all American Football (NFL) market types defined in the domain. +// For each market (Money Line, Spread, Total Points), it tests home/away win, draw, void, and invalid input scenarios. +func TestNFLMarkets(t *testing.T) { + t.Log("Testing NFL (American Football) Markets") + markets := []struct { + marketID int64 + name string + }{ + {int64(domain.AMERICAN_FOOTBALL_MONEY_LINE), "MONEY_LINE"}, + {int64(domain.AMERICAN_FOOTBALL_SPREAD), "SPREAD"}, + {int64(domain.AMERICAN_FOOTBALL_TOTAL_POINTS), "TOTAL_POINTS"}, + } + + for _, m := range markets { + t.Run(m.name, func(t *testing.T) { + // Each subtest below covers a key scenario for the given NFL market. + switch m.marketID { + case int64(domain.AMERICAN_FOOTBALL_MONEY_LINE): + // Home win, away win, draw, and invalid OddHeader for Money Line + t.Run("Home Win", func(t *testing.T) { + status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 21, Away: 14}) + t.Logf("Market: %s, Scenario: Home Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win", func(t *testing.T) { + status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2"}, struct{ Home, Away int }{Home: 14, Away: 21}) + t.Logf("Market: %s, Scenario: Away Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Draw", func(t *testing.T) { + status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 17, Away: 17}) + t.Logf("Market: %s, Scenario: Draw", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_LOSS, status) + }) + t.Run("Invalid OddHeader", func(t *testing.T) { + status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "X"}, struct{ Home, Away int }{Home: 10, Away: 7}) + t.Logf("Market: %s, Scenario: Invalid OddHeader", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.AMERICAN_FOOTBALL_SPREAD): + t.Run("Home Win with Handicap", func(t *testing.T) { + status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "-3.5"}, struct{ Home, Away int }{Home: 24, Away: 20}) + t.Logf("Market: %s, Scenario: Home Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win with Handicap", func(t *testing.T) { + status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2", OddHandicap: "+3.5"}, struct{ Home, Away int }{Home: 20, Away: 24}) + t.Logf("Market: %s, Scenario: Away Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "0"}, struct{ Home, Away int }{Home: 21, Away: 21}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric Handicap", func(t *testing.T) { + status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "notanumber"}, struct{ Home, Away int }{Home: 21, Away: 14}) + t.Logf("Market: %s, Scenario: Non-numeric Handicap", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.AMERICAN_FOOTBALL_TOTAL_POINTS): + t.Run("Over Win", func(t *testing.T) { + status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "44.5"}, struct{ Home, Away int }{Home: 30, Away: 20}) + t.Logf("Market: %s, Scenario: Over Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Under Win", func(t *testing.T) { + status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Under", OddName: "44.5"}, struct{ Home, Away int }{Home: 20, Away: 17}) + t.Logf("Market: %s, Scenario: Under Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "37"}, struct{ Home, Away int }{Home: 20, Away: 17}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric OddName", func(t *testing.T) { + status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "notanumber"}, struct{ Home, Away int }{Home: 20, Away: 17}) + t.Logf("Market: %s, Scenario: Non-numeric OddName", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + } + }) + } +} + +// TestRugbyMarkets covers all Rugby (Union & League) market types defined in the domain. +// For each market (Money Line, Spread, Handicap, Total Points), it tests home/away win, draw, void, and invalid input scenarios. +func TestRugbyMarkets(t *testing.T) { + t.Log("Testing Rugby Markets (Union & League)") + markets := []struct { + marketID int64 + name string + }{ + {int64(domain.RUGBY_MONEY_LINE), "MONEY_LINE"}, + {int64(domain.RUGBY_SPREAD), "SPREAD"}, + {int64(domain.RUGBY_TOTAL_POINTS), "TOTAL_POINTS"}, + {int64(domain.RUGBY_HANDICAP), "HANDICAP"}, + } + + for _, m := range markets { + t.Run(m.name, func(t *testing.T) { + // Each subtest below covers a key scenario for the given Rugby market. + switch m.marketID { + case int64(domain.RUGBY_MONEY_LINE): + // Home win, away win, draw, and invalid OddHeader for Money Line + t.Run("Home Win", func(t *testing.T) { + status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 30, Away: 20}) + t.Logf("Market: %s, Scenario: Home Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win", func(t *testing.T) { + status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2"}, struct{ Home, Away int }{Home: 20, Away: 30}) + t.Logf("Market: %s, Scenario: Away Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Draw", func(t *testing.T) { + status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 25, Away: 25}) + t.Logf("Market: %s, Scenario: Draw", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_LOSS, status) + }) + t.Run("Invalid OddHeader", func(t *testing.T) { + status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "X"}, struct{ Home, Away int }{Home: 10, Away: 7}) + t.Logf("Market: %s, Scenario: Invalid OddHeader", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.RUGBY_SPREAD), int64(domain.RUGBY_HANDICAP): + t.Run("Home Win with Handicap", func(t *testing.T) { + status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "-6.5"}, struct{ Home, Away int }{Home: 28, Away: 20}) + t.Logf("Market: %s, Scenario: Home Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win with Handicap", func(t *testing.T) { + status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2", OddHandicap: "+6.5"}, struct{ Home, Away int }{Home: 20, Away: 28}) + t.Logf("Market: %s, Scenario: Away Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "0"}, struct{ Home, Away int }{Home: 21, Away: 21}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric Handicap", func(t *testing.T) { + status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "notanumber"}, struct{ Home, Away int }{Home: 21, Away: 14}) + t.Logf("Market: %s, Scenario: Non-numeric Handicap", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.RUGBY_TOTAL_POINTS): + t.Run("Over Win", func(t *testing.T) { + status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "40.5"}, struct{ Home, Away int }{Home: 25, Away: 20}) + t.Logf("Market: %s, Scenario: Over Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Under Win", func(t *testing.T) { + status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Under", OddName: "40.5"}, struct{ Home, Away int }{Home: 15, Away: 20}) + t.Logf("Market: %s, Scenario: Under Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "35"}, struct{ Home, Away int }{Home: 20, Away: 15}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric OddName", func(t *testing.T) { + status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "notanumber"}, struct{ Home, Away int }{Home: 20, Away: 15}) + t.Logf("Market: %s, Scenario: Non-numeric OddName", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + } + }) + } +} + +// TestBaseballMarkets covers all Baseball market types defined in the domain. +// For each market (Money Line, Spread, Total Runs), it tests home/away win, draw, void, and invalid input scenarios. +func TestBaseballMarkets(t *testing.T) { + t.Log("Testing Baseball Markets") + markets := []struct { + marketID int64 + name string + }{ + {int64(domain.BASEBALL_MONEY_LINE), "MONEY_LINE"}, + {int64(domain.BASEBALL_SPREAD), "SPREAD"}, + {int64(domain.BASEBALL_TOTAL_RUNS), "TOTAL_RUNS"}, + } + + for _, m := range markets { + t.Run(m.name, func(t *testing.T) { + // Each subtest below covers a key scenario for the given Baseball market. + switch m.marketID { + case int64(domain.BASEBALL_MONEY_LINE): + // Home win, away win, draw, and invalid OddHeader for Money Line + t.Run("Home Win", func(t *testing.T) { + status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 6, Away: 3}) + t.Logf("Market: %s, Scenario: Home Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win", func(t *testing.T) { + status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2"}, struct{ Home, Away int }{Home: 2, Away: 5}) + t.Logf("Market: %s, Scenario: Away Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Draw", func(t *testing.T) { + status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 4, Away: 4}) + t.Logf("Market: %s, Scenario: Draw", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_LOSS, status) + }) + t.Run("Invalid OddHeader", func(t *testing.T) { + status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "X"}, struct{ Home, Away int }{Home: 10, Away: 7}) + t.Logf("Market: %s, Scenario: Invalid OddHeader", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.BASEBALL_SPREAD): + t.Run("Home Win with Handicap", func(t *testing.T) { + status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "-1.5"}, struct{ Home, Away int }{Home: 5, Away: 3}) + t.Logf("Market: %s, Scenario: Home Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win with Handicap", func(t *testing.T) { + status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2", OddHandicap: "+1.5"}, struct{ Home, Away int }{Home: 3, Away: 5}) + t.Logf("Market: %s, Scenario: Away Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "0"}, struct{ Home, Away int }{Home: 4, Away: 4}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric Handicap", func(t *testing.T) { + status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "notanumber"}, struct{ Home, Away int }{Home: 5, Away: 3}) + t.Logf("Market: %s, Scenario: Non-numeric Handicap", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.BASEBALL_TOTAL_RUNS): + t.Run("Over Win", func(t *testing.T) { + status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "7.5"}, struct{ Home, Away int }{Home: 5, Away: 4}) + t.Logf("Market: %s, Scenario: Over Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Under Win", func(t *testing.T) { + status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Under", OddName: "7.5"}, struct{ Home, Away int }{Home: 2, Away: 3}) + t.Logf("Market: %s, Scenario: Under Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "7"}, struct{ Home, Away int }{Home: 4, Away: 3}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric OddName", func(t *testing.T) { + status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "notanumber"}, struct{ Home, Away int }{Home: 4, Away: 3}) + t.Logf("Market: %s, Scenario: Non-numeric OddName", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + } + }) + } +} diff --git a/internal/services/result_checker.go b/internal/services/result_checker.go new file mode 100644 index 0000000..59d4b1c --- /dev/null +++ b/internal/services/result_checker.go @@ -0,0 +1,189 @@ +package services + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +// ResultCheckerService handles the checking of game results +type ResultCheckerService struct { + // Add any dependencies here (e.g., repositories, external APIs) +} + +// NewResultCheckerService creates a new instance of ResultCheckerService +func NewResultCheckerService() *ResultCheckerService { + return &ResultCheckerService{} +} + +// CheckNFLResult checks the result of an NFL game +func (s *ResultCheckerService) CheckNFLResult(data json.RawMessage) (*domain.Result, error) { + nflResult, err := domain.ParseNFLResult(data) + if err != nil { + return nil, fmt.Errorf("failed to parse NFL result: %w", err) + } + + winner, err := domain.GetNFLWinner(nflResult) + if err != nil { + return nil, fmt.Errorf("failed to determine NFL winner: %w", err) + } + + score := domain.FormatNFLScore(nflResult) + + return &domain.Result{ + Status: determineOutcomeStatus(winner, nflResult.Home.Name, nflResult.Away.Name), + Score: score, + FullTimeScore: score, + SS: nflResult.SS, + Scores: map[string]domain.Score{ + "1": nflResult.Scores.FirstQuarter, + "2": nflResult.Scores.SecondQuarter, + "3": nflResult.Scores.ThirdQuarter, + "4": nflResult.Scores.FourthQuarter, + "5": nflResult.Scores.Overtime, + "7": nflResult.Scores.TotalScore, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil +} + +// determineOutcomeStatus determines the outcome status based on the winner and teams +func determineOutcomeStatus(winner, homeTeam, awayTeam string) domain.OutcomeStatus { + if winner == "Draw" { + return domain.OUTCOME_STATUS_VOID + } + if winner == homeTeam { + return domain.OUTCOME_STATUS_WIN + } + if winner == awayTeam { + return domain.OUTCOME_STATUS_LOSS + } + return domain.OUTCOME_STATUS_PENDING +} + +// CheckRugbyResult checks the result of a Rugby game +func (s *ResultCheckerService) CheckRugbyResult(data json.RawMessage) (*domain.Result, error) { + rugbyResult, err := domain.ParseRugbyResult(data) + if err != nil { + return nil, fmt.Errorf("failed to parse Rugby result: %w", err) + } + + winner, err := domain.GetRugbyWinner(rugbyResult) + if err != nil { + return nil, fmt.Errorf("failed to determine Rugby winner: %w", err) + } + + score := domain.FormatRugbyScore(rugbyResult) + + return &domain.Result{ + Status: determineOutcomeStatus(winner, rugbyResult.Home.Name, rugbyResult.Away.Name), + Score: score, + FullTimeScore: score, + SS: rugbyResult.SS, + Scores: map[string]domain.Score{ + "1": rugbyResult.Scores.FirstHalf, + "2": rugbyResult.Scores.SecondHalf, + "7": rugbyResult.Scores.TotalScore, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil +} + +// CheckBaseballResult checks the result of a Baseball game +func (s *ResultCheckerService) CheckBaseballResult(data json.RawMessage) (*domain.Result, error) { + baseballResult, err := domain.ParseBaseballResult(data) + if err != nil { + return nil, fmt.Errorf("failed to parse Baseball result: %w", err) + } + + winner, err := domain.GetBaseballWinner(baseballResult) + if err != nil { + return nil, fmt.Errorf("failed to determine Baseball winner: %w", err) + } + + score := domain.FormatBaseballScore(baseballResult) + + return &domain.Result{ + Status: determineOutcomeStatus(winner, baseballResult.Home.Name, baseballResult.Away.Name), + Score: score, + FullTimeScore: score, + SS: baseballResult.SS, + Scores: map[string]domain.Score{ + "1": baseballResult.Scores.FirstInning, + "2": baseballResult.Scores.SecondInning, + "3": baseballResult.Scores.ThirdInning, + "4": baseballResult.Scores.FourthInning, + "5": baseballResult.Scores.FifthInning, + "6": baseballResult.Scores.SixthInning, + "7": baseballResult.Scores.SeventhInning, + "8": baseballResult.Scores.EighthInning, + "9": baseballResult.Scores.NinthInning, + "10": baseballResult.Scores.ExtraInnings, + "T": baseballResult.Scores.TotalScore, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil +} + +// CheckRugbyUnionResult checks the result of a Rugby Union game +func (s *ResultCheckerService) CheckRugbyUnionResult(data json.RawMessage) (*domain.Result, error) { + rugbyResult, err := domain.ParseRugbyUnionResult(data) + if err != nil { + return nil, fmt.Errorf("failed to parse Rugby Union result: %w", err) + } + + winner, err := domain.GetRugbyWinner(rugbyResult) + if err != nil { + return nil, fmt.Errorf("failed to determine Rugby Union winner: %w", err) + } + + score := domain.FormatRugbyScore(rugbyResult) + + return &domain.Result{ + Status: determineOutcomeStatus(winner, rugbyResult.Home.Name, rugbyResult.Away.Name), + Score: score, + FullTimeScore: score, + SS: rugbyResult.SS, + Scores: map[string]domain.Score{ + "1": rugbyResult.Scores.FirstHalf, + "2": rugbyResult.Scores.SecondHalf, + "7": rugbyResult.Scores.TotalScore, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil +} + +// CheckRugbyLeagueResult checks the result of a Rugby League game +func (s *ResultCheckerService) CheckRugbyLeagueResult(data json.RawMessage) (*domain.Result, error) { + rugbyResult, err := domain.ParseRugbyLeagueResult(data) + if err != nil { + return nil, fmt.Errorf("failed to parse Rugby League result: %w", err) + } + + winner, err := domain.GetRugbyWinner(rugbyResult) + if err != nil { + return nil, fmt.Errorf("failed to determine Rugby League winner: %w", err) + } + + score := domain.FormatRugbyScore(rugbyResult) + + return &domain.Result{ + Status: determineOutcomeStatus(winner, rugbyResult.Home.Name, rugbyResult.Away.Name), + Score: score, + FullTimeScore: score, + SS: rugbyResult.SS, + Scores: map[string]domain.Score{ + "1": rugbyResult.Scores.FirstHalf, + "2": rugbyResult.Scores.SecondHalf, + "7": rugbyResult.Scores.TotalScore, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 70fb79a..77da6f6 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -177,6 +177,7 @@ func (a *App) initAppRoutes() { // Virtual Game Routes a.fiber.Post("/virtual-game/launch", a.authMiddleware, h.LaunchVirtualGame) a.fiber.Post("/virtual-game/callback", h.HandleVirtualGameCallback) + } ///user/profile get