From aef5c4410d72836b3257c30e045280082f03a10e Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 30 May 2025 21:54:22 +0300 Subject: [PATCH 01/10] chapa integration fix --- go.mod | 1 + go.sum | 2 + internal/domain/chapa.go | 44 ++++++++ internal/domain/common.go | 9 ++ internal/domain/responses.go | 53 ++++++++++ internal/services/chapa/port.go | 11 ++ internal/services/chapa/service.go | 128 +++++++++++++++++++++++ internal/services/result/service.go | 2 +- internal/services/result/sports_eval.go | 4 +- internal/web_server/handlers/chapa.go | 58 ++++++++++ internal/web_server/handlers/handlers.go | 4 + 11 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 internal/domain/responses.go create mode 100644 internal/services/chapa/port.go create mode 100644 internal/services/chapa/service.go diff --git a/go.mod b/go.mod index 5a55392..557251b 100644 --- a/go.mod +++ b/go.mod @@ -50,6 +50,7 @@ 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/shopspring/decimal v1.4.0 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 diff --git a/go.sum b/go.sum index ab1ac26..32967eb 100644 --- a/go.sum +++ b/go.sum @@ -112,6 +112,8 @@ 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/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 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/chapa.go b/internal/domain/chapa.go index f630a6d..885f6ad 100644 --- a/internal/domain/chapa.go +++ b/internal/domain/chapa.go @@ -105,3 +105,47 @@ type VerifyTransferResponse struct { Message string `json:"message"` Data TransferVerificationData `json:"data"` } + +type ChapaTransactionType struct { + Type string `json:"type"` +} + +type ChapaWebHookTransfer struct { + AccountName string `json:"account_name"` + AccountNumber string `json:"account_number"` + BankId string `json:"bank_id"` + BankName string `json:"bank_name"` + Currency string `json:"currency"` + Amount string `json:"amount"` + Type string `json:"type"` + Status string `json:"status"` + Reference string `json:"reference"` + TxRef string `json:"tx_ref"` + ChapaReference string `json:"chapa_reference"` + CreatedAt time.Time `json:"created_at"` +} + +type ChapaWebHookPayment struct { + Event string `json:"event"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + Mobile interface{} `json:"mobile"` + Currency string `json:"currency"` + Amount string `json:"amount"` + Charge string `json:"charge"` + Status string `json:"status"` + Mode string `json:"mode"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Type string `json:"type"` + TxRef string `json:"tx_ref"` + PaymentMethod string `json:"payment_method"` + Customization struct { + Title interface{} `json:"title"` + Description interface{} `json:"description"` + Logo interface{} `json:"logo"` + } `json:"customization"` + Meta string `json:"meta"` +} diff --git a/internal/domain/common.go b/internal/domain/common.go index fc652d1..6e0acbb 100644 --- a/internal/domain/common.go +++ b/internal/domain/common.go @@ -47,3 +47,12 @@ func (m Currency) String() string { x = x / 100 return fmt.Sprintf("$%.2f", x) } + +type Response struct { + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` + Success bool `json:"success"` + StatusCode int `json:"status_code"` +} + + diff --git a/internal/domain/responses.go b/internal/domain/responses.go new file mode 100644 index 0000000..841ca37 --- /dev/null +++ b/internal/domain/responses.go @@ -0,0 +1,53 @@ +package domain + +import ( + "errors" + + "github.com/gofiber/fiber/v2" +) + +func UnProcessableEntityResponse(c *fiber.Ctx) error { + return c.Status(fiber.StatusUnprocessableEntity).JSON(Response{ + Message: "failed to parse request body", + StatusCode: fiber.StatusUnprocessableEntity, + Success: false, + }) +} + +func FiberErrorResponse(c *fiber.Ctx, err error) error { + var statusCode int + var message string + + switch { + case errors.Is(err, fiber.ErrNotFound): + statusCode = fiber.StatusNotFound + message = "resource not found" + + case errors.Is(err, fiber.ErrBadRequest): + statusCode = fiber.StatusBadRequest + message = "bad request" + + case errors.Is(err, fiber.ErrUnauthorized): + statusCode = fiber.StatusUnauthorized + message = "unauthorized" + + case errors.Is(err, fiber.ErrForbidden): + statusCode = fiber.StatusForbidden + message = "forbidden" + + case errors.Is(err, fiber.ErrConflict): + statusCode = fiber.StatusConflict + message = "conflict occurred" + + default: + statusCode = fiber.StatusInternalServerError + message = "unexpected server error" + } + + return c.Status(statusCode).JSON(fiber.Map{ + "success": false, + "status_code": statusCode, + "error": message, + "details": err.Error(), + }) +} diff --git a/internal/services/chapa/port.go b/internal/services/chapa/port.go new file mode 100644 index 0000000..2c0d267 --- /dev/null +++ b/internal/services/chapa/port.go @@ -0,0 +1,11 @@ +package chapa + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type ChapaPort interface { + HandleChapaTransferWebhook(ctx context.Context, req domain.ChapaWebHookTransfer) error +} diff --git a/internal/services/chapa/service.go b/internal/services/chapa/service.go new file mode 100644 index 0000000..2272381 --- /dev/null +++ b/internal/services/chapa/service.go @@ -0,0 +1,128 @@ +package chapa + +import ( + "context" + "fmt" + "strconv" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" + "github.com/shopspring/decimal" +) + +type Service struct { + transactionStore transaction.TransactionStore + walletStore wallet.WalletStore + store *repository.Store +} + +func (s *Service) HandleChapaTransferWebhook(ctx context.Context, req domain.ChapaWebHookTransfer) error { + tx, err := s.store.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + // 1. Fetch transaction + referenceID, err := strconv.ParseInt(req.Reference, 10, 64) + txn, err := s.transactionStore.GetTransactionByID(ctx, referenceID) + if err != nil { + return err + } + if txn.Verified { + return nil // already processed + } + + // 2. Compare amount + webhookAmount, _ := decimal.NewFromString(req.Amount) + storedAmount, _ := decimal.NewFromString(txn.Amount.String()) + if !webhookAmount.Equal(storedAmount) { + return fmt.Errorf("amount mismatch") + } + + // 3. Update transaction + txn.Verified = true + + if err := s.transactionStore.UpdateTransactionVerified(ctx, txn.ID, txn.Verified, txn.ApprovedBy.Value, txn.ApproverName.Value); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.ChapaWebHookPayment) error { + tx, err := s.store.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + if req.Status != "success" { + return fmt.Errorf("payment status not successful") + } + + // 1. Parse reference ID + referenceID, err := strconv.ParseInt(req.TxRef, 10, 64) + if err != nil { + return fmt.Errorf("invalid tx_ref: %w", err) + } + + // 2. Fetch transaction + txn, err := s.transactionStore.GetTransactionByID(ctx, referenceID) + if err != nil { + return err + } + if txn.Verified { + return nil // already processed + } + + // 3. Amount validation + webhookAmount, _ := decimal.NewFromString(req.Amount) + storedAmount := txn.Amount // assuming it's domain.Currency (decimal.Decimal alias) + + if webhookAmount.LessThan(storedAmount) { + return fmt.Errorf("webhook amount is less than expected") + } + + // 4. Fetch wallet + wallet, err := s.walletStore.GetWalletByID(ctx, txn.ID) + if err != nil { + return err + } + + // 5. Update wallet balance + newBalance := wallet.Balance.Add(storedAmount) + if err := s.walletStore.UpdateBalance(ctx, wallet.ID, newBalance); err != nil { + return err + } + + // 6. Mark transaction as verified + if err := s.transactionStore.UpdateTransactionVerified(ctx, txn.ID, true, txn.ApprovedBy.Value, txn.ApproverName.Value); err != nil { + return err + } + + // 7. Check & generate referral code + hasCode, err := s.userStore.HasReferralCode(ctx, wallet.UserID) + if err != nil { + return err + } + + if !hasCode { + code := misc.GenerateReferralCode(req.FirstName) + + if err := s.userStore.SetReferralCode(ctx, wallet.UserID, code); err != nil { + return err + } + + if err := s.referralStore.CreateReferralCode(ctx, domain.ReferralCode{ + Code: code, + Amount: config.ReferralRewardBase, + }); err != nil { + return err + } + } + + return tx.Commit(ctx) +} diff --git a/internal/services/result/service.go b/internal/services/result/service.go index 4c3fa81..9d15b7f 100644 --- a/internal/services/result/service.go +++ b/internal/services/result/service.go @@ -730,7 +730,7 @@ 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) { +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) diff --git a/internal/services/result/sports_eval.go b/internal/services/result/sports_eval.go index eeb23f7..48a837f 100644 --- a/internal/services/result/sports_eval.go +++ b/internal/services/result/sports_eval.go @@ -234,7 +234,7 @@ func evaluateBaseballTotalRuns(outcome domain.BetOutcome, score struct{ Home, Aw } // evaluateBaseballFirstInning evaluates Baseball first inning bets -func evaluateBaseballFirstInning(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { +func EvaluateBaseballFirstInning(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { switch outcome.OddHeader { case "1": if score.Home > score.Away { @@ -257,7 +257,7 @@ func evaluateBaseballFirstInning(outcome domain.BetOutcome, score struct{ Home, } // evaluateBaseballFirst5Innings evaluates Baseball first 5 innings bets -func evaluateBaseballFirst5Innings(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { +func EvaluateBaseballFirst5Innings(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { switch outcome.OddHeader { case "1": if score.Home > score.Away { diff --git a/internal/web_server/handlers/chapa.go b/internal/web_server/handlers/chapa.go index 3fc66c0..0e69c83 100644 --- a/internal/web_server/handlers/chapa.go +++ b/internal/web_server/handlers/chapa.go @@ -7,6 +7,8 @@ import ( "io" "net/http" + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/gofiber/fiber/v2" "github.com/google/uuid" ) @@ -281,3 +283,59 @@ func (h *Handler) VerifyTransfer(c *fiber.Ctx) error { return c.Status(resp.StatusCode).Type("json").Send(body) } + +// VerifyChapaPayment godoc +// @Summary Verifies Chapa webhook transaction +// @Tags Chapa +// @Accept json +// @Produce json +// @Param payload body domain.ChapaTransactionType true "Webhook Payload" +// @Success 200 {object} domain.Response +// @Router /api/v1/chapa/payments/verify [post] +func (h *Handler) VerifyChapaPayment(c *fiber.Ctx) error { + var txType domain.ChapaTransactionType + if err := c.BodyParser(&txType); err != nil { + return domain.UnProcessableEntityResponse(c) + } + + switch txType.Type { + case config.ChapaTransferType: + var payload domain.ChapaWebHookTransfer + if err := c.BodyParser(&payload); err != nil { + return domain.UnProcessableEntityResponse(c) + } + + if err := h.chapaSvc.HandleChapaTransferWebhook(c.Context(), payload); err != nil { + return domain.FiberErrorResponse(c, err) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Chapa transfer verified successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) + + case config.ChapaPaymentType: + var payload domain.ChapaWebHookPayment + if err := c.BodyParser(&payload); err != nil { + return domain.UnProcessableEntityResponse(c) + } + + if err := h.chapaSvc.HandleChapaPaymentWebhook(c.Context(), payload); err != nil { + return domain.FiberErrorResponse(c, err) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Chapa payment verified successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) + + default: + return c.Status(fiber.StatusBadRequest).JSON(domain.Response{ + Message: "Invalid Chapa transaction type", + Success: false, + StatusCode: fiber.StatusBadRequest, + }) + } +} diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 532d2da..6c42024 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -7,6 +7,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/chapa" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/company" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" @@ -29,6 +30,7 @@ type Handler struct { notificationSvc *notificationservice.Service userSvc *user.Service referralSvc referralservice.ReferralStore + chapaSvc chapa.ChapaPort walletSvc *wallet.Service transactionSvc *transaction.Service ticketSvc *ticket.Service @@ -51,6 +53,7 @@ func New( logger *slog.Logger, notificationSvc *notificationservice.Service, validator *customvalidator.CustomValidator, + chapaSvc chapa.ChapaPort, walletSvc *wallet.Service, referralSvc referralservice.ReferralStore, virtualGameSvc virtualgameservice.VirtualGameService, @@ -72,6 +75,7 @@ func New( return &Handler{ logger: logger, notificationSvc: notificationSvc, + chapaSvc: chapaSvc, walletSvc: walletSvc, referralSvc: referralSvc, validator: validator, From b4609cdd5b1e27ba53c4617bca8064e3b08f8237 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 30 May 2025 23:59:55 +0300 Subject: [PATCH 02/10] more chapa fix --- cmd/main.go | 10 ++++- internal/config/config.go | 5 +++ internal/repository/store.go | 10 +++++ internal/services/chapa/port.go | 1 + internal/services/chapa/service.go | 61 +++++++++++++++------------ internal/web_server/app.go | 4 ++ internal/web_server/handlers/chapa.go | 4 +- internal/web_server/routes.go | 1 + 8 files changed, 67 insertions(+), 29 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 102d78b..4e87efa 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,6 +19,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/chapa" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/company" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" @@ -107,6 +108,13 @@ func main() { logger, ) recommendationSvc := recommendation.NewService(recommendationRepo) + chapaSvc := chapa.NewService( + transaction.TransactionStore(store), + wallet.WalletStore(store), + user.UserStore(store), + referalSvc, + store, + ) httpserver.StartDataFetchingCrons(eventSvc, oddsSvc, resultSvc) httpserver.StartTicketCrons(*ticketSvc) @@ -115,7 +123,7 @@ func main() { JwtAccessKey: cfg.JwtKey, JwtAccessExpiry: cfg.AccessExpiry, }, userSvc, - ticketSvc, betSvc, walletSvc, transactionSvc, branchSvc, companySvc, notificationSvc, oddsSvc, eventSvc, referalSvc, virtualGameSvc, aleaService, veliService, recommendationSvc, resultSvc, cfg) + ticketSvc, betSvc, chapaSvc, walletSvc, transactionSvc, branchSvc, companySvc, notificationSvc, oddsSvc, eventSvc, referalSvc, virtualGameSvc, aleaService, veliService, recommendationSvc, resultSvc, cfg) logger.Info("Starting server", "port", cfg.Port) if err := app.Run(); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index eba9702..db606c8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -289,3 +289,8 @@ func (c *Config) loadEnv() error { c.Bet365Token = betToken return nil } + +type ChapaConfig struct { + ChapaPaymentType string `mapstructure:"chapa_payment_type"` + ChapaTransferType string `mapstructure:"chapa_transfer_type"` +} diff --git a/internal/repository/store.go b/internal/repository/store.go index 02e1403..f3e7579 100644 --- a/internal/repository/store.go +++ b/internal/repository/store.go @@ -5,6 +5,7 @@ import ( "time" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -39,3 +40,12 @@ func OpenDB(url string) (*pgxpool.Pool, func(), error) { conn.Close() }, nil } + +func (s *Store) BeginTx(ctx context.Context) (*dbgen.Queries, pgx.Tx, error) { + tx, err := s.conn.Begin(ctx) + if err != nil { + return nil, nil, err + } + q := s.queries.WithTx(tx) + return q, tx, nil +} diff --git a/internal/services/chapa/port.go b/internal/services/chapa/port.go index 2c0d267..57ca589 100644 --- a/internal/services/chapa/port.go +++ b/internal/services/chapa/port.go @@ -8,4 +8,5 @@ import ( type ChapaPort interface { HandleChapaTransferWebhook(ctx context.Context, req domain.ChapaWebHookTransfer) error + HandleChapaPaymentWebhook(ctx context.Context, req domain.ChapaWebHookPayment) error } diff --git a/internal/services/chapa/service.go b/internal/services/chapa/service.go index 2272381..ea7915a 100644 --- a/internal/services/chapa/service.go +++ b/internal/services/chapa/service.go @@ -7,7 +7,9 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" + referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/shopspring/decimal" ) @@ -15,36 +17,55 @@ import ( type Service struct { transactionStore transaction.TransactionStore walletStore wallet.WalletStore + userStore user.UserStore + referralStore referralservice.ReferralStore store *repository.Store } +func NewService( + txStore transaction.TransactionStore, + walletStore wallet.WalletStore, + userStore user.UserStore, + referralStore referralservice.ReferralStore, + store *repository.Store, +) *Service { + return &Service{ + transactionStore: txStore, + walletStore: walletStore, + userStore: userStore, + referralStore: referralStore, + store: store, + } +} + func (s *Service) HandleChapaTransferWebhook(ctx context.Context, req domain.ChapaWebHookTransfer) error { - tx, err := s.store.Begin(ctx) + _, tx, err := s.store.BeginTx(ctx) if err != nil { return err } defer tx.Rollback(ctx) - // 1. Fetch transaction + // Use your services normally (they don’t use the transaction, unless you wire `q`) referenceID, err := strconv.ParseInt(req.Reference, 10, 64) + if err != nil { + return fmt.Errorf("invalid reference ID: %w", err) + } + txn, err := s.transactionStore.GetTransactionByID(ctx, referenceID) if err != nil { return err } if txn.Verified { - return nil // already processed + return nil } - // 2. Compare amount webhookAmount, _ := decimal.NewFromString(req.Amount) storedAmount, _ := decimal.NewFromString(txn.Amount.String()) if !webhookAmount.Equal(storedAmount) { return fmt.Errorf("amount mismatch") } - // 3. Update transaction txn.Verified = true - if err := s.transactionStore.UpdateTransactionVerified(ctx, txn.ID, txn.Verified, txn.ApprovedBy.Value, txn.ApproverName.Value); err != nil { return err } @@ -53,7 +74,7 @@ func (s *Service) HandleChapaTransferWebhook(ctx context.Context, req domain.Cha } func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.ChapaWebHookPayment) error { - tx, err := s.store.Begin(ctx) + _, tx, err := s.store.BeginTx(ctx) if err != nil { return err } @@ -78,11 +99,8 @@ func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.Chap return nil // already processed } - // 3. Amount validation - webhookAmount, _ := decimal.NewFromString(req.Amount) - storedAmount := txn.Amount // assuming it's domain.Currency (decimal.Decimal alias) - - if webhookAmount.LessThan(storedAmount) { + webhookAmount, _ := strconv.ParseFloat(req.Amount, 32) + if webhookAmount < float64(txn.Amount) { return fmt.Errorf("webhook amount is less than expected") } @@ -93,7 +111,7 @@ func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.Chap } // 5. Update wallet balance - newBalance := wallet.Balance.Add(storedAmount) + newBalance := wallet.Balance + txn.Amount if err := s.walletStore.UpdateBalance(ctx, wallet.ID, newBalance); err != nil { return err } @@ -103,23 +121,14 @@ func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.Chap return err } - // 7. Check & generate referral code - hasCode, err := s.userStore.HasReferralCode(ctx, wallet.UserID) + // 7. Check & Create Referral + stats, err := s.referralStore.GetReferralStats(ctx, string(wallet.UserID)) if err != nil { return err } - if !hasCode { - code := misc.GenerateReferralCode(req.FirstName) - - if err := s.userStore.SetReferralCode(ctx, wallet.UserID, code); err != nil { - return err - } - - if err := s.referralStore.CreateReferralCode(ctx, domain.ReferralCode{ - Code: code, - Amount: config.ReferralRewardBase, - }); err != nil { + if stats == nil { + if err := s.referralStore.CreateReferral(ctx, wallet.UserID); err != nil { return err } } diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 18e4411..d7c0b46 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -8,6 +8,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/chapa" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/company" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" @@ -44,6 +45,7 @@ type App struct { userSvc *user.Service betSvc *bet.Service virtualGameSvc virtualgameservice.VirtualGameService + chapaSvc *chapa.Service walletSvc *wallet.Service transactionSvc *transaction.Service ticketSvc *ticket.Service @@ -65,6 +67,7 @@ func NewApp( userSvc *user.Service, ticketSvc *ticket.Service, betSvc *bet.Service, + chapaSvc *chapa.Service, walletSvc *wallet.Service, transactionSvc *transaction.Service, branchSvc *branch.Service, @@ -104,6 +107,7 @@ func NewApp( userSvc: userSvc, ticketSvc: ticketSvc, betSvc: betSvc, + chapaSvc: chapaSvc, walletSvc: walletSvc, transactionSvc: transactionSvc, branchSvc: branchSvc, diff --git a/internal/web_server/handlers/chapa.go b/internal/web_server/handlers/chapa.go index 0e69c83..bfd1541 100644 --- a/internal/web_server/handlers/chapa.go +++ b/internal/web_server/handlers/chapa.go @@ -299,7 +299,7 @@ func (h *Handler) VerifyChapaPayment(c *fiber.Ctx) error { } switch txType.Type { - case config.ChapaTransferType: + case config.ChapaConfig.ChapaTransferType: var payload domain.ChapaWebHookTransfer if err := c.BodyParser(&payload); err != nil { return domain.UnProcessableEntityResponse(c) @@ -315,7 +315,7 @@ func (h *Handler) VerifyChapaPayment(c *fiber.Ctx) error { StatusCode: fiber.StatusOK, }) - case config.ChapaPaymentType: + case config.ChapaConfig.ChapaPaymentType: var payload domain.ChapaWebHookPayment if err := c.BodyParser(&payload); err != nil { return domain.UnProcessableEntityResponse(c) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index a7c0810..0e1acfc 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -18,6 +18,7 @@ func (a *App) initAppRoutes() { a.logger, a.NotidicationStore, a.validator, + a.chapaSvc, a.walletSvc, a.referralSvc, a.virtualGameSvc, From ec02497f9790bee0141423eef1f865154e199243 Mon Sep 17 00:00:00 2001 From: Asher Samuel Date: Sat, 31 May 2025 18:14:46 +0300 Subject: [PATCH 03/10] event fetch for bwin --- internal/services/event/service.go | 57 +++++++++++++++++++++++++----- makefile | 2 -- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/internal/services/event/service.go b/internal/services/event/service.go index b24a8ec..1fc2f12 100644 --- a/internal/services/event/service.go +++ b/internal/services/event/service.go @@ -35,9 +35,10 @@ func (s *service) FetchLiveEvents(ctx context.Context) error { name string source string }{ - {"https://api.b365api.com/v1/bet365/inplay?sport_id=%d&token=%s", "bet365"}, + {"https://api.b365api.com/v1/bet365/inplay?sport_id=%d&&token=%s", "bet365"}, {"https://api.b365api.com/v1/betfair/sb/inplay?sport_id=%d&token=%s", "betfair"}, {"https://api.b365api.com/v1/1xbet/inplay?sport_id=%d&token=%s", "1xbet"}, + {"https://api.b365api.com/v1/bwin/inplay?sport_id=%d&token=%s", "bwin"}, } for _, url := range urls { @@ -76,12 +77,14 @@ func (s *service) fetchLiveEvents(ctx context.Context, url, source string) error events := []domain.Event{} switch source { case "bet365": - events = handleBet365prematch(body, sportID) + events = handleBet365prematch(body, sportID, source) case "betfair": events = handleBetfairprematch(body, sportID, source) case "1xbet": // betfair and 1xbet have the same result structure events = handleBetfairprematch(body, sportID, source) + case "bwin": + events = handleBwinprematch(body, sportID, source) } for _, event := range events { @@ -98,7 +101,7 @@ func (s *service) fetchLiveEvents(ctx context.Context, url, source string) error } -func handleBet365prematch(body []byte, sportID int) []domain.Event { +func handleBet365prematch(body []byte, sportID int, source string) []domain.Event { var data struct { Success int `json:"success"` Results [][]map[string]interface{} `json:"results"` @@ -106,7 +109,7 @@ func handleBet365prematch(body []byte, sportID int) []domain.Event { events := []domain.Event{} 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)) + fmt.Printf("%s: Decode failed for sport_id=%d\nRaw: %s\n", source, sportID, string(body)) return events } @@ -135,7 +138,7 @@ func handleBet365prematch(body []byte, sportID int) []domain.Event { Status: "live", MatchPeriod: getInt(ev["MD"]), AddedTime: getInt(ev["TA"]), - Source: "bet365", + Source: source, } events = append(events, event) @@ -153,7 +156,7 @@ func handleBetfairprematch(body []byte, sportID int, source string) []domain.Eve events := []domain.Event{} 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)) + fmt.Printf("%s: Decode failed for sport_id=%d\nRaw: %s\n", source, sportID, string(body)) return events } @@ -182,6 +185,42 @@ func handleBetfairprematch(body []byte, sportID int, source string) []domain.Eve return events } +func handleBwinprematch(body []byte, sportID int, source string) []domain.Event { + var data struct { + Success int `json:"success"` + Results []map[string]interface{} `json:"results"` + } + + events := []domain.Event{} + if err := json.Unmarshal(body, &data); err != nil || data.Success != 1 { + fmt.Printf("%s: Decode failed for sport_id=%d\nRaw: %s\n", source, sportID, string(body)) + return events + } + + for _, ev := range data.Results { + homeTeam := getString(ev["HomeTeam"]) + awayTeam := getString(ev["HomeTeam"]) + + event := domain.Event{ + ID: getString(ev["Id"]), + SportID: fmt.Sprintf("%d", sportID), + TimerStatus: "1", + HomeTeam: homeTeam, + AwayTeam: awayTeam, + StartTime: time.Now().UTC().Format(time.RFC3339), + LeagueID: getString(ev["LeagueId"]), + LeagueName: getString(ev["LeagueName"]), + IsLive: true, + Status: "live", + Source: source, + } + + events = append(events, event) + } + + return events +} + func (s *service) FetchUpcomingEvents(ctx context.Context) error { var wg sync.WaitGroup urls := []struct { @@ -215,8 +254,8 @@ func (s *service) fetchUpcomingEventsFromProvider(ctx context.Context, url, sour for _, sportID := range sportIDs { for page <= totalPages { page = page + 1 - url := fmt.Sprintf("https://api.b365api.com/v1/bet365/upcoming?sport_id=%d&token=%s&page=%d", sportID, s.token, page) - log.Printf("📡 Fetching data for event data page %d", page) + url := fmt.Sprintf(url, sportID, s.token, page) + log.Printf("📡 Fetching data from %s, for event data page %d", source, page) resp, err := http.Get(url) if err != nil { log.Printf("❌ Failed to fetch event data for page %d: %v", page, err) @@ -249,7 +288,7 @@ func (s *service) fetchUpcomingEventsFromProvider(ctx context.Context, url, sour if !slices.Contains(domain.SupportedLeagues, leagueID) { // fmt.Printf("⚠️ Skipping league %s (%d) as it is not supported\n", ev.League.Name, leagueID) skippedLeague = append(skippedLeague, ev.League.Name) - continue + // continue } event := domain.UpcomingEvent{ diff --git a/makefile b/makefile index 5e85c50..a149170 100644 --- a/makefile +++ b/makefile @@ -55,8 +55,6 @@ db-up: .PHONY: db-down db-down: @docker compose down -postgres: - @docker exec -it fortunebet-backend-postgres-1 psql -U root -d gh .PHONY: sqlc-gen sqlc-gen: From 75d469be8cbca9b96768b6da1202281b5f42a970 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Sat, 31 May 2025 21:29:39 +0300 Subject: [PATCH 04/10] createTransaction+createTransfer fix --- cmd/main.go | 4 + docs/docs.go | 259 +++++++++++++++++++++++++- docs/swagger.json | 259 +++++++++++++++++++++++++- docs/swagger.yaml | 167 ++++++++++++++++- internal/domain/chapa.go | 90 ++++++++- internal/domain/common.go | 15 +- internal/domain/transaction.go | 1 - internal/services/chapa/client.go | 98 ++++++++++ internal/services/chapa/port.go | 2 + internal/services/chapa/service.go | 184 +++++++++++++++++- internal/web_server/handlers/chapa.go | 100 +++++++++- internal/web_server/routes.go | 15 +- 12 files changed, 1164 insertions(+), 30 deletions(-) create mode 100644 internal/services/chapa/client.go diff --git a/cmd/main.go b/cmd/main.go index d643d66..cd98778 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -109,11 +109,15 @@ func main() { logger, ) recommendationSvc := recommendation.NewService(recommendationRepo) + chapaClient := chapa.NewClient(cfg.CHAPA_BASE_URL, cfg.CHAPA_SECRET_KEY) + chapaSvc := chapa.NewService( transaction.TransactionStore(store), wallet.WalletStore(store), user.UserStore(store), referalSvc, + branch.BranchStore(store), + chapaClient, store, ) diff --git a/docs/docs.go b/docs/docs.go index 68e448c..044c114 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -361,6 +361,63 @@ const docTemplate = `{ } } }, + "/api/v1/chapa/payments/deposit": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deposits money into user wallet from user account using Chapa", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Deposit money into user wallet using Chapa", + "parameters": [ + { + "description": "Deposit request payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.ChapaDepositRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.ChapaPaymentUrlResponseWrapper" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "422": { + "description": "Validation error", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/chapa/payments/initialize": { "post": { "description": "Initiate a payment through Chapa", @@ -395,6 +452,39 @@ const docTemplate = `{ } } }, + "/api/v1/chapa/payments/verify": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Verifies Chapa webhook transaction", + "parameters": [ + { + "description": "Webhook Payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.ChapaTransactionType" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/chapa/payments/verify/{tx_ref}": { "get": { "description": "Verify the transaction status from Chapa using tx_ref", @@ -427,6 +517,76 @@ const docTemplate = `{ } } }, + "/api/v1/chapa/payments/withdraw": { + "post": { + "description": "Initiates a withdrawal transaction using Chapa for the authenticated user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Withdraw using Chapa", + "parameters": [ + { + "description": "Chapa Withdraw Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.ChapaWithdrawRequest" + } + } + ], + "responses": { + "200": { + "description": "Withdrawal requested successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/chapa/transfers": { "post": { "description": "Initiate a transfer request via Chapa", @@ -4413,6 +4573,46 @@ const docTemplate = `{ } } }, + "domain.ChapaDepositRequest": { + "type": "object", + "properties": { + "amount": { + "type": "integer" + }, + "branch_id": { + "type": "integer" + }, + "currency": { + "type": "string" + }, + "phone_number": { + "type": "string" + } + } + }, + "domain.ChapaPaymentUrlResponse": { + "type": "object", + "properties": { + "payment_url": { + "type": "string" + } + } + }, + "domain.ChapaPaymentUrlResponseWrapper": { + "type": "object", + "properties": { + "data": {}, + "message": { + "type": "string" + }, + "status_code": { + "type": "integer" + }, + "success": { + "type": "boolean" + } + } + }, "domain.ChapaSupportedBank": { "type": "object", "properties": { @@ -4480,6 +4680,44 @@ const docTemplate = `{ } } }, + "domain.ChapaTransactionType": { + "type": "object", + "properties": { + "type": { + "type": "string" + } + } + }, + "domain.ChapaWithdrawRequest": { + "type": "object", + "properties": { + "account_name": { + "type": "string" + }, + "account_number": { + "type": "string" + }, + "amount": { + "type": "integer" + }, + "bank_code": { + "type": "string" + }, + "beneficiary_name": { + "type": "string" + }, + "branch_id": { + "type": "integer" + }, + "currency": { + "type": "string" + }, + "wallet_id": { + "description": "add this", + "type": "integer" + } + } + }, "domain.CreateBetOutcomeReq": { "type": "object", "properties": { @@ -4561,7 +4799,7 @@ const docTemplate = `{ "type": "object", "properties": { "amount": { - "type": "string" + "type": "integer" }, "callback_url": { "type": "string" @@ -4832,6 +5070,21 @@ const docTemplate = `{ } } }, + "domain.Response": { + "type": "object", + "properties": { + "data": {}, + "message": { + "type": "string" + }, + "status_code": { + "type": "integer" + }, + "success": { + "type": "boolean" + } + } + }, "domain.Role": { "type": "string", "enum": [ @@ -5041,6 +5294,10 @@ const docTemplate = `{ "description": "Match or event name", "type": "string" }, + "source": { + "description": "bet api provider (bet365, betfair)", + "type": "string" + }, "sportID": { "description": "Sport ID", "type": "string" diff --git a/docs/swagger.json b/docs/swagger.json index 850af3a..d225501 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -353,6 +353,63 @@ } } }, + "/api/v1/chapa/payments/deposit": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deposits money into user wallet from user account using Chapa", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Deposit money into user wallet using Chapa", + "parameters": [ + { + "description": "Deposit request payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.ChapaDepositRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.ChapaPaymentUrlResponseWrapper" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "422": { + "description": "Validation error", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/chapa/payments/initialize": { "post": { "description": "Initiate a payment through Chapa", @@ -387,6 +444,39 @@ } } }, + "/api/v1/chapa/payments/verify": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Verifies Chapa webhook transaction", + "parameters": [ + { + "description": "Webhook Payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.ChapaTransactionType" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/chapa/payments/verify/{tx_ref}": { "get": { "description": "Verify the transaction status from Chapa using tx_ref", @@ -419,6 +509,76 @@ } } }, + "/api/v1/chapa/payments/withdraw": { + "post": { + "description": "Initiates a withdrawal transaction using Chapa for the authenticated user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Withdraw using Chapa", + "parameters": [ + { + "description": "Chapa Withdraw Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.ChapaWithdrawRequest" + } + } + ], + "responses": { + "200": { + "description": "Withdrawal requested successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/chapa/transfers": { "post": { "description": "Initiate a transfer request via Chapa", @@ -4405,6 +4565,46 @@ } } }, + "domain.ChapaDepositRequest": { + "type": "object", + "properties": { + "amount": { + "type": "integer" + }, + "branch_id": { + "type": "integer" + }, + "currency": { + "type": "string" + }, + "phone_number": { + "type": "string" + } + } + }, + "domain.ChapaPaymentUrlResponse": { + "type": "object", + "properties": { + "payment_url": { + "type": "string" + } + } + }, + "domain.ChapaPaymentUrlResponseWrapper": { + "type": "object", + "properties": { + "data": {}, + "message": { + "type": "string" + }, + "status_code": { + "type": "integer" + }, + "success": { + "type": "boolean" + } + } + }, "domain.ChapaSupportedBank": { "type": "object", "properties": { @@ -4472,6 +4672,44 @@ } } }, + "domain.ChapaTransactionType": { + "type": "object", + "properties": { + "type": { + "type": "string" + } + } + }, + "domain.ChapaWithdrawRequest": { + "type": "object", + "properties": { + "account_name": { + "type": "string" + }, + "account_number": { + "type": "string" + }, + "amount": { + "type": "integer" + }, + "bank_code": { + "type": "string" + }, + "beneficiary_name": { + "type": "string" + }, + "branch_id": { + "type": "integer" + }, + "currency": { + "type": "string" + }, + "wallet_id": { + "description": "add this", + "type": "integer" + } + } + }, "domain.CreateBetOutcomeReq": { "type": "object", "properties": { @@ -4553,7 +4791,7 @@ "type": "object", "properties": { "amount": { - "type": "string" + "type": "integer" }, "callback_url": { "type": "string" @@ -4824,6 +5062,21 @@ } } }, + "domain.Response": { + "type": "object", + "properties": { + "data": {}, + "message": { + "type": "string" + }, + "status_code": { + "type": "integer" + }, + "success": { + "type": "boolean" + } + } + }, "domain.Role": { "type": "string", "enum": [ @@ -5033,6 +5286,10 @@ "description": "Match or event name", "type": "string" }, + "source": { + "description": "bet api provider (bet365, betfair)", + "type": "string" + }, "sportID": { "description": "Sport ID", "type": "string" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b698a18..a0003a7 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -124,6 +124,32 @@ definitions: example: 2 type: integer type: object + domain.ChapaDepositRequest: + properties: + amount: + type: integer + branch_id: + type: integer + currency: + type: string + phone_number: + type: string + type: object + domain.ChapaPaymentUrlResponse: + properties: + payment_url: + type: string + type: object + domain.ChapaPaymentUrlResponseWrapper: + properties: + data: {} + message: + type: string + status_code: + type: integer + success: + type: boolean + type: object domain.ChapaSupportedBank: properties: acct_length: @@ -168,6 +194,31 @@ definitions: message: type: string type: object + domain.ChapaTransactionType: + properties: + type: + type: string + type: object + domain.ChapaWithdrawRequest: + properties: + account_name: + type: string + account_number: + type: string + amount: + type: integer + bank_code: + type: string + beneficiary_name: + type: string + branch_id: + type: integer + currency: + type: string + wallet_id: + description: add this + type: integer + type: object domain.CreateBetOutcomeReq: properties: event_id: @@ -222,7 +273,7 @@ definitions: domain.InitPaymentRequest: properties: amount: - type: string + type: integer callback_url: type: string currency: @@ -408,6 +459,16 @@ definitions: totalRewardEarned: type: number type: object + domain.Response: + properties: + data: {} + message: + type: string + status_code: + type: integer + success: + type: boolean + type: object domain.Role: enum: - super_admin @@ -555,6 +616,9 @@ definitions: matchName: description: Match or event name type: string + source: + description: bet api provider (bet365, betfair) + type: string sportID: description: Sport ID type: string @@ -1721,6 +1785,42 @@ paths: summary: Receive Chapa webhook tags: - Chapa + /api/v1/chapa/payments/deposit: + post: + consumes: + - application/json + description: Deposits money into user wallet from user account using Chapa + parameters: + - description: Deposit request payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/domain.ChapaDepositRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.ChapaPaymentUrlResponseWrapper' + "400": + description: Invalid request + schema: + $ref: '#/definitions/domain.Response' + "422": + description: Validation error + schema: + $ref: '#/definitions/domain.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/domain.Response' + security: + - ApiKeyAuth: [] + summary: Deposit money into user wallet using Chapa + tags: + - Chapa /api/v1/chapa/payments/initialize: post: consumes: @@ -1743,6 +1843,27 @@ paths: summary: Initialize a payment transaction tags: - Chapa + /api/v1/chapa/payments/verify: + post: + consumes: + - application/json + parameters: + - description: Webhook Payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/domain.ChapaTransactionType' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: Verifies Chapa webhook transaction + tags: + - Chapa /api/v1/chapa/payments/verify/{tx_ref}: get: consumes: @@ -1764,6 +1885,50 @@ paths: summary: Verify a payment transaction tags: - Chapa + /api/v1/chapa/payments/withdraw: + post: + consumes: + - application/json + description: Initiates a withdrawal transaction using Chapa for the authenticated + user. + parameters: + - description: Chapa Withdraw Request + in: body + name: request + required: true + schema: + $ref: '#/definitions/domain.ChapaWithdrawRequest' + produces: + - application/json + responses: + "200": + description: Withdrawal requested successfully + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + type: string + type: object + "400": + description: Invalid request + schema: + $ref: '#/definitions/domain.Response' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/domain.Response' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/domain.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.Response' + summary: Withdraw using Chapa + tags: + - Chapa /api/v1/chapa/transfers: post: consumes: diff --git a/internal/domain/chapa.go b/internal/domain/chapa.go index 885f6ad..4bd6d90 100644 --- a/internal/domain/chapa.go +++ b/internal/domain/chapa.go @@ -1,6 +1,9 @@ package domain -import "time" +import ( + "errors" + "time" +) var ( ChapaSecret string @@ -8,14 +11,14 @@ var ( ) type InitPaymentRequest struct { - Amount string `json:"amount"` - Currency string `json:"currency"` - Email string `json:"email"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - TxRef string `json:"tx_ref"` - CallbackURL string `json:"callback_url"` - ReturnURL string `json:"return_url"` + Amount Currency `json:"amount"` + Currency string `json:"currency"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + TxRef string `json:"tx_ref"` + CallbackURL string `json:"callback_url"` + ReturnURL string `json:"return_url"` } type TransferRequest struct { @@ -149,3 +152,72 @@ type ChapaWebHookPayment struct { } `json:"customization"` Meta string `json:"meta"` } + +type ChapaWithdrawRequest struct { + WalletID int64 `json:"wallet_id"` // add this + AccountName string `json:"account_name"` + AccountNumber string `json:"account_number"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + BeneficiaryName string `json:"beneficiary_name"` + BankCode string `json:"bank_code"` + BranchID int64 `json:"branch_id"` +} + +type ChapaTransferPayload struct { + AccountName string + AccountNumber string + Amount string + Currency string + BeneficiaryName string + TxRef string + Reference string + BankCode string +} + +type ChapaDepositRequest struct { + Amount Currency `json:"amount"` + PhoneNumber string `json:"phone_number"` + Currency string `json:"currency"` + BranchID int64 `json:"branch_id"` +} + +func (r ChapaDepositRequest) Validate() error { + if r.Amount <= 0 { + return errors.New("amount must be greater than zero") + } + if r.Currency == "" { + return errors.New("currency is required") + } + if r.PhoneNumber == "" { + return errors.New("phone number is required") + } + if r.BranchID == 0 { + return errors.New("branch ID is required") + } + + return nil +} + +type AcceptChapaPaymentRequest struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + PhoneNumber string `json:"phone_number"` + TxRef string `json:"tx_ref"` + CallbackUrl string `json:"callback_url"` + ReturnUrl string `json:"return_url"` + CustomizationTitle string `json:"customization[title]"` + CustomizationDescription string `json:"customization[description]"` +} + +type ChapaPaymentUrlResponse struct { + PaymentURL string `json:"payment_url"` +} + +type ChapaPaymentUrlResponseWrapper struct { + Data ChapaPaymentUrlResponse `json:"data"` + Response +} diff --git a/internal/domain/common.go b/internal/domain/common.go index 6e0acbb..fcccacb 100644 --- a/internal/domain/common.go +++ b/internal/domain/common.go @@ -48,11 +48,14 @@ func (m Currency) String() string { return fmt.Sprintf("$%.2f", x) } -type Response struct { - Message string `json:"message"` - Data interface{} `json:"data,omitempty"` - Success bool `json:"success"` - StatusCode int `json:"status_code"` +type ResponseWDataFactory[T any] struct { + Data T `json:"data"` + Response } - +type Response struct { + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` + Success bool `json:"success"` + StatusCode int `json:"status_code"` +} diff --git a/internal/domain/transaction.go b/internal/domain/transaction.go index d767600..6e4668e 100644 --- a/internal/domain/transaction.go +++ b/internal/domain/transaction.go @@ -57,7 +57,6 @@ type CreateTransaction struct { PaymentOption PaymentOption FullName string PhoneNumber string - // Payment Details for bank BankCode string BeneficiaryName string AccountName string diff --git a/internal/services/chapa/client.go b/internal/services/chapa/client.go new file mode 100644 index 0000000..ff5a888 --- /dev/null +++ b/internal/services/chapa/client.go @@ -0,0 +1,98 @@ +package chapa + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type ChapaClient interface { + IssuePayment(ctx context.Context, payload domain.ChapaTransferPayload) (bool, error) + InitPayment(ctx context.Context, req domain.InitPaymentRequest) (string, error) +} + +type Client struct { + BaseURL string + SecretKey string + HTTPClient *http.Client +} + +func NewClient(baseURL, secretKey string) *Client { + return &Client{ + BaseURL: baseURL, + SecretKey: secretKey, + HTTPClient: http.DefaultClient, + } +} + +func (c *Client) IssuePayment(ctx context.Context, payload domain.ChapaTransferPayload) (bool, error) { + payloadBytes, err := json.Marshal(payload) + if err != nil { + return false, fmt.Errorf("failed to serialize payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/transfers", bytes.NewBuffer(payloadBytes)) + if err != nil { + return false, fmt.Errorf("failed to create HTTP request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.SecretKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return false, fmt.Errorf("chapa HTTP request failed: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return true, nil + } + + return false, fmt.Errorf("chapa error: status %d, body: %s", resp.StatusCode, string(body)) +} + +// service/chapa_service.go +func (c *Client) InitPayment(ctx context.Context, req domain.InitPaymentRequest) (string, error) { + payloadBytes, err := json.Marshal(req) + if err != nil { + return "", fmt.Errorf("failed to serialize payload: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/transaction/initialize", bytes.NewBuffer(payloadBytes)) + if err != nil { + return "", fmt.Errorf("failed to create HTTP request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+c.SecretKey) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(httpReq) + if err != nil { + return "", fmt.Errorf("chapa HTTP request failed: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("chapa error: status %d, body: %s", resp.StatusCode, string(body)) + } + + var response struct { + Data struct { + CheckoutURL string `json:"checkout_url"` + } `json:"data"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return "", fmt.Errorf("failed to parse chapa response: %w", err) + } + + return response.Data.CheckoutURL, nil +} diff --git a/internal/services/chapa/port.go b/internal/services/chapa/port.go index 57ca589..b1b181f 100644 --- a/internal/services/chapa/port.go +++ b/internal/services/chapa/port.go @@ -9,4 +9,6 @@ import ( type ChapaPort interface { HandleChapaTransferWebhook(ctx context.Context, req domain.ChapaWebHookTransfer) error HandleChapaPaymentWebhook(ctx context.Context, req domain.ChapaWebHookPayment) error + WithdrawUsingChapa(ctx context.Context, userID int64, req domain.ChapaWithdrawRequest) error + DepositUsingChapa(ctx context.Context, userID int64, req domain.ChapaDepositRequest) (string, error) } diff --git a/internal/services/chapa/service.go b/internal/services/chapa/service.go index ea7915a..69d5809 100644 --- a/internal/services/chapa/service.go +++ b/internal/services/chapa/service.go @@ -2,15 +2,22 @@ package chapa import ( "context" + "database/sql" + "errors" "fmt" + + // "log/slog" "strconv" + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" + "github.com/google/uuid" "github.com/shopspring/decimal" ) @@ -19,7 +26,11 @@ type Service struct { walletStore wallet.WalletStore userStore user.UserStore referralStore referralservice.ReferralStore - store *repository.Store + branchStore branch.BranchStore + chapaClient ChapaClient + config *config.Config + // logger *slog.Logger + store *repository.Store } func NewService( @@ -27,6 +38,8 @@ func NewService( walletStore wallet.WalletStore, userStore user.UserStore, referralStore referralservice.ReferralStore, + branchStore branch.BranchStore, + chapaClient ChapaClient, store *repository.Store, ) *Service { return &Service{ @@ -34,6 +47,8 @@ func NewService( walletStore: walletStore, userStore: userStore, referralStore: referralStore, + branchStore: branchStore, + chapaClient: chapaClient, store: store, } } @@ -53,6 +68,9 @@ func (s *Service) HandleChapaTransferWebhook(ctx context.Context, req domain.Cha txn, err := s.transactionStore.GetTransactionByID(ctx, referenceID) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("transaction with ID %d not found", referenceID) + } return err } if txn.Verified { @@ -93,6 +111,9 @@ func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.Chap // 2. Fetch transaction txn, err := s.transactionStore.GetTransactionByID(ctx, referenceID) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("transaction with ID %d not found", referenceID) + } return err } if txn.Verified { @@ -122,7 +143,7 @@ func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.Chap } // 7. Check & Create Referral - stats, err := s.referralStore.GetReferralStats(ctx, string(wallet.UserID)) + stats, err := s.referralStore.GetReferralStats(ctx, strconv.FormatInt(wallet.UserID, 10)) if err != nil { return err } @@ -135,3 +156,162 @@ func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.Chap return tx.Commit(ctx) } + +func (s *Service) WithdrawUsingChapa(ctx context.Context, userID int64, req domain.ChapaWithdrawRequest) error { + _, tx, err := s.store.BeginTx(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + // Get the requesting user + user, err := s.userStore.GetUserByID(ctx, userID) + if err != nil { + return fmt.Errorf("user not found: %w", err) + } + + branch, err := s.branchStore.GetBranchByID(ctx, req.BranchID) + if err != nil { + return err + } + + wallets, err := s.walletStore.GetWalletsByUser(ctx, userID) + if err != nil { + return err + } + + var targetWallet *domain.Wallet + for _, w := range wallets { + if w.ID == req.WalletID { + targetWallet = &w + break + } + } + + if targetWallet == nil { + return fmt.Errorf("no wallet found with the specified ID") + } + + if !targetWallet.IsWithdraw || !targetWallet.IsActive { + return fmt.Errorf("wallet not eligible for withdrawal") + } + + if targetWallet.Balance < domain.Currency(req.Amount) { + return fmt.Errorf("insufficient balance") + } + + txID := uuid.New().String() + + payload := domain.ChapaTransferPayload{ + AccountName: req.AccountName, + AccountNumber: req.AccountNumber, + Amount: strconv.FormatInt(req.Amount, 10), + Currency: req.Currency, + BeneficiaryName: req.BeneficiaryName, + TxRef: txID, + Reference: txID, + BankCode: req.BankCode, + } + + ok, err := s.chapaClient.IssuePayment(ctx, payload) + if err != nil || !ok { + return fmt.Errorf("chapa transfer failed: %v", err) + } + + // Create transaction using user and wallet info + _, err = s.transactionStore.CreateTransaction(ctx, domain.CreateTransaction{ + Amount: domain.Currency(req.Amount), + Type: domain.TransactionType(domain.TRANSACTION_CASHOUT), + ReferenceNumber: txID, + AccountName: req.AccountName, + AccountNumber: req.AccountNumber, + BankCode: req.BankCode, + BeneficiaryName: req.BeneficiaryName, + PaymentOption: domain.PaymentOption(domain.BANK), + BranchID: req.BranchID, + BranchName: branch.Name, + BranchLocation: branch.Location, + // CashierID: user.ID, + // CashierName: user.FullName, + FullName: user.FirstName + " " + user.LastName, + PhoneNumber: user.PhoneNumber, + CompanyID: branch.CompanyID, + }) + if err != nil { + return fmt.Errorf("failed to create transaction: %w", err) + } + + newBalance := domain.Currency(req.Amount) + err = s.walletStore.UpdateBalance(ctx, targetWallet.ID, newBalance) + if err != nil { + return fmt.Errorf("failed to update wallet balance: %w", err) + } + + return tx.Commit(ctx) +} + +func (s *Service) DepositUsingChapa(ctx context.Context, userID int64, req domain.ChapaDepositRequest) (string, error) { + _, tx, err := s.store.BeginTx(ctx) + if err != nil { + return "", err + } + defer tx.Rollback(ctx) + + user, err := s.userStore.GetUserByID(ctx, userID) + if err != nil { + return "", err + } + + branch, err := s.branchStore.GetBranchByID(ctx, req.BranchID) + if err != nil { + return "", err + } + + txID := uuid.New().String() + + _, err = s.transactionStore.CreateTransaction(ctx, domain.CreateTransaction{ + Amount: req.Amount, + Type: domain.TransactionType(domain.TRANSACTION_DEPOSIT), + ReferenceNumber: txID, + BranchID: req.BranchID, + BranchName: branch.Name, + BranchLocation: branch.Location, + FullName: user.FirstName + " " + user.LastName, + PhoneNumber: user.PhoneNumber, + CompanyID: branch.CompanyID, + }) + if err != nil { + return "", err + } + + // Fetch user details for Chapa payment + userInfo, err := s.userStore.GetUserByID(ctx, userID) + if err != nil { + return "", err + } + + // Build Chapa InitPaymentRequest (matches Chapa API) + paymentReq := domain.InitPaymentRequest{ + Amount: req.Amount, + Currency: req.Currency, + Email: userInfo.Email, + FirstName: userInfo.FirstName, + LastName: userInfo.LastName, + TxRef: txID, + CallbackURL: s.config.CHAPA_CALLBACK_URL, + ReturnURL: s.config.CHAPA_RETURN_URL, + } + + // Call Chapa to initialize payment + paymentURL, err := s.chapaClient.InitPayment(ctx, paymentReq) + if err != nil { + return "", err + } + + // Commit DB transaction + if err := tx.Commit(ctx); err != nil { + return "", err + } + + return paymentURL, nil +} diff --git a/internal/web_server/handlers/chapa.go b/internal/web_server/handlers/chapa.go index bfd1541..a2d9991 100644 --- a/internal/web_server/handlers/chapa.go +++ b/internal/web_server/handlers/chapa.go @@ -7,7 +7,6 @@ import ( "io" "net/http" - "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/gofiber/fiber/v2" "github.com/google/uuid" @@ -299,7 +298,7 @@ func (h *Handler) VerifyChapaPayment(c *fiber.Ctx) error { } switch txType.Type { - case config.ChapaConfig.ChapaTransferType: + case "Payout": var payload domain.ChapaWebHookTransfer if err := c.BodyParser(&payload); err != nil { return domain.UnProcessableEntityResponse(c) @@ -315,7 +314,7 @@ func (h *Handler) VerifyChapaPayment(c *fiber.Ctx) error { StatusCode: fiber.StatusOK, }) - case config.ChapaConfig.ChapaPaymentType: + case "API": var payload domain.ChapaWebHookPayment if err := c.BodyParser(&payload); err != nil { return domain.UnProcessableEntityResponse(c) @@ -339,3 +338,98 @@ func (h *Handler) VerifyChapaPayment(c *fiber.Ctx) error { }) } } + +// WithdrawUsingChapa godoc +// @Summary Withdraw using Chapa +// @Description Initiates a withdrawal transaction using Chapa for the authenticated user. +// @Tags Chapa +// @Accept json +// @Produce json +// @Param request body domain.ChapaWithdrawRequest true "Chapa Withdraw Request" +// @Success 200 {object} domain.Response{data=string} "Withdrawal requested successfully" +// @Failure 400 {object} domain.Response "Invalid request" +// @Failure 401 {object} domain.Response "Unauthorized" +// @Failure 422 {object} domain.Response "Unprocessable Entity" +// @Failure 500 {object} domain.Response "Internal Server Error" +// @Router /api/v1/chapa/payments/withdraw [post] +func (h *Handler) WithdrawUsingChapa(c *fiber.Ctx) error { + var req domain.ChapaWithdrawRequest + if err := c.BodyParser(&req); err != nil { + return domain.UnProcessableEntityResponse(c) + } + + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + return c.Status(fiber.StatusUnauthorized).JSON(domain.Response{ + Message: "Unauthorized", + Success: false, + StatusCode: fiber.StatusUnauthorized, + }) + } + + if err := h.chapaSvc.WithdrawUsingChapa(c.Context(), userID, req); err != nil { + return domain.FiberErrorResponse(c, err) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Withdrawal requested successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// DepositUsingChapa godoc +// @Summary Deposit money into user wallet using Chapa +// @Description Deposits money into user wallet from user account using Chapa +// @Tags Chapa +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param payload body domain.ChapaDepositRequest true "Deposit request payload" +// @Success 200 {object} domain.ChapaPaymentUrlResponseWrapper +// @Failure 400 {object} domain.Response "Invalid request" +// @Failure 422 {object} domain.Response "Validation error" +// @Failure 500 {object} domain.Response "Internal server error" +// @Router /api/v1/chapa/payments/deposit [post] +func (h *Handler) DepositUsingChapa(c *fiber.Ctx) error { + // Extract user info from token (adjust as per your auth middleware) + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + return c.Status(fiber.StatusUnauthorized).JSON(domain.Response{ + Message: "Unauthorized", + Success: false, + StatusCode: fiber.StatusUnauthorized, + }) + } + + var req domain.ChapaDepositRequest + if err := c.BodyParser(&req); err != nil { + return domain.UnProcessableEntityResponse(c) + } + + // Validate input in domain/model (you may have a Validate method) + if err := req.Validate(); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.Response{ + Message: err.Error(), + Success: false, + StatusCode: fiber.StatusBadRequest, + }) + } + + // Call service to handle the deposit logic and get payment URL + paymentUrl, svcErr := h.chapaSvc.DepositUsingChapa(c.Context(), userID, req) + if svcErr != nil { + return domain.FiberErrorResponse(c, svcErr) + } + + return c.Status(fiber.StatusOK).JSON(domain.ResponseWDataFactory[domain.ChapaPaymentUrlResponse]{ + Data: domain.ChapaPaymentUrlResponse{ + PaymentURL: paymentUrl, + }, + Response: domain.Response{ + Message: "Deposit process started on wallet, fulfill payment using the URL provided", + Success: true, + StatusCode: fiber.StatusOK, + }, + }) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 0e1acfc..7d31a87 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -182,13 +182,16 @@ func (a *App) initAppRoutes() { a.fiber.Post("/transfer/refill/:id", a.authMiddleware, h.RefillWallet) //Chapa Routes + group.Post("/chapa/payments/verify", h.VerifyChapaPayment) + group.Post("/chapa/payments/withdraw", h.WithdrawUsingChapa) + group.Post("/chapa/payments/deposit", h.DepositUsingChapa) - group.Post("/chapa/payments/initialize", h.InitializePayment) - group.Get("/chapa/payments/verify/:tx_ref", h.VerifyTransaction) - group.Post("/chapa/payments/callback", h.ReceiveWebhook) - group.Get("/chapa/banks", h.GetBanks) - group.Post("/chapa/transfers", h.CreateTransfer) - group.Get("/chapa/transfers/verify/:transfer_ref", h.VerifyTransfer) + // group.Post("/chapa/payments/initialize", h.InitializePayment) + // group.Get("/chapa/payments/verify/:tx_ref", h.VerifyTransaction) + // group.Post("/chapa/payments/callback", h.ReceiveWebhook) + // group.Get("/chapa/banks", h.GetBanks) + // group.Post("/chapa/transfers", h.CreateTransfer) + // group.Get("/chapa/transfers/verify/:transfer_ref", h.VerifyTransfer) //Alea Play Virtual Game Routes group.Get("/alea-play/launch", a.authMiddleware, h.LaunchAleaGame) From 84bbe53bb796a4652f16143225757afd2220d4d6 Mon Sep 17 00:00:00 2001 From: Asher Samuel Date: Mon, 2 Jun 2025 00:59:22 +0300 Subject: [PATCH 05/10] odds and events fetch for bwin (together) --- internal/domain/odds.go | 4 +- internal/repository/odds.go | 68 ++++++----- internal/services/event/service.go | 41 +------ internal/services/odds/service.go | 174 ++++++++++++++++++++++++++++- 4 files changed, 219 insertions(+), 68 deletions(-) diff --git a/internal/domain/odds.go b/internal/domain/odds.go index 990c6a0..b617079 100644 --- a/internal/domain/odds.go +++ b/internal/domain/odds.go @@ -1,7 +1,6 @@ package domain import ( - "encoding/json" "time" ) @@ -15,10 +14,11 @@ type Market struct { MarketName string MarketID string UpdatedAt time.Time - Odds []json.RawMessage + Odds []map[string]interface{} Name string Handicap string OddsVal float64 + Source string } type Odd struct { diff --git a/internal/repository/odds.go b/internal/repository/odds.go index fd20d1c..875ea97 100644 --- a/internal/repository/odds.go +++ b/internal/repository/odds.go @@ -17,15 +17,19 @@ func (s *Store) SaveNonLiveMarket(ctx context.Context, m domain.Market) error { return nil } - for _, raw := range m.Odds { - var item map[string]interface{} - if err := json.Unmarshal(raw, &item); err != nil { - continue - } + for _, item := range m.Odds { + var name string + var oddsVal float64 - name := getString(item["name"]) + if m.Source == "bwin" { + nameValue := getMap(item["name"]) + name = getString(nameValue["value"]) + oddsVal = getFloat(item["odds"]) + } else { + name = getString(item["name"]) + oddsVal = getConvertedFloat(item["odds"]) + } handicap := getString(item["handicap"]) - oddsVal := getFloat(item["odds"]) rawOddsBytes, _ := json.Marshal(m.Odds) @@ -43,7 +47,7 @@ func (s *Store) SaveNonLiveMarket(ctx context.Context, m domain.Market) error { Category: pgtype.Text{Valid: false}, RawOdds: rawOddsBytes, IsActive: pgtype.Bool{Bool: true, Valid: true}, - Source: pgtype.Text{String: "b365api", Valid: true}, + Source: pgtype.Text{String: m.Source, Valid: true}, FetchedAt: pgtype.Timestamp{Time: time.Now(), Valid: true}, } @@ -85,23 +89,6 @@ func writeFailedMarketLog(m domain.Market, err error) error { return writeErr } -func getString(v interface{}) string { - if s, ok := v.(string); ok { - return s - } - return "" -} - -func getFloat(v interface{}) float64 { - if s, ok := v.(string); ok { - f, err := strconv.ParseFloat(s, 64) - if err == nil { - return f - } - } - return 0 -} - func (s *Store) GetPrematchOdds(ctx context.Context, eventID string) ([]domain.Odd, error) { odds, err := s.queries.GetPrematchOdds(ctx) if err != nil { @@ -286,3 +273,34 @@ func (s *Store) GetPrematchOddsByUpcomingID(ctx context.Context, upcomingID stri return domainOdds, nil } + +func getString(v interface{}) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func getConvertedFloat(v interface{}) float64 { + if s, ok := v.(string); ok { + f, err := strconv.ParseFloat(s, 64) + if err == nil { + return f + } + } + return 0 +} + +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 +} diff --git a/internal/services/event/service.go b/internal/services/event/service.go index 1fc2f12..1ad4310 100644 --- a/internal/services/event/service.go +++ b/internal/services/event/service.go @@ -38,7 +38,6 @@ func (s *service) FetchLiveEvents(ctx context.Context) error { {"https://api.b365api.com/v1/bet365/inplay?sport_id=%d&&token=%s", "bet365"}, {"https://api.b365api.com/v1/betfair/sb/inplay?sport_id=%d&token=%s", "betfair"}, {"https://api.b365api.com/v1/1xbet/inplay?sport_id=%d&token=%s", "1xbet"}, - {"https://api.b365api.com/v1/bwin/inplay?sport_id=%d&token=%s", "bwin"}, } for _, url := range urls { @@ -83,8 +82,6 @@ func (s *service) fetchLiveEvents(ctx context.Context, url, source string) error case "1xbet": // betfair and 1xbet have the same result structure events = handleBetfairprematch(body, sportID, source) - case "bwin": - events = handleBwinprematch(body, sportID, source) } for _, event := range events { @@ -185,42 +182,6 @@ func handleBetfairprematch(body []byte, sportID int, source string) []domain.Eve return events } -func handleBwinprematch(body []byte, sportID int, source string) []domain.Event { - var data struct { - Success int `json:"success"` - Results []map[string]interface{} `json:"results"` - } - - events := []domain.Event{} - if err := json.Unmarshal(body, &data); err != nil || data.Success != 1 { - fmt.Printf("%s: Decode failed for sport_id=%d\nRaw: %s\n", source, sportID, string(body)) - return events - } - - for _, ev := range data.Results { - homeTeam := getString(ev["HomeTeam"]) - awayTeam := getString(ev["HomeTeam"]) - - event := domain.Event{ - ID: getString(ev["Id"]), - SportID: fmt.Sprintf("%d", sportID), - TimerStatus: "1", - HomeTeam: homeTeam, - AwayTeam: awayTeam, - StartTime: time.Now().UTC().Format(time.RFC3339), - LeagueID: getString(ev["LeagueId"]), - LeagueName: getString(ev["LeagueName"]), - IsLive: true, - Status: "live", - Source: source, - } - - events = append(events, event) - } - - return events -} - func (s *service) FetchUpcomingEvents(ctx context.Context) error { var wg sync.WaitGroup urls := []struct { @@ -288,7 +249,7 @@ func (s *service) fetchUpcomingEventsFromProvider(ctx context.Context, url, sour if !slices.Contains(domain.SupportedLeagues, leagueID) { // fmt.Printf("⚠️ Skipping league %s (%d) as it is not supported\n", ev.League.Name, leagueID) skippedLeague = append(skippedLeague, ev.League.Name) - // continue + continue } event := domain.UpcomingEvent{ diff --git a/internal/services/odds/service.go b/internal/services/odds/service.go index 36f3a8a..85ca2f7 100644 --- a/internal/services/odds/service.go +++ b/internal/services/odds/service.go @@ -10,6 +10,7 @@ import ( "log/slog" "net/http" "strconv" + "sync" "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/config" @@ -35,6 +36,37 @@ func New(store *repository.Store, cfg *config.Config, logger *slog.Logger) *Serv // 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) + + go func() { + defer wg.Done() + if err := s.fetchBet365Odds(ctx); err != nil { + errChan <- fmt.Errorf("bet365 odds fetching error: %w", err) + } + }() + + go func() { + defer wg.Done() + if err := s.fetchBwinOdds(ctx); err != nil { + errChan <- fmt.Errorf("bwin odds fetching error: %w", err) + } + }() + + var errs []error + for err := range errChan { + errs = append(errs, err) + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} + +func (s *ServiceImpl) fetchBet365Odds(ctx context.Context) error { eventIDs, err := s.store.GetAllUpcomingEvents(ctx) if err != nil { log.Printf("❌ Failed to fetch upcoming event IDs: %v", err) @@ -107,6 +139,91 @@ func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { 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{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 { + log.Printf("❌ Failed to create request for sportId %d: %v", sportId, err) + continue + } + + resp, err := s.client.Do(req) + if err != nil { + log.Printf("❌ Failed to fetch request for sportId %d: %v", sportId, err) + continue + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("❌ Failed to read response body for sportId %d: %v", sportId, 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)) + continue + } + + for _, res := range data.Results { + if getInt(res["Id"]) == -1 { + continue + } + + event := domain.Event{ + ID: strconv.Itoa(getInt(res["Id"])), + SportID: strconv.Itoa(getInt(res["SportId"])), + LeagueID: strconv.Itoa(getInt(res["LeagueId"])), + LeagueName: getString(res["Leaguename"]), + HomeTeam: getString(res["HomeTeam"]), + HomeTeamID: strconv.Itoa(getInt(res["HomeTeamId"])), + AwayTeam: getString(res["AwayTeam"]), + AwayTeamID: strconv.Itoa(getInt(res["AwayTeamId"])), + StartTime: time.Now().UTC().Format(time.RFC3339), + TimerStatus: "1", + IsLive: true, + Status: "live", + Source: "bwin", + } + + if err := s.store.SaveEvent(ctx, event); err != nil { + fmt.Printf("Could not store live event [id=%s]: %v\n", event.ID, err) + continue + } + + for _, m := range getMapArray(res["Markets"]) { + name := getMap(m["name"]) + marketName := getString(name["value"]) + + market := domain.Market{ + EventID: event.ID, + MarketID: getString(m["id"]), + MarketCategory: getString(m["category"]), + MarketName: marketName, + Source: "bwin", + } + + results := getMapArray(m["results"]) + market.Odds = results + + s.store.SaveNonLiveMarket(ctx, market) + + } + } + + } + return nil +} + func (s *ServiceImpl) parseFootball(ctx context.Context, res json.RawMessage) error { var footballRes domain.FootballOddsResponse if err := json.Unmarshal(res, &footballRes); err != nil { @@ -264,6 +381,13 @@ func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName continue } + marketOdds, err := convertRawMessage(market.Odds) + if err != nil { + s.logger.Error("failed to conver json.RawMessage to []map[string]interface{} for market_id: ", market.ID) + errs = append(errs, err) + continue + } + marketRecord := domain.Market{ EventID: eventID, FI: fi, @@ -272,7 +396,9 @@ func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName MarketName: market.Name, MarketID: marketIDstr, UpdatedAt: updatedAt, - Odds: market.Odds, + Odds: marketOdds, + // bwin won't reach this code so bet365 is hardcoded for now + Source: "bet365", } err = s.store.SaveNonLiveMarket(ctx, marketRecord) @@ -313,3 +439,49 @@ func (s *ServiceImpl) GetPrematchOddsByUpcomingID(ctx context.Context, upcomingI func (s *ServiceImpl) GetPaginatedPrematchOddsByUpcomingID(ctx context.Context, upcomingID string, limit, offset domain.ValidInt64) ([]domain.Odd, error) { return s.store.GetPaginatedPrematchOddsByUpcomingID(ctx, upcomingID, limit, offset) } + +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 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 +} From f2ec267347e7bbdc0f2a4666123eeb7e0f6bc9da Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Mon, 2 Jun 2025 17:22:07 +0300 Subject: [PATCH 06/10] wallet security+log file --- db/migrations/000001_fortune.up.sql | 1 + docs/docs.go | 391 ++----------- docs/swagger.json | 391 ++----------- docs/swagger.yaml | 260 +-------- go.mod | 19 +- go.sum | 2 + internal/domain/chapa.go | 5 + internal/logger/logger.go | 15 +- internal/services/chapa/client.go | 28 + internal/services/chapa/port.go | 1 + internal/services/chapa/service.go | 36 +- internal/services/result/sports_eval.go | 34 +- internal/web_server/handlers/chapa.go | 513 +++++++++--------- .../handlers/read_chapa_banks_handler_test.go | 131 +++++ internal/web_server/routes.go | 7 +- makefile | 2 +- 16 files changed, 617 insertions(+), 1219 deletions(-) create mode 100644 internal/web_server/handlers/read_chapa_banks_handler_test.go diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 1db8ddb..6e12d64 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -297,6 +297,7 @@ ADD CONSTRAINT fk_branch_operations_operations FOREIGN KEY (operation_id) REFERE ALTER TABLE branch_cashiers ADD CONSTRAINT fk_branch_cashiers_users FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, ADD CONSTRAINT fk_branch_cashiers_branches FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE CASCADE; + ALTER TABLE companies ADD CONSTRAINT fk_companies_admin FOREIGN KEY (admin_id) REFERENCES users(id), ADD CONSTRAINT fk_companies_wallet FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE CASCADE; diff --git a/docs/docs.go b/docs/docs.go index 044c114..78486ff 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -306,7 +306,6 @@ const docTemplate = `{ }, "/api/v1/chapa/banks": { "get": { - "description": "Fetch all supported banks from Chapa", "consumes": [ "application/json" ], @@ -316,46 +315,42 @@ const docTemplate = `{ "tags": [ "Chapa" ], - "summary": "Get list of banks", + "summary": "fetches chapa supported banks", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/domain.ChapaSupportedBanksResponse" + "$ref": "#/definitions/domain.ChapaSupportedBanksResponseWrapper" } - } - } - } - }, - "/api/v1/chapa/payments/callback": { - "post": { - "description": "Endpoint to receive webhook payloads from Chapa", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chapa" - ], - "summary": "Receive Chapa webhook", - "parameters": [ - { - "description": "Webhook Payload (dynamic)", - "name": "payload", - "in": "body", - "required": true, + }, + "400": { + "description": "Bad Request", "schema": { - "type": "object" + "$ref": "#/definitions/domain.Response" } - } - ], - "responses": { - "200": { - "description": "ok", + }, + "401": { + "description": "Unauthorized", "schema": { - "type": "string" + "$ref": "#/definitions/domain.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.Response" } } } @@ -363,11 +358,6 @@ const docTemplate = `{ }, "/api/v1/chapa/payments/deposit": { "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], "description": "Deposits money into user wallet from user account using Chapa", "consumes": [ "application/json" @@ -418,40 +408,6 @@ const docTemplate = `{ } } }, - "/api/v1/chapa/payments/initialize": { - "post": { - "description": "Initiate a payment through Chapa", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chapa" - ], - "summary": "Initialize a payment transaction", - "parameters": [ - { - "description": "Payment initialization request", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.InitPaymentRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.InitPaymentResponse" - } - } - } - } - }, "/api/v1/chapa/payments/verify": { "post": { "consumes": [ @@ -485,38 +441,6 @@ const docTemplate = `{ } } }, - "/api/v1/chapa/payments/verify/{tx_ref}": { - "get": { - "description": "Verify the transaction status from Chapa using tx_ref", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chapa" - ], - "summary": "Verify a payment transaction", - "parameters": [ - { - "type": "string", - "description": "Transaction Reference", - "name": "tx_ref", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.VerifyTransactionResponse" - } - } - } - } - }, "/api/v1/chapa/payments/withdraw": { "post": { "description": "Initiates a withdrawal transaction using Chapa for the authenticated user.", @@ -587,72 +511,6 @@ const docTemplate = `{ } } }, - "/api/v1/chapa/transfers": { - "post": { - "description": "Initiate a transfer request via Chapa", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chapa" - ], - "summary": "Create a money transfer", - "parameters": [ - { - "description": "Transfer request body", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.TransferRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.CreateTransferResponse" - } - } - } - } - }, - "/api/v1/chapa/transfers/verify/{transfer_ref}": { - "get": { - "description": "Check the status of a money transfer via reference", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chapa" - ], - "summary": "Verify a transfer", - "parameters": [ - { - "type": "string", - "description": "Transfer Reference", - "name": "transfer_ref", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.VerifyTransferResponse" - } - } - } - } - }, "/api/v1/virtual-games/recommendations/{userID}": { "get": { "description": "Returns a list of recommended virtual games for a specific user", @@ -4666,17 +4524,18 @@ const docTemplate = `{ } } }, - "domain.ChapaSupportedBanksResponse": { + "domain.ChapaSupportedBanksResponseWrapper": { "type": "object", "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/domain.ChapaSupportedBank" - } - }, + "data": {}, "message": { "type": "string" + }, + "status_code": { + "type": "integer" + }, + "success": { + "type": "boolean" } } }, @@ -4770,76 +4629,6 @@ const docTemplate = `{ } } }, - "domain.CreateTransferResponse": { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/domain.TransferData" - }, - "message": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, - "domain.InitPaymentData": { - "type": "object", - "properties": { - "checkout_url": { - "type": "string" - }, - "tx_ref": { - "type": "string" - } - } - }, - "domain.InitPaymentRequest": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "callback_url": { - "type": "string" - }, - "currency": { - "type": "string" - }, - "email": { - "type": "string" - }, - "first_name": { - "type": "string" - }, - "last_name": { - "type": "string" - }, - "return_url": { - "type": "string" - }, - "tx_ref": { - "type": "string" - } - } - }, - "domain.InitPaymentResponse": { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/domain.InitPaymentData" - }, - "message": { - "description": "e.g., \"Payment initialized\"", - "type": "string" - }, - "status": { - "description": "\"success\"", - "type": "string" - } - } - }, "domain.Odd": { "type": "object", "properties": { @@ -5167,86 +4956,6 @@ const docTemplate = `{ } } }, - "domain.TransactionData": { - "type": "object", - "properties": { - "amount": { - "type": "string" - }, - "currency": { - "type": "string" - }, - "email": { - "type": "string" - }, - "status": { - "type": "string" - }, - "tx_ref": { - "type": "string" - } - } - }, - "domain.TransferData": { - "type": "object", - "properties": { - "amount": { - "type": "string" - }, - "currency": { - "type": "string" - }, - "reference": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, - "domain.TransferRequest": { - "type": "object", - "properties": { - "account_number": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "bank_code": { - "type": "string" - }, - "currency": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "recipient_name": { - "type": "string" - }, - "reference": { - "type": "string" - } - } - }, - "domain.TransferVerificationData": { - "type": "object", - "properties": { - "account_name": { - "type": "string" - }, - "bank_code": { - "type": "string" - }, - "reference": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, "domain.UpcomingEvent": { "type": "object", "properties": { @@ -5353,34 +5062,6 @@ const docTemplate = `{ } } }, - "domain.VerifyTransactionResponse": { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/domain.TransactionData" - }, - "message": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, - "domain.VerifyTransferResponse": { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/domain.TransferVerificationData" - }, - "message": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, "domain.VirtualGame": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index d225501..948658c 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -298,7 +298,6 @@ }, "/api/v1/chapa/banks": { "get": { - "description": "Fetch all supported banks from Chapa", "consumes": [ "application/json" ], @@ -308,46 +307,42 @@ "tags": [ "Chapa" ], - "summary": "Get list of banks", + "summary": "fetches chapa supported banks", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/domain.ChapaSupportedBanksResponse" + "$ref": "#/definitions/domain.ChapaSupportedBanksResponseWrapper" } - } - } - } - }, - "/api/v1/chapa/payments/callback": { - "post": { - "description": "Endpoint to receive webhook payloads from Chapa", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chapa" - ], - "summary": "Receive Chapa webhook", - "parameters": [ - { - "description": "Webhook Payload (dynamic)", - "name": "payload", - "in": "body", - "required": true, + }, + "400": { + "description": "Bad Request", "schema": { - "type": "object" + "$ref": "#/definitions/domain.Response" } - } - ], - "responses": { - "200": { - "description": "ok", + }, + "401": { + "description": "Unauthorized", "schema": { - "type": "string" + "$ref": "#/definitions/domain.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.Response" } } } @@ -355,11 +350,6 @@ }, "/api/v1/chapa/payments/deposit": { "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], "description": "Deposits money into user wallet from user account using Chapa", "consumes": [ "application/json" @@ -410,40 +400,6 @@ } } }, - "/api/v1/chapa/payments/initialize": { - "post": { - "description": "Initiate a payment through Chapa", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chapa" - ], - "summary": "Initialize a payment transaction", - "parameters": [ - { - "description": "Payment initialization request", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.InitPaymentRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.InitPaymentResponse" - } - } - } - } - }, "/api/v1/chapa/payments/verify": { "post": { "consumes": [ @@ -477,38 +433,6 @@ } } }, - "/api/v1/chapa/payments/verify/{tx_ref}": { - "get": { - "description": "Verify the transaction status from Chapa using tx_ref", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chapa" - ], - "summary": "Verify a payment transaction", - "parameters": [ - { - "type": "string", - "description": "Transaction Reference", - "name": "tx_ref", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.VerifyTransactionResponse" - } - } - } - } - }, "/api/v1/chapa/payments/withdraw": { "post": { "description": "Initiates a withdrawal transaction using Chapa for the authenticated user.", @@ -579,72 +503,6 @@ } } }, - "/api/v1/chapa/transfers": { - "post": { - "description": "Initiate a transfer request via Chapa", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chapa" - ], - "summary": "Create a money transfer", - "parameters": [ - { - "description": "Transfer request body", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.TransferRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.CreateTransferResponse" - } - } - } - } - }, - "/api/v1/chapa/transfers/verify/{transfer_ref}": { - "get": { - "description": "Check the status of a money transfer via reference", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chapa" - ], - "summary": "Verify a transfer", - "parameters": [ - { - "type": "string", - "description": "Transfer Reference", - "name": "transfer_ref", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.VerifyTransferResponse" - } - } - } - } - }, "/api/v1/virtual-games/recommendations/{userID}": { "get": { "description": "Returns a list of recommended virtual games for a specific user", @@ -4658,17 +4516,18 @@ } } }, - "domain.ChapaSupportedBanksResponse": { + "domain.ChapaSupportedBanksResponseWrapper": { "type": "object", "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/domain.ChapaSupportedBank" - } - }, + "data": {}, "message": { "type": "string" + }, + "status_code": { + "type": "integer" + }, + "success": { + "type": "boolean" } } }, @@ -4762,76 +4621,6 @@ } } }, - "domain.CreateTransferResponse": { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/domain.TransferData" - }, - "message": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, - "domain.InitPaymentData": { - "type": "object", - "properties": { - "checkout_url": { - "type": "string" - }, - "tx_ref": { - "type": "string" - } - } - }, - "domain.InitPaymentRequest": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "callback_url": { - "type": "string" - }, - "currency": { - "type": "string" - }, - "email": { - "type": "string" - }, - "first_name": { - "type": "string" - }, - "last_name": { - "type": "string" - }, - "return_url": { - "type": "string" - }, - "tx_ref": { - "type": "string" - } - } - }, - "domain.InitPaymentResponse": { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/domain.InitPaymentData" - }, - "message": { - "description": "e.g., \"Payment initialized\"", - "type": "string" - }, - "status": { - "description": "\"success\"", - "type": "string" - } - } - }, "domain.Odd": { "type": "object", "properties": { @@ -5159,86 +4948,6 @@ } } }, - "domain.TransactionData": { - "type": "object", - "properties": { - "amount": { - "type": "string" - }, - "currency": { - "type": "string" - }, - "email": { - "type": "string" - }, - "status": { - "type": "string" - }, - "tx_ref": { - "type": "string" - } - } - }, - "domain.TransferData": { - "type": "object", - "properties": { - "amount": { - "type": "string" - }, - "currency": { - "type": "string" - }, - "reference": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, - "domain.TransferRequest": { - "type": "object", - "properties": { - "account_number": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "bank_code": { - "type": "string" - }, - "currency": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "recipient_name": { - "type": "string" - }, - "reference": { - "type": "string" - } - } - }, - "domain.TransferVerificationData": { - "type": "object", - "properties": { - "account_name": { - "type": "string" - }, - "bank_code": { - "type": "string" - }, - "reference": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, "domain.UpcomingEvent": { "type": "object", "properties": { @@ -5345,34 +5054,6 @@ } } }, - "domain.VerifyTransactionResponse": { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/domain.TransactionData" - }, - "message": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, - "domain.VerifyTransferResponse": { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/domain.TransferVerificationData" - }, - "message": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, "domain.VirtualGame": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index a0003a7..feaedda 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -185,14 +185,15 @@ definitions: updated_at: type: string type: object - domain.ChapaSupportedBanksResponse: + domain.ChapaSupportedBanksResponseWrapper: properties: - data: - items: - $ref: '#/definitions/domain.ChapaSupportedBank' - type: array + data: {} message: type: string + status_code: + type: integer + success: + type: boolean type: object domain.ChapaTransactionType: properties: @@ -254,52 +255,6 @@ definitions: - $ref: '#/definitions/domain.OutcomeStatus' example: 1 type: object - domain.CreateTransferResponse: - properties: - data: - $ref: '#/definitions/domain.TransferData' - message: - type: string - status: - type: string - type: object - domain.InitPaymentData: - properties: - checkout_url: - type: string - tx_ref: - type: string - type: object - domain.InitPaymentRequest: - properties: - amount: - type: integer - callback_url: - type: string - currency: - type: string - email: - type: string - first_name: - type: string - last_name: - type: string - return_url: - type: string - tx_ref: - type: string - type: object - domain.InitPaymentResponse: - properties: - data: - $ref: '#/definitions/domain.InitPaymentData' - message: - description: e.g., "Payment initialized" - type: string - status: - description: '"success"' - type: string - type: object domain.Odd: properties: category: @@ -529,58 +484,6 @@ definitions: example: 1 type: integer type: object - domain.TransactionData: - properties: - amount: - type: string - currency: - type: string - email: - type: string - status: - type: string - tx_ref: - type: string - type: object - domain.TransferData: - properties: - amount: - type: string - currency: - type: string - reference: - type: string - status: - type: string - type: object - domain.TransferRequest: - properties: - account_number: - type: string - amount: - type: string - bank_code: - type: string - currency: - type: string - reason: - type: string - recipient_name: - type: string - reference: - type: string - type: object - domain.TransferVerificationData: - properties: - account_name: - type: string - bank_code: - type: string - reference: - type: string - status: - type: string - type: object domain.UpcomingEvent: properties: awayKitImage: @@ -659,24 +562,6 @@ definitions: description: Veli's user identifier type: string type: object - domain.VerifyTransactionResponse: - properties: - data: - $ref: '#/definitions/domain.TransactionData' - message: - type: string - status: - type: string - type: object - domain.VerifyTransferResponse: - properties: - data: - $ref: '#/definitions/domain.TransferVerificationData' - message: - type: string - status: - type: string - type: object domain.VirtualGame: properties: category: @@ -1752,37 +1637,34 @@ paths: get: consumes: - application/json - description: Fetch all supported banks from Chapa produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/domain.ChapaSupportedBanksResponse' - summary: Get list of banks - tags: - - Chapa - /api/v1/chapa/payments/callback: - post: - consumes: - - application/json - description: Endpoint to receive webhook payloads from Chapa - parameters: - - description: Webhook Payload (dynamic) - in: body - name: payload - required: true - schema: - type: object - produces: - - application/json - responses: - "200": - description: ok + $ref: '#/definitions/domain.ChapaSupportedBanksResponseWrapper' + "400": + description: Bad Request schema: - type: string - summary: Receive Chapa webhook + $ref: '#/definitions/domain.Response' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/domain.Response' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.Response' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/domain.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.Response' + summary: fetches chapa supported banks tags: - Chapa /api/v1/chapa/payments/deposit: @@ -1816,33 +1698,9 @@ paths: description: Internal server error schema: $ref: '#/definitions/domain.Response' - security: - - ApiKeyAuth: [] summary: Deposit money into user wallet using Chapa tags: - Chapa - /api/v1/chapa/payments/initialize: - post: - consumes: - - application/json - description: Initiate a payment through Chapa - parameters: - - description: Payment initialization request - in: body - name: payload - required: true - schema: - $ref: '#/definitions/domain.InitPaymentRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.InitPaymentResponse' - summary: Initialize a payment transaction - tags: - - Chapa /api/v1/chapa/payments/verify: post: consumes: @@ -1864,27 +1722,6 @@ paths: summary: Verifies Chapa webhook transaction tags: - Chapa - /api/v1/chapa/payments/verify/{tx_ref}: - get: - consumes: - - application/json - description: Verify the transaction status from Chapa using tx_ref - parameters: - - description: Transaction Reference - in: path - name: tx_ref - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.VerifyTransactionResponse' - summary: Verify a payment transaction - tags: - - Chapa /api/v1/chapa/payments/withdraw: post: consumes: @@ -1929,49 +1766,6 @@ paths: summary: Withdraw using Chapa tags: - Chapa - /api/v1/chapa/transfers: - post: - consumes: - - application/json - description: Initiate a transfer request via Chapa - parameters: - - description: Transfer request body - in: body - name: payload - required: true - schema: - $ref: '#/definitions/domain.TransferRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.CreateTransferResponse' - summary: Create a money transfer - tags: - - Chapa - /api/v1/chapa/transfers/verify/{transfer_ref}: - get: - consumes: - - application/json - description: Check the status of a money transfer via reference - parameters: - - description: Transfer Reference - in: path - name: transfer_ref - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.VerifyTransferResponse' - summary: Verify a transfer - tags: - - Chapa /api/v1/virtual-games/recommendations/{userID}: get: consumes: diff --git a/go.mod b/go.mod index 1b6761d..32d9786 100644 --- a/go.mod +++ b/go.mod @@ -9,28 +9,28 @@ require ( github.com/gofiber/fiber/v2 v2.52.6 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 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/shopspring/decimal v1.4.0 github.com/stretchr/testify v1.10.0 + // github.com/stretchr/testify v1.10.0 github.com/swaggo/fiber-swagger v1.3.0 github.com/swaggo/swag v1.16.4 + github.com/valyala/fasthttp v1.59.0 golang.org/x/crypto v0.36.0 ) -require github.com/gorilla/websocket v1.5.3 // indirect - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect -) - require ( + // github.com/davecgh/go-spew v1.1.1 // indirect + // github.com/pmezard/go-difflib v1.0.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/andybalholm/brotli v1.1.1 // indirect // github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect + github.com/davecgh/go-spew v1.1.1 // 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 @@ -38,7 +38,6 @@ 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/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 @@ -50,13 +49,13 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/shopspring/decimal v1.4.0 + github.com/stretchr/objx v0.5.2 // 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 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 32967eb..69ce8cd 100644 --- a/go.sum +++ b/go.sum @@ -120,6 +120,8 @@ github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/domain/chapa.go b/internal/domain/chapa.go index 4bd6d90..1dba8f9 100644 --- a/internal/domain/chapa.go +++ b/internal/domain/chapa.go @@ -221,3 +221,8 @@ type ChapaPaymentUrlResponseWrapper struct { Data ChapaPaymentUrlResponse `json:"data"` Response } + +type ChapaSupportedBanksResponseWrapper struct { + Data []ChapaSupportedBank `json:"data"` + Response +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 043836c..41e6bb4 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -18,13 +18,24 @@ var Environment = map[string]string{ func NewLogger(env string, lvl slog.Level) *slog.Logger { var logHandler slog.Handler + + err := os.MkdirAll("logs", os.ModePerm) + if err != nil { + panic("Failed to create log directory: " + err.Error()) + } + + file, err := os.OpenFile("logs/app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + panic("Failed to open log file: " + err.Error()) + } + switch env { case "development": - logHandler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + logHandler = slog.NewTextHandler(file, &slog.HandlerOptions{ Level: lvl, }) default: - logHandler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + logHandler = slog.NewJSONHandler(file, &slog.HandlerOptions{ Level: lvl, }) } diff --git a/internal/services/chapa/client.go b/internal/services/chapa/client.go index ff5a888..8e0374f 100644 --- a/internal/services/chapa/client.go +++ b/internal/services/chapa/client.go @@ -14,12 +14,14 @@ import ( type ChapaClient interface { IssuePayment(ctx context.Context, payload domain.ChapaTransferPayload) (bool, error) InitPayment(ctx context.Context, req domain.InitPaymentRequest) (string, error) + FetchBanks() ([]domain.ChapaSupportedBank, error) } type Client struct { BaseURL string SecretKey string HTTPClient *http.Client + UserAgent string } func NewClient(baseURL, secretKey string) *Client { @@ -27,6 +29,7 @@ func NewClient(baseURL, secretKey string) *Client { BaseURL: baseURL, SecretKey: secretKey, HTTPClient: http.DefaultClient, + UserAgent: "FortuneBet/1.0", } } @@ -96,3 +99,28 @@ func (c *Client) InitPayment(ctx context.Context, req domain.InitPaymentRequest) return response.Data.CheckoutURL, nil } + +func (c *Client) FetchBanks() ([]domain.ChapaSupportedBank, error) { + req, _ := http.NewRequest("GET", c.BaseURL+"/banks", nil) + req.Header.Set("Authorization", "Bearer "+c.SecretKey) + fmt.Printf("\n\nbase URL is: %s\n\n", c.BaseURL) + + res, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + var resp struct { + Message string `json:"message"` + Data []domain.ChapaSupportedBank `json:"data"` + } + + if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { + return nil, err + } + + fmt.Printf("\n\nclient fetched banks: %+v\n\n", resp.Data) + + return resp.Data, nil +} diff --git a/internal/services/chapa/port.go b/internal/services/chapa/port.go index b1b181f..0cdb213 100644 --- a/internal/services/chapa/port.go +++ b/internal/services/chapa/port.go @@ -11,4 +11,5 @@ type ChapaPort interface { HandleChapaPaymentWebhook(ctx context.Context, req domain.ChapaWebHookPayment) error WithdrawUsingChapa(ctx context.Context, userID int64, req domain.ChapaWithdrawRequest) error DepositUsingChapa(ctx context.Context, userID int64, req domain.ChapaDepositRequest) (string, error) + GetSupportedBanks() ([]domain.ChapaSupportedBank, error) } diff --git a/internal/services/chapa/service.go b/internal/services/chapa/service.go index 69d5809..9c67ab4 100644 --- a/internal/services/chapa/service.go +++ b/internal/services/chapa/service.go @@ -256,7 +256,7 @@ func (s *Service) DepositUsingChapa(ctx context.Context, userID int64, req domai return "", err } defer tx.Rollback(ctx) - + user, err := s.userStore.GetUserByID(ctx, userID) if err != nil { return "", err @@ -315,3 +315,37 @@ func (s *Service) DepositUsingChapa(ctx context.Context, userID int64, req domai return paymentURL, nil } + +func (s *Service) GetSupportedBanks() ([]domain.ChapaSupportedBank, error) { + banks, err := s.chapaClient.FetchBanks() + fmt.Printf("\n\nfetched banks: %+v\n\n", banks) + if err != nil { + return nil, err + } + + // Add formatting logic (same as in original controller) + for i := range banks { + if banks[i].IsMobilemoney != nil && *(banks[i].IsMobilemoney) == 1 { + banks[i].AcctNumberRegex = "/^09[0-9]{8}$/" + banks[i].ExampleValue = "0952097177" + } else { + switch banks[i].AcctLength { + case 8: + banks[i].ExampleValue = "16967608" + case 13: + banks[i].ExampleValue = "1000222215735" + case 14: + banks[i].ExampleValue = "01320089280800" + case 16: + banks[i].ExampleValue = "1000222215735123" + } + banks[i].AcctNumberRegex = formatRegex(banks[i].AcctLength) + } + } + + return banks, nil +} + +func formatRegex(length int) string { + return fmt.Sprintf("/^[0-9]{%d}$/", length) +} diff --git a/internal/services/result/sports_eval.go b/internal/services/result/sports_eval.go index 48a837f..86e082f 100644 --- a/internal/services/result/sports_eval.go +++ b/internal/services/result/sports_eval.go @@ -8,7 +8,7 @@ import ( ) // NFL evaluations -func evaluateNFLMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { +func EvaluateNFLMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { switch outcome.OddHeader { case "1": if score.Home > score.Away { @@ -25,7 +25,7 @@ func evaluateNFLMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away in } } -func evaluateNFLSpread(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { +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) @@ -56,7 +56,7 @@ func evaluateNFLSpread(outcome domain.BetOutcome, score struct{ Home, Away int } return domain.OUTCOME_STATUS_VOID, nil } -func evaluateNFLTotalPoints(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { +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 { @@ -81,8 +81,8 @@ func evaluateNFLTotalPoints(outcome domain.BetOutcome, score struct{ Home, Away 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) { +// 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 { @@ -99,8 +99,8 @@ func evaluateRugbyMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away } } -// evaluateRugbySpread evaluates Rugby spread bets -func evaluateRugbySpread(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { +// 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) @@ -131,8 +131,8 @@ func evaluateRugbySpread(outcome domain.BetOutcome, score struct{ Home, Away int 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) { +// 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 { @@ -157,8 +157,8 @@ func evaluateRugbyTotalPoints(outcome domain.BetOutcome, score struct{ Home, Awa 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) { +// 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 { @@ -175,8 +175,8 @@ func evaluateBaseballMoneyLine(outcome domain.BetOutcome, score struct{ Home, Aw } } -// evaluateBaseballSpread evaluates Baseball spread bets -func evaluateBaseballSpread(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { +// 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) @@ -207,8 +207,8 @@ func evaluateBaseballSpread(outcome domain.BetOutcome, score struct{ Home, Away 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) { +// 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 { @@ -233,7 +233,7 @@ func evaluateBaseballTotalRuns(outcome domain.BetOutcome, score struct{ Home, Aw return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) } -// evaluateBaseballFirstInning evaluates Baseball first inning bets +// EvaluateBaseballFirstInning Evaluates Baseball first inning bets func EvaluateBaseballFirstInning(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { switch outcome.OddHeader { case "1": @@ -256,7 +256,7 @@ func EvaluateBaseballFirstInning(outcome domain.BetOutcome, score struct{ Home, } } -// evaluateBaseballFirst5Innings evaluates Baseball first 5 innings bets +// 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": diff --git a/internal/web_server/handlers/chapa.go b/internal/web_server/handlers/chapa.go index a2d9991..7c03183 100644 --- a/internal/web_server/handlers/chapa.go +++ b/internal/web_server/handlers/chapa.go @@ -1,287 +1,288 @@ package handlers import ( - "bytes" - "encoding/json" + // "bytes" + // "encoding/json" + // "fmt" + // "io" + // "net/http" + "fmt" - "io" - "net/http" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/gofiber/fiber/v2" - "github.com/google/uuid" ) -// GetBanks godoc -// @Summary Get list of banks -// @Description Fetch all supported banks from Chapa -// @Tags Chapa -// @Accept json -// @Produce json -// @Success 200 {object} domain.ChapaSupportedBanksResponse -// @Router /api/v1/chapa/banks [get] -func (h *Handler) GetBanks(c *fiber.Ctx) error { - httpReq, err := http.NewRequest("GET", h.Cfg.CHAPA_BASE_URL+"/banks", nil) - // log.Printf("\n\nbase url is: %v\n\n", h.Cfg.CHAPA_BASE_URL) - if err != nil { - return c.Status(500).JSON(fiber.Map{"error": "Failed to create request", "details": err.Error()}) - } - httpReq.Header.Set("Authorization", "Bearer "+h.Cfg.CHAPA_SECRET_KEY) +// // GetBanks godoc +// // @Summary Get list of banks +// // @Description Fetch all supported banks from Chapa +// // @Tags Chapa +// // @Accept json +// // @Produce json +// // @Success 200 {object} domain.ChapaSupportedBanksResponse +// // @Router /api/v1/chapa/banks [get] +// func (h *Handler) GetBanks(c *fiber.Ctx) error { +// httpReq, err := http.NewRequest("GET", h.Cfg.CHAPA_BASE_URL+"/banks", nil) +// // log.Printf("\n\nbase url is: %v\n\n", h.Cfg.CHAPA_BASE_URL) +// if err != nil { +// return c.Status(500).JSON(fiber.Map{"error": "Failed to create request", "details": err.Error()}) +// } +// httpReq.Header.Set("Authorization", "Bearer "+h.Cfg.CHAPA_SECRET_KEY) - resp, err := http.DefaultClient.Do(httpReq) - if err != nil { - return c.Status(500).JSON(fiber.Map{"error": "Failed to fetch banks", "details": err.Error()}) - } - defer resp.Body.Close() +// resp, err := http.DefaultClient.Do(httpReq) +// if err != nil { +// return c.Status(500).JSON(fiber.Map{"error": "Failed to fetch banks", "details": err.Error()}) +// } +// defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return c.Status(500).JSON(fiber.Map{"error": "Failed to read response", "details": err.Error()}) - } +// body, err := io.ReadAll(resp.Body) +// if err != nil { +// return c.Status(500).JSON(fiber.Map{"error": "Failed to read response", "details": err.Error()}) +// } - return c.Status(resp.StatusCode).Type("json").Send(body) -} +// return c.Status(resp.StatusCode).Type("json").Send(body) +// } -// InitializePayment godoc -// @Summary Initialize a payment transaction -// @Description Initiate a payment through Chapa -// @Tags Chapa -// @Accept json -// @Produce json -// @Param payload body domain.InitPaymentRequest true "Payment initialization request" -// @Success 200 {object} domain.InitPaymentResponse -// @Router /api/v1/chapa/payments/initialize [post] -func (h *Handler) InitializePayment(c *fiber.Ctx) error { - var req InitPaymentRequest - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Invalid request body", - "details": err.Error(), - }) - } +// // InitializePayment godoc +// // @Summary Initialize a payment transaction +// // @Description Initiate a payment through Chapa +// // @Tags Chapa +// // @Accept json +// // @Produce json +// // @Param payload body domain.InitPaymentRequest true "Payment initialization request" +// // @Success 200 {object} domain.InitPaymentResponse +// // @Router /api/v1/chapa/payments/initialize [post] +// func (h *Handler) InitializePayment(c *fiber.Ctx) error { +// var req InitPaymentRequest +// if err := c.BodyParser(&req); err != nil { +// return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ +// "error": "Invalid request body", +// "details": err.Error(), +// }) +// } - // Generate and assign a unique transaction reference - req.TxRef = uuid.New().String() +// // Generate and assign a unique transaction reference +// req.TxRef = uuid.New().String() - payload, err := json.Marshal(req) - if err != nil { - return c.Status(500).JSON(fiber.Map{ - "error": "Failed to serialize request", - "details": err.Error(), - }) - } +// payload, err := json.Marshal(req) +// if err != nil { +// return c.Status(500).JSON(fiber.Map{ +// "error": "Failed to serialize request", +// "details": err.Error(), +// }) +// } - httpReq, err := http.NewRequest("POST", h.Cfg.CHAPA_BASE_URL+"/transaction/initialize", bytes.NewBuffer(payload)) - if err != nil { - return c.Status(500).JSON(fiber.Map{ - "error": "Failed to create request", - "details": err.Error(), - }) - } - httpReq.Header.Set("Authorization", "Bearer "+h.Cfg.CHAPA_SECRET_KEY) - httpReq.Header.Set("Content-Type", "application/json") +// httpReq, err := http.NewRequest("POST", h.Cfg.CHAPA_BASE_URL+"/transaction/initialize", bytes.NewBuffer(payload)) +// if err != nil { +// return c.Status(500).JSON(fiber.Map{ +// "error": "Failed to create request", +// "details": err.Error(), +// }) +// } +// httpReq.Header.Set("Authorization", "Bearer "+h.Cfg.CHAPA_SECRET_KEY) +// httpReq.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(httpReq) - if err != nil { - return c.Status(500).JSON(fiber.Map{ - "error": "Failed to initialize payment", - "details": err.Error(), - }) - } - defer resp.Body.Close() +// resp, err := http.DefaultClient.Do(httpReq) +// if err != nil { +// return c.Status(500).JSON(fiber.Map{ +// "error": "Failed to initialize payment", +// "details": err.Error(), +// }) +// } +// defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return c.Status(500).JSON(fiber.Map{ - "error": "Failed to read response", - "details": err.Error(), - }) - } +// body, err := io.ReadAll(resp.Body) +// if err != nil { +// return c.Status(500).JSON(fiber.Map{ +// "error": "Failed to read response", +// "details": err.Error(), +// }) +// } - return c.Status(resp.StatusCode).Type("json").Send(body) -} +// return c.Status(resp.StatusCode).Type("json").Send(body) +// } -// VerifyTransaction godoc -// @Summary Verify a payment transaction -// @Description Verify the transaction status from Chapa using tx_ref -// @Tags Chapa -// @Accept json -// @Produce json -// @Param tx_ref path string true "Transaction Reference" -// @Success 200 {object} domain.VerifyTransactionResponse -// @Router /api/v1/chapa/payments/verify/{tx_ref} [get] -func (h *Handler) VerifyTransaction(c *fiber.Ctx) error { - txRef := c.Params("tx_ref") - if txRef == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Missing transaction reference", - }) - } +// // VerifyTransaction godoc +// // @Summary Verify a payment transaction +// // @Description Verify the transaction status from Chapa using tx_ref +// // @Tags Chapa +// // @Accept json +// // @Produce json +// // @Param tx_ref path string true "Transaction Reference" +// // @Success 200 {object} domain.VerifyTransactionResponse +// // @Router /api/v1/chapa/payments/verify/{tx_ref} [get] +// func (h *Handler) VerifyTransaction(c *fiber.Ctx) error { +// txRef := c.Params("tx_ref") +// if txRef == "" { +// return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ +// "error": "Missing transaction reference", +// }) +// } - url := fmt.Sprintf("%s/transaction/verify/%s", h.Cfg.CHAPA_BASE_URL, txRef) +// url := fmt.Sprintf("%s/transaction/verify/%s", h.Cfg.CHAPA_BASE_URL, txRef) - httpReq, err := http.NewRequest("GET", url, nil) - if err != nil { - return c.Status(500).JSON(fiber.Map{ - "error": "Failed to create request", - "details": err.Error(), - }) - } - httpReq.Header.Set("Authorization", "Bearer "+h.Cfg.CHAPA_SECRET_KEY) +// httpReq, err := http.NewRequest("GET", url, nil) +// if err != nil { +// return c.Status(500).JSON(fiber.Map{ +// "error": "Failed to create request", +// "details": err.Error(), +// }) +// } +// httpReq.Header.Set("Authorization", "Bearer "+h.Cfg.CHAPA_SECRET_KEY) - resp, err := http.DefaultClient.Do(httpReq) - if err != nil { - return c.Status(500).JSON(fiber.Map{ - "error": "Failed to verify transaction", - "details": err.Error(), - }) - } - defer resp.Body.Close() +// resp, err := http.DefaultClient.Do(httpReq) +// if err != nil { +// return c.Status(500).JSON(fiber.Map{ +// "error": "Failed to verify transaction", +// "details": err.Error(), +// }) +// } +// defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return c.Status(500).JSON(fiber.Map{ - "error": "Failed to read response", - "details": err.Error(), - }) - } +// body, err := io.ReadAll(resp.Body) +// if err != nil { +// return c.Status(500).JSON(fiber.Map{ +// "error": "Failed to read response", +// "details": err.Error(), +// }) +// } - return c.Status(resp.StatusCode).Type("json").Send(body) -} +// return c.Status(resp.StatusCode).Type("json").Send(body) +// } -// ReceiveWebhook godoc -// @Summary Receive Chapa webhook -// @Description Endpoint to receive webhook payloads from Chapa -// @Tags Chapa -// @Accept json -// @Produce json -// @Param payload body object true "Webhook Payload (dynamic)" -// @Success 200 {string} string "ok" -// @Router /api/v1/chapa/payments/callback [post] -func (h *Handler) ReceiveWebhook(c *fiber.Ctx) error { - var payload map[string]interface{} - if err := c.BodyParser(&payload); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Invalid webhook data", - "details": err.Error(), - }) - } +// // ReceiveWebhook godoc +// // @Summary Receive Chapa webhook +// // @Description Endpoint to receive webhook payloads from Chapa +// // @Tags Chapa +// // @Accept json +// // @Produce json +// // @Param payload body object true "Webhook Payload (dynamic)" +// // @Success 200 {string} string "ok" +// // @Router /api/v1/chapa/payments/callback [post] +// func (h *Handler) ReceiveWebhook(c *fiber.Ctx) error { +// var payload map[string]interface{} +// if err := c.BodyParser(&payload); err != nil { +// return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ +// "error": "Invalid webhook data", +// "details": err.Error(), +// }) +// } - h.logger.Info("Chapa webhook received", "payload", payload) +// h.logger.Info("Chapa webhook received", "payload", payload) - // Optional: you can verify tx_ref here again if needed +// // Optional: you can verify tx_ref here again if needed - return c.SendStatus(fiber.StatusOK) -} +// return c.SendStatus(fiber.StatusOK) +// } -// CreateTransfer godoc -// @Summary Create a money transfer -// @Description Initiate a transfer request via Chapa -// @Tags Chapa -// @Accept json -// @Produce json -// @Param payload body domain.TransferRequest true "Transfer request body" -// @Success 200 {object} domain.CreateTransferResponse -// @Router /api/v1/chapa/transfers [post] -func (h *Handler) CreateTransfer(c *fiber.Ctx) error { - var req TransferRequest - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Invalid request", - "details": err.Error(), - }) - } +// // CreateTransfer godoc +// // @Summary Create a money transfer +// // @Description Initiate a transfer request via Chapa +// // @Tags Chapa +// // @Accept json +// // @Produce json +// // @Param payload body domain.TransferRequest true "Transfer request body" +// // @Success 200 {object} domain.CreateTransferResponse +// // @Router /api/v1/chapa/transfers [post] +// func (h *Handler) CreateTransfer(c *fiber.Ctx) error { +// var req TransferRequest +// if err := c.BodyParser(&req); err != nil { +// return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ +// "error": "Invalid request", +// "details": err.Error(), +// }) +// } - // Inject unique transaction reference - req.Reference = uuid.New().String() +// // Inject unique transaction reference +// req.Reference = uuid.New().String() - payload, err := json.Marshal(req) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to serialize request", - "details": err.Error(), - }) - } +// payload, err := json.Marshal(req) +// if err != nil { +// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ +// "error": "Failed to serialize request", +// "details": err.Error(), +// }) +// } - httpReq, err := http.NewRequest("POST", h.Cfg.CHAPA_BASE_URL+"/transfers", bytes.NewBuffer(payload)) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to create HTTP request", - "details": err.Error(), - }) - } +// httpReq, err := http.NewRequest("POST", h.Cfg.CHAPA_BASE_URL+"/transfers", bytes.NewBuffer(payload)) +// if err != nil { +// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ +// "error": "Failed to create HTTP request", +// "details": err.Error(), +// }) +// } - httpReq.Header.Set("Authorization", "Bearer "+h.Cfg.CHAPA_SECRET_KEY) - httpReq.Header.Set("Content-Type", "application/json") +// httpReq.Header.Set("Authorization", "Bearer "+h.Cfg.CHAPA_SECRET_KEY) +// httpReq.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(httpReq) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Transfer request failed", - "details": err.Error(), - }) - } - defer resp.Body.Close() +// resp, err := http.DefaultClient.Do(httpReq) +// if err != nil { +// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ +// "error": "Transfer request failed", +// "details": err.Error(), +// }) +// } +// defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to read response", - "details": err.Error(), - }) - } +// body, err := io.ReadAll(resp.Body) +// if err != nil { +// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ +// "error": "Failed to read response", +// "details": err.Error(), +// }) +// } - return c.Status(resp.StatusCode).Type("json").Send(body) -} +// return c.Status(resp.StatusCode).Type("json").Send(body) +// } -// VerifyTransfer godoc -// @Summary Verify a transfer -// @Description Check the status of a money transfer via reference -// @Tags Chapa -// @Accept json -// @Produce json -// @Param transfer_ref path string true "Transfer Reference" -// @Success 200 {object} domain.VerifyTransferResponse -// @Router /api/v1/chapa/transfers/verify/{transfer_ref} [get] -func (h *Handler) VerifyTransfer(c *fiber.Ctx) error { - transferRef := c.Params("transfer_ref") - if transferRef == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Missing transfer reference in URL", - }) - } +// // VerifyTransfer godoc +// // @Summary Verify a transfer +// // @Description Check the status of a money transfer via reference +// // @Tags Chapa +// // @Accept json +// // @Produce json +// // @Param transfer_ref path string true "Transfer Reference" +// // @Success 200 {object} domain.VerifyTransferResponse +// // @Router /api/v1/chapa/transfers/verify/{transfer_ref} [get] +// func (h *Handler) VerifyTransfer(c *fiber.Ctx) error { +// transferRef := c.Params("transfer_ref") +// if transferRef == "" { +// return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ +// "error": "Missing transfer reference in URL", +// }) +// } - url := fmt.Sprintf("%s/transfers/verify/%s", h.Cfg.CHAPA_BASE_URL, transferRef) +// url := fmt.Sprintf("%s/transfers/verify/%s", h.Cfg.CHAPA_BASE_URL, transferRef) - httpReq, err := http.NewRequest("GET", url, nil) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to create HTTP request", - "details": err.Error(), - }) - } +// httpReq, err := http.NewRequest("GET", url, nil) +// if err != nil { +// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ +// "error": "Failed to create HTTP request", +// "details": err.Error(), +// }) +// } - httpReq.Header.Set("Authorization", "Bearer "+h.Cfg.CHAPA_SECRET_KEY) +// httpReq.Header.Set("Authorization", "Bearer "+h.Cfg.CHAPA_SECRET_KEY) - resp, err := http.DefaultClient.Do(httpReq) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Verification request failed", - "details": err.Error(), - }) - } - defer resp.Body.Close() +// resp, err := http.DefaultClient.Do(httpReq) +// if err != nil { +// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ +// "error": "Verification request failed", +// "details": err.Error(), +// }) +// } +// defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to read response body", - "details": err.Error(), - }) - } +// body, err := io.ReadAll(resp.Body) +// if err != nil { +// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ +// "error": "Failed to read response body", +// "details": err.Error(), +// }) +// } - return c.Status(resp.StatusCode).Type("json").Send(body) -} +// return c.Status(resp.StatusCode).Type("json").Send(body) +// } // VerifyChapaPayment godoc // @Summary Verifies Chapa webhook transaction @@ -384,7 +385,6 @@ func (h *Handler) WithdrawUsingChapa(c *fiber.Ctx) error { // @Tags Chapa // @Accept json // @Produce json -// @Security ApiKeyAuth // @Param payload body domain.ChapaDepositRequest true "Deposit request payload" // @Success 200 {object} domain.ChapaPaymentUrlResponseWrapper // @Failure 400 {object} domain.Response "Invalid request" @@ -407,7 +407,7 @@ func (h *Handler) DepositUsingChapa(c *fiber.Ctx) error { return domain.UnProcessableEntityResponse(c) } - // Validate input in domain/model (you may have a Validate method) + // Validate input in domain/domain (you may have a Validate method) if err := req.Validate(); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.Response{ Message: err.Error(), @@ -433,3 +433,32 @@ func (h *Handler) DepositUsingChapa(c *fiber.Ctx) error { }, }) } + +// ReadChapaBanks godoc +// @Summary fetches chapa supported banks +// @Tags Chapa +// @Accept json +// @Produce json +// @Success 200 {object} domain.ChapaSupportedBanksResponseWrapper +// @Failure 400,401,404,422,500 {object} domain.Response +// @Router /api/v1/chapa/banks [get] +func (h *Handler) ReadChapaBanks(c *fiber.Ctx) error { + banks, err := h.chapaSvc.GetSupportedBanks() + fmt.Printf("\n\nhandler fetched banks: %+v\n\n", banks) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.Response{ + Message: "Internal server error", + Success: false, + StatusCode: fiber.StatusInternalServerError, + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.ResponseWDataFactory[[]domain.ChapaSupportedBank]{ + Data: banks, + Response: domain.Response{ + Message: "read successful on chapa supported banks", + Success: true, + StatusCode: fiber.StatusOK, + }, + }) +} diff --git a/internal/web_server/handlers/read_chapa_banks_handler_test.go b/internal/web_server/handlers/read_chapa_banks_handler_test.go new file mode 100644 index 0000000..73e785c --- /dev/null +++ b/internal/web_server/handlers/read_chapa_banks_handler_test.go @@ -0,0 +1,131 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "testing" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// --- Mock service --- + +type MockChapaService struct { + mock.Mock +} + +func (m *MockChapaService) GetSupportedBanks() ([]domain.ChapaSupportedBank, error) { + args := m.Called() + return args.Get(0).([]domain.ChapaSupportedBank), args.Error(1) +} + +// --- Tests --- + +func (h *Handler) TestReadChapaBanks_Success(t *testing.T) { + app := fiber.New() + + mockService := new(MockChapaService) + + now := time.Now() + isMobile := 1 + isRtgs := 1 + is24hrs := 1 + + mockBanks := []domain.ChapaSupportedBank{ + { + Id: 101, + Slug: "bank-a", + Swift: "BKAETHAA", + Name: "Bank A", + AcctLength: 13, + AcctNumberRegex: "^[0-9]{13}$", + ExampleValue: "1000222215735", + CountryId: 1, + IsMobilemoney: &isMobile, + IsActive: 1, + IsRtgs: &isRtgs, + Active: 1, + Is24Hrs: &is24hrs, + CreatedAt: now, + UpdatedAt: now, + Currency: "ETB", + }, + } + + mockService.On("GetSupportedBanks").Return(mockBanks, nil) + + // handler := handlers.NewChapaHandler(mockService) + app.Post("/chapa/banks", h.ReadChapaBanks) + + req := createTestRequest(t, "POST", "/chapa/banks", nil) + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, fiber.StatusOK, resp.StatusCode) + + var body domain.ResponseWDataFactory[[]domain.ChapaSupportedBank] + err = parseJSONBody(resp, &body) + require.NoError(t, err) + + assert.True(t, body.Success) + assert.Equal(t, "read successful on chapa supported banks", body.Message) + require.Len(t, body.Data, 1) + assert.Equal(t, mockBanks[0].Name, body.Data[0].Name) + assert.Equal(t, mockBanks[0].AcctNumberRegex, body.Data[0].AcctNumberRegex) + + mockService.AssertExpectations(t) +} + +func (h *Handler) TestReadChapaBanks_Failure(t *testing.T) { + app := fiber.New() + + mockService := new(MockChapaService) + mockService.On("GetSupportedBanks").Return(nil, errors.New("chapa service unavailable")) + + // handler := handlers.NewChapaHandler(mockService) + app.Post("/chapa/banks", h.ReadChapaBanks) + + req := createTestRequest(t, "POST", "/chapa/banks", nil) + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, fiber.StatusInternalServerError, resp.StatusCode) + + var body domain.Response + err = parseJSONBody(resp, &body) + require.NoError(t, err) + + assert.False(t, body.Success) + assert.Equal(t, "Internal server error", body.Message) + mockService.AssertExpectations(t) +} + +func createTestRequest(t *testing.T, method, url string, body interface{}) *http.Request { + var buf io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + buf = bytes.NewBuffer(b) + } + + req, err := http.NewRequest(method, url, buf) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + return req +} + +func parseJSONBody(resp *http.Response, target interface{}) error { + return json.NewDecoder(resp.Body).Decode(target) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 7d31a87..88e8a2f 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -182,9 +182,10 @@ func (a *App) initAppRoutes() { a.fiber.Post("/transfer/refill/:id", a.authMiddleware, h.RefillWallet) //Chapa Routes - group.Post("/chapa/payments/verify", h.VerifyChapaPayment) - group.Post("/chapa/payments/withdraw", h.WithdrawUsingChapa) - group.Post("/chapa/payments/deposit", h.DepositUsingChapa) + group.Post("/chapa/payments/verify", a.authMiddleware, h.VerifyChapaPayment) + group.Post("/chapa/payments/withdraw", a.authMiddleware, h.WithdrawUsingChapa) + group.Post("/chapa/payments/deposit", a.authMiddleware, h.DepositUsingChapa) + group.Get("/chapa/banks", a.authMiddleware, h.ReadChapaBanks) // group.Post("/chapa/payments/initialize", h.InitializePayment) // group.Get("/chapa/payments/verify/:tx_ref", h.VerifyTransaction) diff --git a/makefile b/makefile index a842d04..303a8cc 100644 --- a/makefile +++ b/makefile @@ -19,7 +19,7 @@ build: .PHONY: run run: - @docker compose up -d + @docker compose up .PHONY: stop stop: From c2b547ff34e794918b74bb28cb76ab43bb2d4916 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Mon, 2 Jun 2025 17:24:01 +0300 Subject: [PATCH 07/10] sprots_eval_test fix --- internal/services/result/sports_eval_test.go | 118 +++++++++---------- 1 file changed, 59 insertions(+), 59 deletions(-) diff --git a/internal/services/result/sports_eval_test.go b/internal/services/result/sports_eval_test.go index 4810385..9132658 100644 --- a/internal/services/result/sports_eval_test.go +++ b/internal/services/result/sports_eval_test.go @@ -29,75 +29,75 @@ func TestNFLMarkets(t *testing.T) { 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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) @@ -128,75 +128,75 @@ func TestRugbyMarkets(t *testing.T) { 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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) @@ -226,75 +226,75 @@ func TestBaseballMarkets(t *testing.T) { 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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}) + 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) @@ -338,7 +338,7 @@ func TestEvaluateFootballOutcome(t *testing.T) { } // Act - status, _ := service.evaluateFootballOutcome(outcome, finalScore, firstHalfScore, secondHalfScore, corners, halfTimeCorners, events) + status, _ := service.EvaluateFootballOutcome(outcome, finalScore, firstHalfScore, secondHalfScore, corners, halfTimeCorners, events) fmt.Printf("\n\nBet Outcome: %v\n\n", &status) @@ -357,7 +357,7 @@ func TestEvaluateTotalLegs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateTotalLegs(tt.outcome, tt.score) + status, _ := EvaluateTotalLegs(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -380,7 +380,7 @@ func TestEvaluateGameLines(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateGameLines(tt.outcome, tt.score) + status, _ := EvaluateGameLines(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -408,7 +408,7 @@ func TestEvaluateFirstTeamToScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateFirstTeamToScore(tt.outcome, tt.events) + status, _ := EvaluateFirstTeamToScore(tt.outcome, tt.events) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -430,7 +430,7 @@ func TestEvaluateGoalsOverUnder(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateGoalsOverUnder(tt.outcome, tt.score) + status, _ := EvaluateGoalsOverUnder(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -452,7 +452,7 @@ func TestEvaluateGoalsOddEven(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateGoalsOddEven(tt.outcome, tt.score) + status, _ := EvaluateGoalsOddEven(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -474,7 +474,7 @@ func TestEvaluateCorrectScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateCorrectScore(tt.outcome, tt.score) + status, _ := EvaluateCorrectScore(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -498,7 +498,7 @@ func TestEvaluateHighestScoringHalf(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateHighestScoringHalf(tt.outcome, tt.firstScore, tt.secondScore) + status, _ := EvaluateHighestScoringHalf(tt.outcome, tt.firstScore, tt.secondScore) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -551,7 +551,7 @@ func TestEvaluateHighestScoringQuarter(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateHighestScoringQuarter(tt.outcome, tt.firstScore, tt.secondScore, tt.thirdScore, tt.fourthScore) + status, _ := EvaluateHighestScoringQuarter(tt.outcome, tt.firstScore, tt.secondScore, tt.thirdScore, tt.fourthScore) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -577,7 +577,7 @@ func TestEvaluateWinningMargin(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateWinningMargin(tt.outcome, tt.score) + status, _ := EvaluateWinningMargin(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -605,7 +605,7 @@ func TestEvaluateDoubleResult(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateDoubleResult(tt.outcome, tt.firstHalfScore, tt.fullTimeScore) + status, _ := EvaluateDoubleResult(tt.outcome, tt.firstHalfScore, tt.fullTimeScore) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -632,7 +632,7 @@ func TestEvaluateHighestScoringPeriod(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateHighestScoringPeriod(tt.outcome, tt.firstScore, tt.secondScore, tt.thirdScore) + status, _ := EvaluateHighestScoringPeriod(tt.outcome, tt.firstScore, tt.secondScore, tt.thirdScore) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -656,7 +656,7 @@ func TestEvalauteTiedAfterRegulation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateTiedAfterRegulation(tt.outcome, tt.score) + status, _ := EvaluateTiedAfterRegulation(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -680,7 +680,7 @@ func TestEvaluateTeamTotal(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateTeamTotal(tt.outcome, tt.score) + status, _ := EvaluateTeamTotal(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -703,7 +703,7 @@ func TestDrawNoBet(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateDrawNoBet(tt.outcome, tt.score) + status, _ := EvaluateDrawNoBet(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -727,7 +727,7 @@ func TestEvaluateMoneyLine(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateMoneyLine(tt.outcome, tt.score) + status, _ := EvaluateMoneyLine(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -751,7 +751,7 @@ func TestEvaluateDoubleChance(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateDoubleChance(tt.outcome, tt.score) + status, _ := EvaluateDoubleChance(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -775,7 +775,7 @@ func TestEvaluateResultAndTotal(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateResultAndTotal(tt.outcome, tt.score) + status, _ := EvaluateResultAndTotal(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -826,7 +826,7 @@ func TestEvaluateBTTSX(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateBTTSX(tt.outcome, tt.score) + status, _ := EvaluateBTTSX(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -852,7 +852,7 @@ func TestEvaluateResultAndBTTSX(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateResultAndBTTSX(tt.outcome, tt.score) + status, _ := EvaluateResultAndBTTSX(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -876,7 +876,7 @@ func TestEvaluateMoneyLine3Way(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateMoneyLine3Way(tt.outcome, tt.score) + status, _ := EvaluateMoneyLine3Way(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -937,7 +937,7 @@ func TestEvaluateAsianHandicap(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateAsianHandicap(tt.outcome, tt.score) + status, _ := EvaluateAsianHandicap(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } @@ -971,7 +971,7 @@ func TestEvaluateHandicapAndTotal(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, _ := evaluateHandicapAndTotal(tt.outcome, tt.score) + status, _ := EvaluateHandicapAndTotal(tt.outcome, tt.score) if status != tt.expected { t.Errorf("expected %d, got %d", tt.expected, status) } From eb4f2671420d1019303873dfa49e3f6c54d834b3 Mon Sep 17 00:00:00 2001 From: Asher Samuel Date: Thu, 5 Jun 2025 01:56:40 +0300 Subject: [PATCH 08/10] refactor events table --- db/migrations/000001_fortune.up.sql | 8 +-- gen/db/cashier.sql.go | 2 +- gen/db/events.sql.go | 56 ++++++++-------- gen/db/models.go | 34 ++++++++-- internal/domain/common.go | 4 ++ internal/domain/event.go | 16 ++--- internal/repository/event.go | 74 ++++++++++----------- internal/services/bet/service.go | 17 ++--- internal/services/event/port.go | 2 +- internal/services/event/service.go | 49 ++++++++------ internal/services/odds/service.go | 61 ++++++++--------- internal/services/result/service.go | 8 +-- internal/web_server/handlers/bet_handler.go | 27 +++++--- internal/web_server/handlers/prematch.go | 25 ++++--- 14 files changed, 214 insertions(+), 169 deletions(-) diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 56f3d51..50a4b75 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -183,15 +183,15 @@ CREATE TABLE IF NOT EXISTS branch_cashiers ( ); CREATE TABLE events ( id TEXT PRIMARY KEY, - sport_id TEXT, + sport_id INT, match_name TEXT, home_team TEXT, away_team TEXT, - home_team_id TEXT, - away_team_id TEXT, + home_team_id INT, + away_team_id INT, home_kit_image TEXT, away_kit_image TEXT, - league_id TEXT, + league_id INT, league_name TEXT, league_cc TEXT, start_time TIMESTAMP, diff --git a/gen/db/cashier.sql.go b/gen/db/cashier.sql.go index d0f6768..bb71cb2 100644 --- a/gen/db/cashier.sql.go +++ b/gen/db/cashier.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: cashier.sql package dbgen diff --git a/gen/db/events.sql.go b/gen/db/events.sql.go index d7c6824..a2a53d6 100644 --- a/gen/db/events.sql.go +++ b/gen/db/events.sql.go @@ -47,15 +47,15 @@ ORDER BY start_time ASC type GetAllUpcomingEventsRow struct { ID string `json:"id"` - SportID pgtype.Text `json:"sport_id"` + SportID pgtype.Int4 `json:"sport_id"` MatchName pgtype.Text `json:"match_name"` HomeTeam pgtype.Text `json:"home_team"` AwayTeam pgtype.Text `json:"away_team"` - HomeTeamID pgtype.Text `json:"home_team_id"` - AwayTeamID pgtype.Text `json:"away_team_id"` + HomeTeamID pgtype.Int4 `json:"home_team_id"` + AwayTeamID pgtype.Int4 `json:"away_team_id"` HomeKitImage pgtype.Text `json:"home_kit_image"` AwayKitImage pgtype.Text `json:"away_kit_image"` - LeagueID pgtype.Text `json:"league_id"` + LeagueID pgtype.Int4 `json:"league_id"` LeagueName pgtype.Text `json:"league_name"` LeagueCc pgtype.Text `json:"league_cc"` StartTime pgtype.Timestamp `json:"start_time"` @@ -128,15 +128,15 @@ ORDER BY start_time ASC type GetExpiredUpcomingEventsRow struct { ID string `json:"id"` - SportID pgtype.Text `json:"sport_id"` + SportID pgtype.Int4 `json:"sport_id"` MatchName pgtype.Text `json:"match_name"` HomeTeam pgtype.Text `json:"home_team"` AwayTeam pgtype.Text `json:"away_team"` - HomeTeamID pgtype.Text `json:"home_team_id"` - AwayTeamID pgtype.Text `json:"away_team_id"` + HomeTeamID pgtype.Int4 `json:"home_team_id"` + AwayTeamID pgtype.Int4 `json:"away_team_id"` HomeKitImage pgtype.Text `json:"home_kit_image"` AwayKitImage pgtype.Text `json:"away_kit_image"` - LeagueID pgtype.Text `json:"league_id"` + LeagueID pgtype.Int4 `json:"league_id"` LeagueName pgtype.Text `json:"league_name"` LeagueCc pgtype.Text `json:"league_cc"` StartTime pgtype.Timestamp `json:"start_time"` @@ -226,8 +226,8 @@ LIMIT $6 OFFSET $5 ` type GetPaginatedUpcomingEventsParams struct { - LeagueID pgtype.Text `json:"league_id"` - SportID pgtype.Text `json:"sport_id"` + LeagueID pgtype.Int4 `json:"league_id"` + SportID pgtype.Int4 `json:"sport_id"` LastStartTime pgtype.Timestamp `json:"last_start_time"` FirstStartTime pgtype.Timestamp `json:"first_start_time"` Offset pgtype.Int4 `json:"offset"` @@ -236,15 +236,15 @@ type GetPaginatedUpcomingEventsParams struct { type GetPaginatedUpcomingEventsRow struct { ID string `json:"id"` - SportID pgtype.Text `json:"sport_id"` + SportID pgtype.Int4 `json:"sport_id"` MatchName pgtype.Text `json:"match_name"` HomeTeam pgtype.Text `json:"home_team"` AwayTeam pgtype.Text `json:"away_team"` - HomeTeamID pgtype.Text `json:"home_team_id"` - AwayTeamID pgtype.Text `json:"away_team_id"` + HomeTeamID pgtype.Int4 `json:"home_team_id"` + AwayTeamID pgtype.Int4 `json:"away_team_id"` HomeKitImage pgtype.Text `json:"home_kit_image"` AwayKitImage pgtype.Text `json:"away_kit_image"` - LeagueID pgtype.Text `json:"league_id"` + LeagueID pgtype.Int4 `json:"league_id"` LeagueName pgtype.Text `json:"league_name"` LeagueCc pgtype.Text `json:"league_cc"` StartTime pgtype.Timestamp `json:"start_time"` @@ -323,8 +323,8 @@ WHERE is_live = false ` type GetTotalEventsParams struct { - LeagueID pgtype.Text `json:"league_id"` - SportID pgtype.Text `json:"sport_id"` + LeagueID pgtype.Int4 `json:"league_id"` + SportID pgtype.Int4 `json:"sport_id"` LastStartTime pgtype.Timestamp `json:"last_start_time"` FirstStartTime pgtype.Timestamp `json:"first_start_time"` } @@ -368,15 +368,15 @@ LIMIT 1 type GetUpcomingByIDRow struct { ID string `json:"id"` - SportID pgtype.Text `json:"sport_id"` + SportID pgtype.Int4 `json:"sport_id"` MatchName pgtype.Text `json:"match_name"` HomeTeam pgtype.Text `json:"home_team"` AwayTeam pgtype.Text `json:"away_team"` - HomeTeamID pgtype.Text `json:"home_team_id"` - AwayTeamID pgtype.Text `json:"away_team_id"` + HomeTeamID pgtype.Int4 `json:"home_team_id"` + AwayTeamID pgtype.Int4 `json:"away_team_id"` HomeKitImage pgtype.Text `json:"home_kit_image"` AwayKitImage pgtype.Text `json:"away_kit_image"` - LeagueID pgtype.Text `json:"league_id"` + LeagueID pgtype.Int4 `json:"league_id"` LeagueName pgtype.Text `json:"league_name"` LeagueCc pgtype.Text `json:"league_cc"` StartTime pgtype.Timestamp `json:"start_time"` @@ -484,15 +484,15 @@ SET sport_id = EXCLUDED.sport_id, type InsertEventParams struct { ID string `json:"id"` - SportID pgtype.Text `json:"sport_id"` + SportID pgtype.Int4 `json:"sport_id"` MatchName pgtype.Text `json:"match_name"` HomeTeam pgtype.Text `json:"home_team"` AwayTeam pgtype.Text `json:"away_team"` - HomeTeamID pgtype.Text `json:"home_team_id"` - AwayTeamID pgtype.Text `json:"away_team_id"` + HomeTeamID pgtype.Int4 `json:"home_team_id"` + AwayTeamID pgtype.Int4 `json:"away_team_id"` HomeKitImage pgtype.Text `json:"home_kit_image"` AwayKitImage pgtype.Text `json:"away_kit_image"` - LeagueID pgtype.Text `json:"league_id"` + LeagueID pgtype.Int4 `json:"league_id"` LeagueName pgtype.Text `json:"league_name"` LeagueCc pgtype.Text `json:"league_cc"` StartTime pgtype.Timestamp `json:"start_time"` @@ -591,15 +591,15 @@ SET sport_id = EXCLUDED.sport_id, type InsertUpcomingEventParams struct { ID string `json:"id"` - SportID pgtype.Text `json:"sport_id"` + SportID pgtype.Int4 `json:"sport_id"` MatchName pgtype.Text `json:"match_name"` HomeTeam pgtype.Text `json:"home_team"` AwayTeam pgtype.Text `json:"away_team"` - HomeTeamID pgtype.Text `json:"home_team_id"` - AwayTeamID pgtype.Text `json:"away_team_id"` + HomeTeamID pgtype.Int4 `json:"home_team_id"` + AwayTeamID pgtype.Int4 `json:"away_team_id"` HomeKitImage pgtype.Text `json:"home_kit_image"` AwayKitImage pgtype.Text `json:"away_kit_image"` - LeagueID pgtype.Text `json:"league_id"` + LeagueID pgtype.Int4 `json:"league_id"` LeagueName pgtype.Text `json:"league_name"` LeagueCc pgtype.Text `json:"league_cc"` StartTime pgtype.Timestamp `json:"start_time"` diff --git a/gen/db/models.go b/gen/db/models.go index 7c65cfc..75c6af8 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -176,15 +176,15 @@ type CustomerWallet struct { type Event struct { ID string `json:"id"` - SportID pgtype.Text `json:"sport_id"` + SportID pgtype.Int4 `json:"sport_id"` MatchName pgtype.Text `json:"match_name"` HomeTeam pgtype.Text `json:"home_team"` AwayTeam pgtype.Text `json:"away_team"` - HomeTeamID pgtype.Text `json:"home_team_id"` - AwayTeamID pgtype.Text `json:"away_team_id"` + HomeTeamID pgtype.Int4 `json:"home_team_id"` + AwayTeamID pgtype.Int4 `json:"away_team_id"` HomeKitImage pgtype.Text `json:"home_kit_image"` AwayKitImage pgtype.Text `json:"away_kit_image"` - LeagueID pgtype.Text `json:"league_id"` + LeagueID pgtype.Int4 `json:"league_id"` LeagueName pgtype.Text `json:"league_name"` LeagueCc pgtype.Text `json:"league_cc"` StartTime pgtype.Timestamp `json:"start_time"` @@ -383,6 +383,32 @@ type User struct { ReferredBy pgtype.Text `json:"referred_by"` } +type UserGameInteraction struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + GameID int64 `json:"game_id"` + InteractionType string `json:"interaction_type"` + Amount pgtype.Numeric `json:"amount"` + DurationSeconds pgtype.Int4 `json:"duration_seconds"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type VirtualGame struct { + ID int64 `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + Category string `json:"category"` + MinBet pgtype.Numeric `json:"min_bet"` + MaxBet pgtype.Numeric `json:"max_bet"` + Volatility string `json:"volatility"` + Rtp pgtype.Numeric `json:"rtp"` + IsFeatured pgtype.Bool `json:"is_featured"` + PopularityScore pgtype.Int4 `json:"popularity_score"` + ThumbnailUrl pgtype.Text `json:"thumbnail_url"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type VirtualGameSession struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` diff --git a/internal/domain/common.go b/internal/domain/common.go index fc652d1..43c5ed0 100644 --- a/internal/domain/common.go +++ b/internal/domain/common.go @@ -13,6 +13,10 @@ type ValidInt struct { Value int Valid bool } +type ValidInt32 struct { + Value int32 + Valid bool +} type ValidString struct { Value string diff --git a/internal/domain/event.go b/internal/domain/event.go index 9a463ca..a1c2a6f 100644 --- a/internal/domain/event.go +++ b/internal/domain/event.go @@ -4,15 +4,15 @@ import "time" type Event struct { ID string - SportID string + SportID int32 MatchName string HomeTeam string AwayTeam string - HomeTeamID string - AwayTeamID string + HomeTeamID int32 + AwayTeamID int32 HomeKitImage string AwayKitImage string - LeagueID string + LeagueID int32 LeagueName string LeagueCC string StartTime string @@ -54,15 +54,15 @@ type BetResult struct { type UpcomingEvent struct { ID string // Event ID - SportID string // Sport ID + SportID int32 // Sport ID MatchName string // Match or event name HomeTeam string // Home team name (if available) AwayTeam string // Away team name (can be empty/null) - HomeTeamID string // Home team ID - AwayTeamID string // Away team ID (can be empty/null) + HomeTeamID int32 // Home team ID + AwayTeamID int32 // Away team ID (can be empty/null) HomeKitImage string // Kit or image for home team (optional) AwayKitImage string // Kit or image for away team (optional) - LeagueID string // League ID + LeagueID int32 // League ID LeagueName string // League name LeagueCC string // League country code StartTime time.Time // Converted from "time" field in UNIX format diff --git a/internal/repository/event.go b/internal/repository/event.go index 8f2ade8..2366e75 100644 --- a/internal/repository/event.go +++ b/internal/repository/event.go @@ -21,15 +21,15 @@ func (s *Store) SaveEvent(ctx context.Context, e domain.Event) error { return s.queries.InsertEvent(ctx, dbgen.InsertEventParams{ ID: e.ID, - SportID: pgtype.Text{String: e.SportID, Valid: true}, + SportID: pgtype.Int4{Int32: e.SportID, Valid: true}, MatchName: pgtype.Text{String: e.MatchName, Valid: true}, HomeTeam: pgtype.Text{String: e.HomeTeam, Valid: true}, AwayTeam: pgtype.Text{String: e.AwayTeam, Valid: true}, - HomeTeamID: pgtype.Text{String: e.HomeTeamID, Valid: true}, - AwayTeamID: pgtype.Text{String: e.AwayTeamID, Valid: true}, + HomeTeamID: pgtype.Int4{Int32: e.HomeTeamID, Valid: true}, + AwayTeamID: pgtype.Int4{Int32: e.AwayTeamID, Valid: true}, HomeKitImage: pgtype.Text{String: e.HomeKitImage, Valid: true}, AwayKitImage: pgtype.Text{String: e.AwayKitImage, Valid: true}, - LeagueID: pgtype.Text{String: e.LeagueID, Valid: true}, + LeagueID: pgtype.Int4{Int32: e.LeagueID, Valid: true}, LeagueName: pgtype.Text{String: e.LeagueName, Valid: true}, LeagueCc: pgtype.Text{String: e.LeagueCC, Valid: true}, StartTime: pgtype.Timestamp{Time: parsedTime, Valid: true}, @@ -46,15 +46,15 @@ func (s *Store) SaveEvent(ctx context.Context, e domain.Event) error { func (s *Store) SaveUpcomingEvent(ctx context.Context, e domain.UpcomingEvent) error { return s.queries.InsertUpcomingEvent(ctx, dbgen.InsertUpcomingEventParams{ ID: e.ID, - SportID: pgtype.Text{String: e.SportID, Valid: true}, + SportID: pgtype.Int4{Int32: e.SportID, Valid: true}, MatchName: pgtype.Text{String: e.MatchName, Valid: true}, HomeTeam: pgtype.Text{String: e.HomeTeam, Valid: true}, AwayTeam: pgtype.Text{String: e.AwayTeam, Valid: true}, - HomeTeamID: pgtype.Text{String: e.HomeTeamID, Valid: true}, - AwayTeamID: pgtype.Text{String: e.AwayTeamID, Valid: true}, + HomeTeamID: pgtype.Int4{Int32: e.HomeTeamID, Valid: true}, + AwayTeamID: pgtype.Int4{Int32: e.AwayTeamID, Valid: true}, HomeKitImage: pgtype.Text{String: e.HomeKitImage, Valid: true}, AwayKitImage: pgtype.Text{String: e.AwayKitImage, Valid: true}, - LeagueID: pgtype.Text{String: e.LeagueID, Valid: true}, + LeagueID: pgtype.Int4{Int32: e.LeagueID, Valid: true}, LeagueName: pgtype.Text{String: e.LeagueName, Valid: true}, LeagueCc: pgtype.Text{String: e.LeagueCC, Valid: true}, StartTime: pgtype.Timestamp{Time: e.StartTime, Valid: true}, @@ -75,15 +75,15 @@ func (s *Store) GetAllUpcomingEvents(ctx context.Context) ([]domain.UpcomingEven for i, e := range events { upcomingEvents[i] = domain.UpcomingEvent{ ID: e.ID, - SportID: e.SportID.String, + SportID: e.SportID.Int32, MatchName: e.MatchName.String, HomeTeam: e.HomeTeam.String, AwayTeam: e.AwayTeam.String, - HomeTeamID: e.HomeTeamID.String, - AwayTeamID: e.AwayTeamID.String, + HomeTeamID: e.HomeTeamID.Int32, + AwayTeamID: e.AwayTeamID.Int32, HomeKitImage: e.HomeKitImage.String, AwayKitImage: e.AwayKitImage.String, - LeagueID: e.LeagueID.String, + LeagueID: e.LeagueID.Int32, LeagueName: e.LeagueName.String, LeagueCC: e.LeagueCc.String, StartTime: e.StartTime.Time.UTC(), @@ -103,15 +103,15 @@ func (s *Store) GetExpiredUpcomingEvents(ctx context.Context) ([]domain.Upcoming for i, e := range events { upcomingEvents[i] = domain.UpcomingEvent{ ID: e.ID, - SportID: e.SportID.String, + SportID: e.SportID.Int32, MatchName: e.MatchName.String, HomeTeam: e.HomeTeam.String, AwayTeam: e.AwayTeam.String, - HomeTeamID: e.HomeTeamID.String, - AwayTeamID: e.AwayTeamID.String, + HomeTeamID: e.HomeTeamID.Int32, + AwayTeamID: e.AwayTeamID.Int32, HomeKitImage: e.HomeKitImage.String, AwayKitImage: e.AwayKitImage.String, - LeagueID: e.LeagueID.String, + LeagueID: e.LeagueID.Int32, LeagueName: e.LeagueName.String, LeagueCC: e.LeagueCc.String, StartTime: e.StartTime.Time.UTC(), @@ -121,16 +121,16 @@ func (s *Store) GetExpiredUpcomingEvents(ctx context.Context) ([]domain.Upcoming return upcomingEvents, nil } -func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.ValidInt64, offset domain.ValidInt64, leagueID domain.ValidString, sportID domain.ValidString, firstStartTime domain.ValidTime, lastStartTime domain.ValidTime) ([]domain.UpcomingEvent, int64, error) { +func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.ValidInt64, offset domain.ValidInt64, leagueID domain.ValidInt32, sportID domain.ValidInt32, firstStartTime domain.ValidTime, lastStartTime domain.ValidTime) ([]domain.UpcomingEvent, int64, error) { events, err := s.queries.GetPaginatedUpcomingEvents(ctx, dbgen.GetPaginatedUpcomingEventsParams{ - LeagueID: pgtype.Text{ - String: leagueID.Value, - Valid: leagueID.Valid, + LeagueID: pgtype.Int4{ + Int32: leagueID.Value, + Valid: leagueID.Valid, }, - SportID: pgtype.Text{ - String: sportID.Value, - Valid: sportID.Valid, + SportID: pgtype.Int4{ + Int32: sportID.Value, + Valid: sportID.Valid, }, Limit: pgtype.Int4{ Int32: int32(limit.Value), @@ -157,15 +157,15 @@ func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.Val for i, e := range events { upcomingEvents[i] = domain.UpcomingEvent{ ID: e.ID, - SportID: e.SportID.String, + SportID: e.SportID.Int32, MatchName: e.MatchName.String, HomeTeam: e.HomeTeam.String, AwayTeam: e.AwayTeam.String, - HomeTeamID: e.HomeTeamID.String, - AwayTeamID: e.AwayTeamID.String, + HomeTeamID: e.HomeTeamID.Int32, + AwayTeamID: e.AwayTeamID.Int32, HomeKitImage: e.HomeKitImage.String, AwayKitImage: e.AwayKitImage.String, - LeagueID: e.LeagueID.String, + LeagueID: e.LeagueID.Int32, LeagueName: e.LeagueName.String, LeagueCC: e.LeagueCc.String, StartTime: e.StartTime.Time.UTC(), @@ -173,13 +173,13 @@ func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.Val } } totalCount, err := s.queries.GetTotalEvents(ctx, dbgen.GetTotalEventsParams{ - LeagueID: pgtype.Text{ - String: leagueID.Value, - Valid: leagueID.Valid, + LeagueID: pgtype.Int4{ + Int32: leagueID.Value, + Valid: leagueID.Valid, }, - SportID: pgtype.Text{ - String: sportID.Value, - Valid: sportID.Valid, + SportID: pgtype.Int4{ + Int32: sportID.Value, + Valid: sportID.Valid, }, FirstStartTime: pgtype.Timestamp{ Time: firstStartTime.Value.UTC(), @@ -205,15 +205,15 @@ func (s *Store) GetUpcomingEventByID(ctx context.Context, ID string) (domain.Upc return domain.UpcomingEvent{ ID: event.ID, - SportID: event.SportID.String, + SportID: event.SportID.Int32, MatchName: event.MatchName.String, HomeTeam: event.HomeTeam.String, AwayTeam: event.AwayTeam.String, - HomeTeamID: event.HomeTeamID.String, - AwayTeamID: event.AwayTeamID.String, + HomeTeamID: event.HomeTeamID.Int32, + AwayTeamID: event.AwayTeamID.Int32, HomeKitImage: event.HomeKitImage.String, AwayKitImage: event.AwayKitImage.String, - LeagueID: event.LeagueID.String, + LeagueID: event.LeagueID.Int32, LeagueName: event.LeagueName.String, LeagueCC: event.LeagueCc.String, StartTime: event.StartTime.Time.UTC(), diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go index 65af3d7..cb65c9d 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -121,15 +121,11 @@ func (s *Service) GenerateBetOutcome(ctx context.Context, eventID int64, marketI if err != nil { return domain.CreateBetOutcome{}, err } - sportID, err := strconv.ParseInt(event.SportID, 10, 64) - if err != nil { - return domain.CreateBetOutcome{}, err - } newOutcome := domain.CreateBetOutcome{ EventID: eventID, OddID: oddID, MarketID: marketID, - SportID: sportID, + SportID: int64(event.SportID), HomeTeamName: event.HomeTeam, AwayTeamName: event.AwayTeam, MarketName: odds.MarketName, @@ -278,7 +274,7 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID return res, nil } -func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID, sportID, HomeTeam, AwayTeam string, StartTime time.Time, numMarkets int) ([]domain.CreateBetOutcome, float32, error) { +func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID string, sportID int32, HomeTeam, AwayTeam string, StartTime time.Time, numMarkets int) ([]domain.CreateBetOutcome, float32, error) { var newOdds []domain.CreateBetOutcome var totalOdds float32 = 1 @@ -328,11 +324,6 @@ func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID, sportI s.logger.Error("Failed to parse odd", "error", err) continue } - sportID, err := strconv.ParseInt(sportID, 10, 64) - if err != nil { - s.logger.Error("Failed to get sport id", "error", err) - continue - } eventID, err := strconv.ParseInt(eventID, 10, 64) if err != nil { s.logger.Error("Failed to get event id", "error", err) @@ -356,7 +347,7 @@ func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID, sportI EventID: eventID, OddID: oddID, MarketID: marketID, - SportID: sportID, + SportID: int64(sportID), HomeTeamName: HomeTeam, AwayTeamName: AwayTeam, MarketName: marketName, @@ -379,7 +370,7 @@ func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID, sportI return newOdds, totalOdds, nil } -func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, leagueID, sportID domain.ValidString, firstStartTime, lastStartTime domain.ValidTime) (domain.CreateBetRes, error) { +func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, leagueID, sportID domain.ValidInt32, firstStartTime, lastStartTime domain.ValidTime) (domain.CreateBetRes, error) { // Get a unexpired event id diff --git a/internal/services/event/port.go b/internal/services/event/port.go index 94f4313..af8397e 100644 --- a/internal/services/event/port.go +++ b/internal/services/event/port.go @@ -11,7 +11,7 @@ type Service interface { FetchUpcomingEvents(ctx context.Context) error GetAllUpcomingEvents(ctx context.Context) ([]domain.UpcomingEvent, error) GetExpiredUpcomingEvents(ctx context.Context) ([]domain.UpcomingEvent, error) - GetPaginatedUpcomingEvents(ctx context.Context, limit domain.ValidInt64, offset domain.ValidInt64, leagueID domain.ValidString, sportID domain.ValidString, firstStartTime domain.ValidTime, lastStartTime domain.ValidTime) ([]domain.UpcomingEvent, int64, error) + GetPaginatedUpcomingEvents(ctx context.Context, limit domain.ValidInt64, offset domain.ValidInt64, leagueID domain.ValidInt32, sportID domain.ValidInt32, firstStartTime domain.ValidTime, lastStartTime domain.ValidTime) ([]domain.UpcomingEvent, int64, error) GetUpcomingEventByID(ctx context.Context, ID string) (domain.UpcomingEvent, error) // GetAndStoreMatchResult(ctx context.Context, eventID string) error diff --git a/internal/services/event/service.go b/internal/services/event/service.go index 1ad4310..2c6bc52 100644 --- a/internal/services/event/service.go +++ b/internal/services/event/service.go @@ -35,7 +35,7 @@ func (s *service) FetchLiveEvents(ctx context.Context) error { name string source string }{ - {"https://api.b365api.com/v1/bet365/inplay?sport_id=%d&&token=%s", "bet365"}, + {"https://api.b365api.com/v1/bet365/inplay?sport_id=%d&token=%s", "bet365"}, {"https://api.b365api.com/v1/betfair/sb/inplay?sport_id=%d&token=%s", "betfair"}, {"https://api.b365api.com/v1/1xbet/inplay?sport_id=%d&token=%s", "1xbet"}, } @@ -48,7 +48,6 @@ func (s *service) FetchLiveEvents(ctx context.Context) error { s.fetchLiveEvents(ctx, url.name, url.source) }() } - wg.Wait() return nil } @@ -118,17 +117,17 @@ func handleBet365prematch(body []byte, sportID int, source string) []domain.Even event := domain.Event{ ID: getString(ev["ID"]), - SportID: fmt.Sprintf("%d", sportID), + SportID: int32(sportID), MatchName: getString(ev["NA"]), Score: getString(ev["SS"]), MatchMinute: getInt(ev["TM"]), TimerStatus: getString(ev["TT"]), - HomeTeamID: getString(ev["HT"]), - AwayTeamID: getString(ev["AT"]), + HomeTeamID: getInt32(ev["HT"]), + AwayTeamID: getInt32(ev["AT"]), HomeKitImage: getString(ev["K1"]), AwayKitImage: getString(ev["K2"]), LeagueName: getString(ev["CT"]), - LeagueID: getString(ev["C2"]), + LeagueID: getInt32(ev["C2"]), LeagueCC: getString(ev["CB"]), StartTime: time.Now().UTC().Format(time.RFC3339), IsLive: true, @@ -159,17 +158,14 @@ func handleBetfairprematch(body []byte, sportID int, source string) []domain.Eve for _, ev := range data.Results { homeRaw, _ := ev["home"].(map[string]interface{}) - homeId, _ := homeRaw["id"].(string) - awayRaw, _ := ev["home"].(map[string]interface{}) - awayId, _ := awayRaw["id"].(string) event := domain.Event{ ID: getString(ev["id"]), - SportID: fmt.Sprintf("%d", sportID), + SportID: int32(sportID), TimerStatus: getString(ev["time_status"]), - HomeTeamID: homeId, - AwayTeamID: awayId, + HomeTeamID: getInt32(homeRaw["id"]), + AwayTeamID: getInt32(awayRaw["id"]), StartTime: time.Now().UTC().Format(time.RFC3339), IsLive: true, Status: "live", @@ -249,20 +245,21 @@ func (s *service) fetchUpcomingEventsFromProvider(ctx context.Context, url, sour if !slices.Contains(domain.SupportedLeagues, leagueID) { // fmt.Printf("⚠️ Skipping league %s (%d) as it is not supported\n", ev.League.Name, leagueID) skippedLeague = append(skippedLeague, ev.League.Name) - continue + // ! for now + // continue } event := domain.UpcomingEvent{ ID: ev.ID, - SportID: ev.SportID, + SportID: convertInt32(ev.SportID), MatchName: "", HomeTeam: ev.Home.Name, AwayTeam: "", // handle nil safely - HomeTeamID: ev.Home.ID, - AwayTeamID: "", + HomeTeamID: convertInt32(ev.Home.ID), + AwayTeamID: 0, HomeKitImage: "", AwayKitImage: "", - LeagueID: ev.League.ID, + LeagueID: convertInt32(ev.League.ID), LeagueName: ev.League.Name, LeagueCC: "", StartTime: time.Unix(startUnix, 0).UTC(), @@ -271,7 +268,7 @@ func (s *service) fetchUpcomingEventsFromProvider(ctx context.Context, url, sour if ev.Away != nil { event.AwayTeam = ev.Away.Name - event.AwayTeamID = ev.Away.ID + event.AwayTeamID = convertInt32(ev.Away.ID) event.MatchName = ev.Home.Name + " vs " + ev.Away.Name } @@ -309,6 +306,20 @@ func getInt(v interface{}) int { } return 0 } + +func getInt32(v interface{}) int32 { + if n, err := strconv.Atoi(getString(v)); err == nil { + return int32(n) + } + return 0 +} + +func convertInt32(num string) int32 { + if n, err := strconv.Atoi(num); err == nil { + return int32(n) + } + return 0 +} func (s *service) GetAllUpcomingEvents(ctx context.Context) ([]domain.UpcomingEvent, error) { return s.store.GetAllUpcomingEvents(ctx) } @@ -317,7 +328,7 @@ func (s *service) GetExpiredUpcomingEvents(ctx context.Context) ([]domain.Upcomi return s.store.GetExpiredUpcomingEvents(ctx) } -func (s *service) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.ValidInt64, offset domain.ValidInt64, leagueID domain.ValidString, sportID domain.ValidString, firstStartTime domain.ValidTime, lastStartTime domain.ValidTime) ([]domain.UpcomingEvent, int64, error) { +func (s *service) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.ValidInt64, offset domain.ValidInt64, leagueID domain.ValidInt32, sportID domain.ValidInt32, firstStartTime domain.ValidTime, lastStartTime domain.ValidTime) ([]domain.UpcomingEvent, int64, error) { return s.store.GetPaginatedUpcomingEvents(ctx, limit, offset, leagueID, sportID, firstStartTime, lastStartTime) } diff --git a/internal/services/odds/service.go b/internal/services/odds/service.go index 85ca2f7..488c9bb 100644 --- a/internal/services/odds/service.go +++ b/internal/services/odds/service.go @@ -38,14 +38,15 @@ func New(store *repository.Store, cfg *config.Config, logger *slog.Logger) *Serv func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { var wg sync.WaitGroup errChan := make(chan error, 2) - wg.Add(2) + // wg.Add(2) + wg.Add(1) - go func() { - defer wg.Done() - if err := s.fetchBet365Odds(ctx); err != nil { - errChan <- fmt.Errorf("bet365 odds fetching error: %w", err) - } - }() + // go func() { + // defer wg.Done() + // if err := s.fetchBet365Odds(ctx); err != nil { + // errChan <- fmt.Errorf("bet365 odds fetching error: %w", err) + // } + // }() go func() { defer wg.Done() @@ -112,9 +113,7 @@ func (s *ServiceImpl) fetchBet365Odds(ctx context.Context) error { continue } - sportID, err := strconv.ParseInt(event.SportID, 10, 64) - - switch sportID { + switch event.SportID { case domain.FOOTBALL: if err := s.parseFootball(ctx, oddsData.Results[0]); err != nil { s.logger.Error("Error while inserting football odd") @@ -142,7 +141,7 @@ func (s *ServiceImpl) fetchBet365Odds(ctx context.Context) error { 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{12, 7} + 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) @@ -181,13 +180,13 @@ func (s *ServiceImpl) fetchBwinOdds(ctx context.Context) error { event := domain.Event{ ID: strconv.Itoa(getInt(res["Id"])), - SportID: strconv.Itoa(getInt(res["SportId"])), - LeagueID: strconv.Itoa(getInt(res["LeagueId"])), + SportID: int32(getInt(res["SportId"])), + LeagueID: int32(getInt(res["LeagueId"])), LeagueName: getString(res["Leaguename"]), HomeTeam: getString(res["HomeTeam"]), - HomeTeamID: strconv.Itoa(getInt(res["HomeTeamId"])), + HomeTeamID: int32(getInt(res["HomeTeamId"])), AwayTeam: getString(res["AwayTeam"]), - AwayTeamID: strconv.Itoa(getInt(res["AwayTeamId"])), + AwayTeamID: int32(getInt(res["AwayTeamId"])), StartTime: time.Now().UTC().Format(time.RFC3339), TimerStatus: "1", IsLive: true, @@ -200,23 +199,25 @@ func (s *ServiceImpl) fetchBwinOdds(ctx context.Context) error { continue } - for _, m := range getMapArray(res["Markets"]) { - name := getMap(m["name"]) - marketName := getString(name["value"]) + for _, market := range []string{"Markets, optionMarkets"} { + for _, m := range getMapArray(res[market]) { + name := getMap(m["name"]) + marketName := getString(name["value"]) + + market := domain.Market{ + EventID: event.ID, + MarketID: getString(m["id"]), + MarketCategory: getString(m["category"]), + MarketName: marketName, + Source: "bwin", + } + + results := getMapArray(m["results"]) + market.Odds = results + + s.store.SaveNonLiveMarket(ctx, market) - market := domain.Market{ - EventID: event.ID, - MarketID: getString(m["id"]), - MarketCategory: getString(m["category"]), - MarketName: marketName, - Source: "bwin", } - - results := getMapArray(m["results"]) - market.Odds = results - - s.store.SaveNonLiveMarket(ctx, market) - } } diff --git a/internal/services/result/service.go b/internal/services/result/service.go index bc73535..b3f285d 100644 --- a/internal/services/result/service.go +++ b/internal/services/result/service.go @@ -80,14 +80,8 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { 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) - isDeleted = false - continue - } // TODO: optimize this because the result is being fetched for each outcome which will have the same event id but different market id - result, err := s.fetchResult(ctx, outcome.EventID, outcome.OddID, outcome.MarketID, sportID, outcome) + result, err := s.fetchResult(ctx, outcome.EventID, outcome.OddID, outcome.MarketID, int64(event.SportID), outcome) if err != nil { if err == ErrEventIsNotActive { s.logger.Warn("Event is not active", "event_id", outcome.EventID, "error", err) diff --git a/internal/web_server/handlers/bet_handler.go b/internal/web_server/handlers/bet_handler.go index b5f87ec..6070740 100644 --- a/internal/web_server/handlers/bet_handler.go +++ b/internal/web_server/handlers/bet_handler.go @@ -71,18 +71,28 @@ func (h *Handler) RandomBet(c *fiber.Ctx) error { userID := c.Locals("user_id").(int64) // role := c.Locals("role").(domain.Role) - leagueIDQuery := c.Query("league_id") - sportIDQuery := c.Query("sport_id") + leagueIDQuery, err := strconv.Atoi(c.Query("league_id")) + if err != nil { + h.logger.Error("invalid league id", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "invalid league id", nil, nil) + } + + sportIDQuery, err := strconv.Atoi(c.Query("sport_id")) + if err != nil { + h.logger.Error("invalid sport id", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "invalid sport id", nil, nil) + } + firstStartTimeQuery := c.Query("first_start_time") lastStartTimeQuery := c.Query("last_start_time") - leagueID := domain.ValidString{ - Value: leagueIDQuery, - Valid: leagueIDQuery != "", + leagueID := domain.ValidInt32{ + Value: int32(leagueIDQuery), + Valid: leagueIDQuery != 0, } - sportID := domain.ValidString{ - Value: sportIDQuery, - Valid: sportIDQuery != "", + sportID := domain.ValidInt32{ + Value: int32(sportIDQuery), + Valid: sportIDQuery != 0, } var firstStartTime domain.ValidTime @@ -122,7 +132,6 @@ func (h *Handler) RandomBet(c *fiber.Ctx) error { } var res domain.CreateBetRes - var err error for i := 0; i < int(req.NumberOfBets); i++ { res, err = h.betSvc.PlaceRandomBet(c.Context(), userID, req.BranchID, leagueID, sportID, firstStartTime, lastStartTime) diff --git a/internal/web_server/handlers/prematch.go b/internal/web_server/handlers/prematch.go index b8d3778..f070d5e 100644 --- a/internal/web_server/handlers/prematch.go +++ b/internal/web_server/handlers/prematch.go @@ -107,18 +107,27 @@ func (h *Handler) GetRawOddsByMarketID(c *fiber.Ctx) error { func (h *Handler) GetAllUpcomingEvents(c *fiber.Ctx) error { page := c.QueryInt("page", 1) pageSize := c.QueryInt("page_size", 10) - leagueIDQuery := c.Query("league_id") - sportIDQuery := c.Query("sport_id") + leagueIDQuery, err := strconv.Atoi(c.Query("league_id")) + if err != nil { + h.logger.Error("invalid league id", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "invalid league id", nil, nil) + } + + sportIDQuery, err := strconv.Atoi(c.Query("sport_id")) + if err != nil { + h.logger.Error("invalid sport id", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "invalid sport id", nil, nil) + } firstStartTimeQuery := c.Query("first_start_time") lastStartTimeQuery := c.Query("last_start_time") - leagueID := domain.ValidString{ - Value: leagueIDQuery, - Valid: leagueIDQuery != "", + leagueID := domain.ValidInt32{ + Value: int32(leagueIDQuery), + Valid: leagueIDQuery != 0, } - sportID := domain.ValidString{ - Value: sportIDQuery, - Valid: sportIDQuery != "", + sportID := domain.ValidInt32{ + Value: int32(sportIDQuery), + Valid: sportIDQuery != 0, } var firstStartTime domain.ValidTime From aedefbdb0b36afcb100db732cf84a66582fb9ff4 Mon Sep 17 00:00:00 2001 From: Asher Samuel Date: Fri, 6 Jun 2025 03:36:15 +0300 Subject: [PATCH 09/10] league support check from db --- db/migrations/000001_fortune.down.sql | 3 +- db/migrations/000001_fortune.up.sql | 7 ++ db/query/leagues.sql | 37 ++++++++ gen/db/leagues.sql.go | 128 ++++++++++++++++++++++++++ gen/db/models.go | 8 ++ internal/domain/league.go | 42 ++------- internal/domain/resultres.go | 50 +++++----- internal/repository/league.go | 61 ++++++++++++ internal/services/event/service.go | 14 ++- 9 files changed, 283 insertions(+), 67 deletions(-) create mode 100644 db/query/leagues.sql create mode 100644 gen/db/leagues.sql.go create mode 100644 internal/repository/league.go diff --git a/db/migrations/000001_fortune.down.sql b/db/migrations/000001_fortune.down.sql index 82d488d..2724f06 100644 --- a/db/migrations/000001_fortune.down.sql +++ b/db/migrations/000001_fortune.down.sql @@ -75,4 +75,5 @@ DROP TABLE IF EXISTS supported_operations; DROP TABLE IF EXISTS refresh_tokens; DROP TABLE IF EXISTS otps; DROP TABLE IF EXISTS odds; -DROP TABLE IF EXISTS events; \ No newline at end of file +DROP TABLE IF EXISTS events; +DROP TABLE IF EXISTS leagues; \ No newline at end of file diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 50a4b75..25f23f3 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -232,6 +232,13 @@ CREATE TABLE companies ( admin_id BIGINT NOT NULL, wallet_id BIGINT NOT NULL ); +CREATE TABLE leagues ( + id BIGINT PRIMARY KEY, + name TEXT NOT NULL, + country_code TEXT, + bet365_id INT, + is_active BOOLEAN DEFAULT true +); -- Views CREATE VIEW companies_details AS SELECT companies.*, diff --git a/db/query/leagues.sql b/db/query/leagues.sql new file mode 100644 index 0000000..b4905c8 --- /dev/null +++ b/db/query/leagues.sql @@ -0,0 +1,37 @@ +-- name: InsertLeague :exec +INSERT INTO leagues ( + id, + name, + country_code, + bet365_id, + is_active +) VALUES ( + $1, $2, $3, $4, $5 +) +ON CONFLICT (id) DO UPDATE +SET name = EXCLUDED.name, + country_code = EXCLUDED.country_code, + bet365_id = EXCLUDED.bet365_id, + is_active = EXCLUDED.is_active; +-- name: GetSupportedLeagues :many +SELECT id, + name, + country_code, + bet365_id, + is_active +FROM leagues +WHERE is_active = true; +-- name: CheckLeagueSupport :one +SELECT EXISTS( + SELECT 1 + FROM leagues + WHERE id = $1 + AND is_active = true +); +-- name: UpdateLeague :exec +UPDATE leagues +SET name = $1, + country_code = $2, + bet365_id = $3, + is_active = $4 +WHERE id = $5; \ No newline at end of file diff --git a/gen/db/leagues.sql.go b/gen/db/leagues.sql.go new file mode 100644 index 0000000..e8589dd --- /dev/null +++ b/gen/db/leagues.sql.go @@ -0,0 +1,128 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: leagues.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CheckLeagueSupport = `-- name: CheckLeagueSupport :one +SELECT EXISTS( + SELECT 1 + FROM leagues + WHERE id = $1 + AND is_active = true +) +` + +func (q *Queries) CheckLeagueSupport(ctx context.Context, id int64) (bool, error) { + row := q.db.QueryRow(ctx, CheckLeagueSupport, id) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const GetSupportedLeagues = `-- name: GetSupportedLeagues :many +SELECT id, + name, + country_code, + bet365_id, + is_active +FROM leagues +WHERE is_active = true +` + +func (q *Queries) GetSupportedLeagues(ctx context.Context) ([]League, error) { + rows, err := q.db.Query(ctx, GetSupportedLeagues) + if err != nil { + return nil, err + } + defer rows.Close() + var items []League + for rows.Next() { + var i League + if err := rows.Scan( + &i.ID, + &i.Name, + &i.CountryCode, + &i.Bet365ID, + &i.IsActive, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const InsertLeague = `-- name: InsertLeague :exec +INSERT INTO leagues ( + id, + name, + country_code, + bet365_id, + is_active +) VALUES ( + $1, $2, $3, $4, $5 +) +ON CONFLICT (id) DO UPDATE +SET name = EXCLUDED.name, + country_code = EXCLUDED.country_code, + bet365_id = EXCLUDED.bet365_id, + is_active = EXCLUDED.is_active +` + +type InsertLeagueParams struct { + ID int64 `json:"id"` + Name string `json:"name"` + CountryCode pgtype.Text `json:"country_code"` + Bet365ID pgtype.Int4 `json:"bet365_id"` + IsActive pgtype.Bool `json:"is_active"` +} + +func (q *Queries) InsertLeague(ctx context.Context, arg InsertLeagueParams) error { + _, err := q.db.Exec(ctx, InsertLeague, + arg.ID, + arg.Name, + arg.CountryCode, + arg.Bet365ID, + arg.IsActive, + ) + return err +} + +const UpdateLeague = `-- name: UpdateLeague :exec +UPDATE leagues +SET name = $1, + country_code = $2, + bet365_id = $3, + is_active = $4 +WHERE id = $5 +` + +type UpdateLeagueParams struct { + Name string `json:"name"` + CountryCode pgtype.Text `json:"country_code"` + Bet365ID pgtype.Int4 `json:"bet365_id"` + IsActive pgtype.Bool `json:"is_active"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateLeague(ctx context.Context, arg UpdateLeagueParams) error { + _, err := q.db.Exec(ctx, UpdateLeague, + arg.Name, + arg.CountryCode, + arg.Bet365ID, + arg.IsActive, + arg.ID, + ) + return err +} diff --git a/gen/db/models.go b/gen/db/models.go index 75c6af8..d45d0a3 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -199,6 +199,14 @@ type Event struct { Source pgtype.Text `json:"source"` } +type League struct { + ID int64 `json:"id"` + Name string `json:"name"` + CountryCode pgtype.Text `json:"country_code"` + Bet365ID pgtype.Int4 `json:"bet365_id"` + IsActive pgtype.Bool `json:"is_active"` +} + type Notification struct { ID string `json:"id"` RecipientID int64 `json:"recipient_id"` diff --git a/internal/domain/league.go b/internal/domain/league.go index a4a9cc2..f5ac35e 100644 --- a/internal/domain/league.go +++ b/internal/domain/league.go @@ -1,39 +1,9 @@ package domain -// TODO Will make this dynamic by moving into the database - -var SupportedLeagues = []int64{ - // Football - 10041282, //Premier League - 10083364, //La Liga - 10041095, //German Bundesliga - 10041100, //Ligue 1 - 10041809, //UEFA Champions League - 10041957, //UEFA Europa League - 10079560, //UEFA Conference League - 10047168, // US MLS - 10044469, // Ethiopian Premier League - 10050282, //UEFA Nations League - - 10043156, //England FA Cup - 10042103, //France Cup - 10041088, //Premier League 2 - 10084250, //Turkiye Super League - 10041187, //Kenya Super League - 10041315, //Italian Serie A - 10041391, //Netherlands Eredivisie - - // Basketball - 173998768, //NBA - 10041830, //NBA - 10049984, //WNBA - 10037165, //German Bundesliga - 10036608, //Italian Lega 1 - 10040795, //EuroLeague - - // Ice Hockey - 10037477, //NHL - 10037447, //AHL - 10069385, //IIHF World Championship - +type League struct { + ID int64 + Name string + CountryCode string + Bet365ID int32 + IsActive bool } diff --git a/internal/domain/resultres.go b/internal/domain/resultres.go index 493c0c9..3e53ce6 100644 --- a/internal/domain/resultres.go +++ b/internal/domain/resultres.go @@ -9,7 +9,7 @@ type BaseResultResponse struct { Results []json.RawMessage `json:"results"` } -type League struct { +type LeagueRes struct { ID string `json:"id"` Name string `json:"name"` CC string `json:"cc"` @@ -28,14 +28,14 @@ type Score struct { } type FootballResultResponse struct { - ID string `json:"id"` - SportID string `json:"sport_id"` - Time string `json:"time"` - TimeStatus string `json:"time_status"` - League League `json:"league"` - Home Team `json:"home"` - Away Team `json:"away"` - SS string `json:"ss"` + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League LeagueRes `json:"league"` + Home Team `json:"home"` + Away Team `json:"away"` + SS string `json:"ss"` Scores struct { FirstHalf Score `json:"1"` SecondHalf Score `json:"2"` @@ -67,14 +67,14 @@ type FootballResultResponse struct { } type BasketballResultResponse struct { - ID string `json:"id"` - SportID string `json:"sport_id"` - Time string `json:"time"` - TimeStatus string `json:"time_status"` - League League `json:"league"` - Home Team `json:"home"` - Away Team `json:"away"` - SS string `json:"ss"` + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League LeagueRes `json:"league"` + Home Team `json:"home"` + Away Team `json:"away"` + SS string `json:"ss"` Scores struct { FirstQuarter Score `json:"1"` SecondQuarter Score `json:"2"` @@ -114,14 +114,14 @@ type BasketballResultResponse struct { Bet365ID string `json:"bet365_id"` } type IceHockeyResultResponse struct { - ID string `json:"id"` - SportID string `json:"sport_id"` - Time string `json:"time"` - TimeStatus string `json:"time_status"` - League League `json:"league"` - Home Team `json:"home"` - Away Team `json:"away"` - SS string `json:"ss"` + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League LeagueRes `json:"league"` + Home Team `json:"home"` + Away Team `json:"away"` + SS string `json:"ss"` Scores struct { FirstPeriod Score `json:"1"` SecondPeriod Score `json:"2"` diff --git a/internal/repository/league.go b/internal/repository/league.go new file mode 100644 index 0000000..1bbec9c --- /dev/null +++ b/internal/repository/league.go @@ -0,0 +1,61 @@ +package repository + +import ( + "context" + + dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) SaveLeague(ctx context.Context, l domain.League) error { + return s.queries.InsertLeague(ctx, dbgen.InsertLeagueParams{ + ID: l.ID, + Name: l.Name, + CountryCode: pgtype.Text{String: l.CountryCode, Valid: true}, + Bet365ID: pgtype.Int4{Int32: l.Bet365ID, Valid: true}, + IsActive: pgtype.Bool{Bool: l.IsActive, Valid: true}, + }) +} + +func (s *Store) GetSupportedLeagues(ctx context.Context) ([]domain.League, error) { + leagues, err := s.queries.GetSupportedLeagues(ctx) + if err != nil { + return nil, err + } + + supportedLeagues := make([]domain.League, len(leagues)) + for i, league := range leagues { + supportedLeagues[i] = domain.League{ + ID: league.ID, + Name: league.Name, + CountryCode: league.CountryCode.String, + Bet365ID: league.Bet365ID.Int32, + IsActive: league.IsActive.Bool, + } + } + return supportedLeagues, nil +} + +func (s *Store) CheckLeagueSupport(ctx context.Context, leagueID int64) (bool, error) { + return s.queries.CheckLeagueSupport(ctx, leagueID) +} + +// TODO: change to only take league id instad of the whole league +func (s *Store) SetLeagueActive(ctx context.Context, l domain.League) error { + return s.queries.UpdateLeague(ctx, dbgen.UpdateLeagueParams{ + Name: l.Name, + CountryCode: pgtype.Text{String: l.CountryCode, Valid: true}, + Bet365ID: pgtype.Int4{Int32: l.Bet365ID, Valid: true}, + IsActive: pgtype.Bool{Bool: true, Valid: true}, + }) +} + +func (s *Store) SetLeagueInActive(ctx context.Context, l domain.League) error { + return s.queries.UpdateLeague(ctx, dbgen.UpdateLeagueParams{ + Name: l.Name, + CountryCode: pgtype.Text{String: l.CountryCode, Valid: true}, + Bet365ID: pgtype.Int4{Int32: l.Bet365ID, Valid: true}, + IsActive: pgtype.Bool{Bool: false, Valid: true}, + }) +} diff --git a/internal/services/event/service.go b/internal/services/event/service.go index 2c6bc52..2382091 100644 --- a/internal/services/event/service.go +++ b/internal/services/event/service.go @@ -7,7 +7,6 @@ import ( "io" "log" "net/http" - "slices" "strconv" "sync" "time" @@ -242,11 +241,16 @@ func (s *service) fetchUpcomingEventsFromProvider(ctx context.Context, url, sour continue } - if !slices.Contains(domain.SupportedLeagues, leagueID) { - // fmt.Printf("⚠️ Skipping league %s (%d) as it is not supported\n", ev.League.Name, leagueID) + // doesn't make sense to save and check back to back, but for now it can be here + s.store.SaveLeague(ctx, domain.League{ + ID: leagueID, + Name: ev.League.Name, + IsActive: true, + }) + + if supported, err := s.store.CheckLeagueSupport(ctx, leagueID); !supported || err != nil { skippedLeague = append(skippedLeague, ev.League.Name) - // ! for now - // continue + continue } event := domain.UpcomingEvent{ From 9807e8ed14e2da1a93dc9674a8c5c8c2ae78edf2 Mon Sep 17 00:00:00 2001 From: Asher Samuel Date: Fri, 6 Jun 2025 15:19:42 +0300 Subject: [PATCH 10/10] handler for fetching leagues and update league status --- cmd/main.go | 4 ++- db/migrations/000001_fortune.up.sql | 7 ++++ db/query/leagues.sql | 13 ++++++- gen/db/leagues.sql.go | 46 ++++++++++++++++++++++++ internal/repository/league.go | 30 +++++++++++----- internal/services/league/port.go | 12 +++++++ internal/services/league/service.go | 26 ++++++++++++++ internal/web_server/app.go | 4 +++ internal/web_server/handlers/handlers.go | 4 +++ internal/web_server/handlers/leagues.go | 34 ++++++++++++++++++ internal/web_server/routes.go | 17 +++++---- makefile | 2 -- 12 files changed, 181 insertions(+), 18 deletions(-) create mode 100644 internal/services/league/port.go create mode 100644 internal/services/league/service.go create mode 100644 internal/web_server/handlers/leagues.go diff --git a/cmd/main.go b/cmd/main.go index cd98778..c271ec9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -23,6 +23,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/chapa" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/company" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/league" notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation" @@ -86,6 +87,7 @@ func main() { transactionSvc := transaction.NewService(store) branchSvc := branch.NewService(store) companySvc := company.NewService(store) + leagueSvc := league.New(store) betSvc := bet.NewService(store, eventSvc, oddsSvc, *walletSvc, *branchSvc, logger) resultSvc := result.NewService(store, cfg, logger, *betSvc) notificationRepo := repository.NewNotificationRepository(store) @@ -128,7 +130,7 @@ func main() { JwtAccessKey: cfg.JwtKey, JwtAccessExpiry: cfg.AccessExpiry, }, userSvc, - ticketSvc, betSvc, chapaSvc, walletSvc, transactionSvc, branchSvc, companySvc, notificationSvc, oddsSvc, eventSvc, referalSvc, virtualGameSvc, aleaService, veliService, recommendationSvc, resultSvc, cfg) + ticketSvc, betSvc, chapaSvc, walletSvc, transactionSvc, branchSvc, companySvc, notificationSvc, oddsSvc, eventSvc, leagueSvc, referalSvc, virtualGameSvc, aleaService, veliService, recommendationSvc, resultSvc, cfg) logger.Info("Starting server", "port", cfg.Port) if err := app.Run(); err != nil { diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index b2cb0aa..30a006b 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -240,6 +240,13 @@ CREATE TABLE leagues ( bet365_id INT, is_active BOOLEAN DEFAULT true ); +CREATE TABLE teams ( + id TEXT PRIMARY KEY, + team_name TEXT NOT NULL, + country TEXT, + bet365_id INT, + logo_url TEXT +); -- Views CREATE VIEW companies_details AS SELECT companies.*, diff --git a/db/query/leagues.sql b/db/query/leagues.sql index b4905c8..b9c0e02 100644 --- a/db/query/leagues.sql +++ b/db/query/leagues.sql @@ -21,6 +21,13 @@ SELECT id, is_active FROM leagues WHERE is_active = true; +-- name: GetAllLeagues :many +SELECT id, + name, + country_code, + bet365_id, + is_active +FROM leagues; -- name: CheckLeagueSupport :one SELECT EXISTS( SELECT 1 @@ -34,4 +41,8 @@ SET name = $1, country_code = $2, bet365_id = $3, is_active = $4 -WHERE id = $5; \ No newline at end of file +WHERE id = $5; +-- name: SetLeagueActive :exec +UPDATE leagues +SET is_active = true +WHERE id = $1; \ No newline at end of file diff --git a/gen/db/leagues.sql.go b/gen/db/leagues.sql.go index e8589dd..49c1555 100644 --- a/gen/db/leagues.sql.go +++ b/gen/db/leagues.sql.go @@ -27,6 +27,41 @@ func (q *Queries) CheckLeagueSupport(ctx context.Context, id int64) (bool, error return exists, err } +const GetAllLeagues = `-- name: GetAllLeagues :many +SELECT id, + name, + country_code, + bet365_id, + is_active +FROM leagues +` + +func (q *Queries) GetAllLeagues(ctx context.Context) ([]League, error) { + rows, err := q.db.Query(ctx, GetAllLeagues) + if err != nil { + return nil, err + } + defer rows.Close() + var items []League + for rows.Next() { + var i League + if err := rows.Scan( + &i.ID, + &i.Name, + &i.CountryCode, + &i.Bet365ID, + &i.IsActive, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetSupportedLeagues = `-- name: GetSupportedLeagues :many SELECT id, name, @@ -99,6 +134,17 @@ func (q *Queries) InsertLeague(ctx context.Context, arg InsertLeagueParams) erro return err } +const SetLeagueActive = `-- name: SetLeagueActive :exec +UPDATE leagues +SET is_active = true +WHERE id = $1 +` + +func (q *Queries) SetLeagueActive(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, SetLeagueActive, id) + return err +} + const UpdateLeague = `-- name: UpdateLeague :exec UPDATE leagues SET name = $1, diff --git a/internal/repository/league.go b/internal/repository/league.go index 1bbec9c..7e5205f 100644 --- a/internal/repository/league.go +++ b/internal/repository/league.go @@ -37,20 +37,34 @@ func (s *Store) GetSupportedLeagues(ctx context.Context) ([]domain.League, error return supportedLeagues, nil } +func (s *Store) GetAllLeagues(ctx context.Context) ([]domain.League, error) { + l, err := s.queries.GetAllLeagues(ctx) + if err != nil { + return nil, err + } + + leagues := make([]domain.League, len(l)) + for i, league := range l { + leagues[i] = domain.League{ + ID: league.ID, + Name: league.Name, + CountryCode: league.CountryCode.String, + Bet365ID: league.Bet365ID.Int32, + IsActive: league.IsActive.Bool, + } + } + return leagues, nil +} + func (s *Store) CheckLeagueSupport(ctx context.Context, leagueID int64) (bool, error) { return s.queries.CheckLeagueSupport(ctx, leagueID) } -// TODO: change to only take league id instad of the whole league -func (s *Store) SetLeagueActive(ctx context.Context, l domain.League) error { - return s.queries.UpdateLeague(ctx, dbgen.UpdateLeagueParams{ - Name: l.Name, - CountryCode: pgtype.Text{String: l.CountryCode, Valid: true}, - Bet365ID: pgtype.Int4{Int32: l.Bet365ID, Valid: true}, - IsActive: pgtype.Bool{Bool: true, Valid: true}, - }) +func (s *Store) SetLeagueActive(ctx context.Context, leagueId int64) error { + return s.queries.SetLeagueActive(ctx, leagueId) } +// TODO: update based on id, no need for the entire league (same as the set active one) func (s *Store) SetLeagueInActive(ctx context.Context, l domain.League) error { return s.queries.UpdateLeague(ctx, dbgen.UpdateLeagueParams{ Name: l.Name, diff --git a/internal/services/league/port.go b/internal/services/league/port.go new file mode 100644 index 0000000..7b71a48 --- /dev/null +++ b/internal/services/league/port.go @@ -0,0 +1,12 @@ +package league + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type Service interface { + GetAllLeagues(ctx context.Context) ([]domain.League, error) + SetLeagueActive(ctx context.Context, leagueId int64) error +} diff --git a/internal/services/league/service.go b/internal/services/league/service.go new file mode 100644 index 0000000..b1f05ed --- /dev/null +++ b/internal/services/league/service.go @@ -0,0 +1,26 @@ +package league + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" +) + +type service struct { + store *repository.Store +} + +func New(store *repository.Store) Service { + return &service{ + store: store, + } +} + +func (s *service) GetAllLeagues(ctx context.Context) ([]domain.League, error) { + return s.store.GetAllLeagues(ctx) +} + +func (s *service) SetLeagueActive(ctx context.Context, leagueId int64) error { + return s.store.SetLeagueActive(ctx, leagueId) +} diff --git a/internal/web_server/app.go b/internal/web_server/app.go index d7c0b46..0a50ef7 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -11,6 +11,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/chapa" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/company" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/league" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation" referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" @@ -56,6 +57,7 @@ type App struct { Logger *slog.Logger prematchSvc *odds.ServiceImpl eventSvc event.Service + leagueSvc league.Service resultSvc *result.Service } @@ -75,6 +77,7 @@ func NewApp( notidicationStore *notificationservice.Service, prematchSvc *odds.ServiceImpl, eventSvc event.Service, + leagueSvc league.Service, referralSvc referralservice.ReferralStore, virtualGameSvc virtualgameservice.VirtualGameService, aleaVirtualGameService alea.AleaVirtualGameService, @@ -117,6 +120,7 @@ func NewApp( Logger: logger, prematchSvc: prematchSvc, eventSvc: eventSvc, + leagueSvc: leagueSvc, virtualGameSvc: virtualGameSvc, aleaVirtualGameService: aleaVirtualGameService, veliVirtualGameService: veliVirtualGameService, diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 6c42024..f6665d7 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -10,6 +10,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/chapa" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/company" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/league" notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation" @@ -39,6 +40,7 @@ type Handler struct { companySvc *company.Service prematchSvc *odds.ServiceImpl eventSvc event.Service + leagueSvc league.Service virtualGameSvc virtualgameservice.VirtualGameService aleaVirtualGameSvc alea.AleaVirtualGameService veliVirtualGameSvc veli.VeliVirtualGameService @@ -70,6 +72,7 @@ func New( companySvc *company.Service, prematchSvc *odds.ServiceImpl, eventSvc event.Service, + leagueSvc league.Service, cfg *config.Config, ) *Handler { return &Handler{ @@ -87,6 +90,7 @@ func New( companySvc: companySvc, prematchSvc: prematchSvc, eventSvc: eventSvc, + leagueSvc: leagueSvc, virtualGameSvc: virtualGameSvc, aleaVirtualGameSvc: aleaVirtualGameSvc, veliVirtualGameSvc: veliVirtualGameSvc, diff --git a/internal/web_server/handlers/leagues.go b/internal/web_server/handlers/leagues.go new file mode 100644 index 0000000..d4f78ee --- /dev/null +++ b/internal/web_server/handlers/leagues.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "strconv" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" + "github.com/gofiber/fiber/v2" +) + +func (h *Handler) GetAllLeagues(c *fiber.Ctx) error { + leagues, err := h.leagueSvc.GetAllLeagues(c.Context()) + if err != nil { + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get leagues", err, nil) + } + + return response.WriteJSON(c, fiber.StatusOK, "All leagues retrived", leagues, nil) +} + +func (h *Handler) SetLeagueActive(c *fiber.Ctx) error { + leagueIdStr := c.Params("id") + if leagueIdStr == "" { + response.WriteJSON(c, fiber.StatusBadRequest, "Missing league id", nil, nil) + } + leagueId, err := strconv.Atoi(leagueIdStr) + if err != nil { + response.WriteJSON(c, fiber.StatusBadRequest, "invalid league id", nil, nil) + } + + if err := h.leagueSvc.SetLeagueActive(c.Context(), int64(leagueId)); err != nil { + response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to update league", err, nil) + } + + return response.WriteJSON(c, fiber.StatusOK, "League updated successfully", nil, nil) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 88e8a2f..eaa2939 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -35,6 +35,7 @@ func (a *App) initAppRoutes() { a.companySvc, a.prematchSvc, a.eventSvc, + a.leagueSvc, a.cfg, ) @@ -114,13 +115,17 @@ func (a *App) initAppRoutes() { a.fiber.Put("/managers/:id", a.authMiddleware, h.UpdateManagers) a.fiber.Get("/manager/:id/branch", a.authMiddleware, h.GetBranchByManagerID) - a.fiber.Get("/prematch/odds/:event_id", h.GetPrematchOdds) - a.fiber.Get("/prematch/odds", h.GetALLPrematchOdds) - a.fiber.Get("/prematch/odds/upcoming/:upcoming_id/market/:market_id", h.GetRawOddsByMarketID) + a.fiber.Get("/events/odds/:event_id", h.GetPrematchOdds) + a.fiber.Get("/events/odds", h.GetALLPrematchOdds) + a.fiber.Get("/events/odds/upcoming/:upcoming_id/market/:market_id", h.GetRawOddsByMarketID) - a.fiber.Get("/prematch/events/:id", h.GetUpcomingEventByID) - a.fiber.Get("/prematch/events", h.GetAllUpcomingEvents) - a.fiber.Get("/prematch/odds/upcoming/:upcoming_id", h.GetPrematchOddsByUpcomingID) + a.fiber.Get("/events/:id", h.GetUpcomingEventByID) + a.fiber.Get("/events", h.GetAllUpcomingEvents) + a.fiber.Get("/events/odds/upcoming/:upcoming_id", h.GetPrematchOddsByUpcomingID) + + // Leagues + a.fiber.Get("/leagues", h.GetAllLeagues) + a.fiber.Get("/leagues/:id/set-active", h.SetLeagueActive) // Swagger a.fiber.Get("/swagger/*", fiberSwagger.FiberWrapHandler()) diff --git a/makefile b/makefile index 303a8cc..a40a255 100644 --- a/makefile +++ b/makefile @@ -56,8 +56,6 @@ db-up: db-down: @docker compose down @docker volume rm fortunebet-backend_postgres_data -postgres: - @docker exec -it fortunebet-backend-postgres-1 psql -U root -d gh .PHONY: sqlc-gen sqlc-gen: @sqlc generate \ No newline at end of file