diff --git a/cmd/main.go b/cmd/main.go index a6fa58d..497c02e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -168,6 +168,7 @@ func main() { ) go httpserver.SetupReportCronJobs(context.Background(), reportSvc) + go httpserver.ProcessBetCashback(context.TODO(), betSvc) bankRepository := repository.NewBankRepository(store) instSvc := institutions.New(bankRepository) diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 5228f1e..ffe46c6 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS bets ( is_shop_bet BOOLEAN NOT NULL, outcomes_hash TEXT NOT NULL, fast_code VARCHAR(10) NOT NULL, + processed BOOLEAN DEFAULT FALSE NOT NULL, UNIQUE(cashout_id), CHECK ( user_id IS NOT NULL diff --git a/db/query/bet.sql b/db/query/bet.sql index bdd6f23..8be3a31 100644 --- a/db/query/bet.sql +++ b/db/query/bet.sql @@ -99,6 +99,11 @@ SELECT * FROM bet_with_outcomes WHERE fast_code = $1 LIMIT 1; +-- name: GetBetsForCashback :many +SELECT * +FROM bet_with_outcomes +WHERE status = 2 + AND processed = false; -- name: GetBetOutcomeByEventID :many SELECT * FROM bet_outcomes @@ -143,6 +148,10 @@ UPDATE bets SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2; +-- name: UpdateBetWithCashback :exec +UPDATE bets +SET processed = $1 +WHERE id = $2; -- name: DeleteBet :exec DELETE FROM bets WHERE id = $1; diff --git a/gen/db/bet.sql.go b/gen/db/bet.sql.go index 00b4bad..3f93e45 100644 --- a/gen/db/bet.sql.go +++ b/gen/db/bet.sql.go @@ -27,7 +27,7 @@ INSERT INTO bets ( fast_code ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) -RETURNING id, amount, total_odds, status, full_name, phone_number, company_id, branch_id, user_id, cashed_out, cashout_id, created_at, updated_at, is_shop_bet, outcomes_hash, fast_code +RETURNING id, amount, total_odds, status, full_name, phone_number, company_id, branch_id, user_id, cashed_out, cashout_id, created_at, updated_at, is_shop_bet, outcomes_hash, fast_code, processed ` type CreateBetParams struct { @@ -78,6 +78,7 @@ func (q *Queries) CreateBet(ctx context.Context, arg CreateBetParams) (Bet, erro &i.IsShopBet, &i.OutcomesHash, &i.FastCode, + &i.Processed, ) return i, err } @@ -119,7 +120,7 @@ func (q *Queries) DeleteBetOutcome(ctx context.Context, betID int64) error { } const GetAllBets = `-- name: GetAllBets :many -SELECT id, amount, total_odds, status, full_name, phone_number, company_id, branch_id, user_id, cashed_out, cashout_id, created_at, updated_at, is_shop_bet, outcomes_hash, fast_code, outcomes +SELECT id, amount, total_odds, status, full_name, phone_number, company_id, branch_id, user_id, cashed_out, cashout_id, created_at, updated_at, is_shop_bet, outcomes_hash, fast_code, processed, outcomes FROM bet_with_outcomes wHERE ( branch_id = $1 @@ -196,6 +197,7 @@ func (q *Queries) GetAllBets(ctx context.Context, arg GetAllBetsParams) ([]BetWi &i.IsShopBet, &i.OutcomesHash, &i.FastCode, + &i.Processed, &i.Outcomes, ); err != nil { return nil, err @@ -209,7 +211,7 @@ func (q *Queries) GetAllBets(ctx context.Context, arg GetAllBetsParams) ([]BetWi } const GetBetByBranchID = `-- name: GetBetByBranchID :many -SELECT id, amount, total_odds, status, full_name, phone_number, company_id, branch_id, user_id, cashed_out, cashout_id, created_at, updated_at, is_shop_bet, outcomes_hash, fast_code, outcomes +SELECT id, amount, total_odds, status, full_name, phone_number, company_id, branch_id, user_id, cashed_out, cashout_id, created_at, updated_at, is_shop_bet, outcomes_hash, fast_code, processed, outcomes FROM bet_with_outcomes WHERE branch_id = $1 ` @@ -240,6 +242,7 @@ func (q *Queries) GetBetByBranchID(ctx context.Context, branchID pgtype.Int8) ([ &i.IsShopBet, &i.OutcomesHash, &i.FastCode, + &i.Processed, &i.Outcomes, ); err != nil { return nil, err @@ -253,7 +256,7 @@ func (q *Queries) GetBetByBranchID(ctx context.Context, branchID pgtype.Int8) ([ } const GetBetByCashoutID = `-- name: GetBetByCashoutID :one -SELECT id, amount, total_odds, status, full_name, phone_number, company_id, branch_id, user_id, cashed_out, cashout_id, created_at, updated_at, is_shop_bet, outcomes_hash, fast_code, outcomes +SELECT id, amount, total_odds, status, full_name, phone_number, company_id, branch_id, user_id, cashed_out, cashout_id, created_at, updated_at, is_shop_bet, outcomes_hash, fast_code, processed, outcomes FROM bet_with_outcomes WHERE cashout_id = $1 ` @@ -278,13 +281,14 @@ func (q *Queries) GetBetByCashoutID(ctx context.Context, cashoutID string) (BetW &i.IsShopBet, &i.OutcomesHash, &i.FastCode, + &i.Processed, &i.Outcomes, ) return i, err } const GetBetByFastCode = `-- name: GetBetByFastCode :one -SELECT id, amount, total_odds, status, full_name, phone_number, company_id, branch_id, user_id, cashed_out, cashout_id, created_at, updated_at, is_shop_bet, outcomes_hash, fast_code, outcomes +SELECT id, amount, total_odds, status, full_name, phone_number, company_id, branch_id, user_id, cashed_out, cashout_id, created_at, updated_at, is_shop_bet, outcomes_hash, fast_code, processed, outcomes FROM bet_with_outcomes WHERE fast_code = $1 LIMIT 1 @@ -310,13 +314,14 @@ func (q *Queries) GetBetByFastCode(ctx context.Context, fastCode string) (BetWit &i.IsShopBet, &i.OutcomesHash, &i.FastCode, + &i.Processed, &i.Outcomes, ) return i, err } const GetBetByID = `-- name: GetBetByID :one -SELECT id, amount, total_odds, status, full_name, phone_number, company_id, branch_id, user_id, cashed_out, cashout_id, created_at, updated_at, is_shop_bet, outcomes_hash, fast_code, outcomes +SELECT id, amount, total_odds, status, full_name, phone_number, company_id, branch_id, user_id, cashed_out, cashout_id, created_at, updated_at, is_shop_bet, outcomes_hash, fast_code, processed, outcomes FROM bet_with_outcomes WHERE id = $1 ` @@ -341,13 +346,14 @@ func (q *Queries) GetBetByID(ctx context.Context, id int64) (BetWithOutcome, err &i.IsShopBet, &i.OutcomesHash, &i.FastCode, + &i.Processed, &i.Outcomes, ) return i, err } const GetBetByUserID = `-- name: GetBetByUserID :many -SELECT id, amount, total_odds, status, full_name, phone_number, company_id, branch_id, user_id, cashed_out, cashout_id, created_at, updated_at, is_shop_bet, outcomes_hash, fast_code, outcomes +SELECT id, amount, total_odds, status, full_name, phone_number, company_id, branch_id, user_id, cashed_out, cashout_id, created_at, updated_at, is_shop_bet, outcomes_hash, fast_code, processed, outcomes FROM bet_with_outcomes WHERE user_id = $1 ` @@ -378,6 +384,7 @@ func (q *Queries) GetBetByUserID(ctx context.Context, userID pgtype.Int8) ([]Bet &i.IsShopBet, &i.OutcomesHash, &i.FastCode, + &i.Processed, &i.Outcomes, ); err != nil { return nil, err @@ -393,7 +400,7 @@ func (q *Queries) GetBetByUserID(ctx context.Context, userID pgtype.Int8) ([]Bet const GetBetCount = `-- name: GetBetCount :one SELECT COUNT(*) FROM bets -where user_id = $1 +WHERE user_id = $1 AND outcomes_hash = $2 ` @@ -505,6 +512,52 @@ func (q *Queries) GetBetOutcomeByEventID(ctx context.Context, arg GetBetOutcomeB return items, nil } +const GetBetsForCashback = `-- name: GetBetsForCashback :many +SELECT id, amount, total_odds, status, full_name, phone_number, company_id, branch_id, user_id, cashed_out, cashout_id, created_at, updated_at, is_shop_bet, outcomes_hash, fast_code, processed, outcomes +FROM bet_with_outcomes +WHERE status = 2 + AND processed = false +` + +func (q *Queries) GetBetsForCashback(ctx context.Context) ([]BetWithOutcome, error) { + rows, err := q.db.Query(ctx, GetBetsForCashback) + if err != nil { + return nil, err + } + defer rows.Close() + var items []BetWithOutcome + for rows.Next() { + var i BetWithOutcome + if err := rows.Scan( + &i.ID, + &i.Amount, + &i.TotalOdds, + &i.Status, + &i.FullName, + &i.PhoneNumber, + &i.CompanyID, + &i.BranchID, + &i.UserID, + &i.CashedOut, + &i.CashoutID, + &i.CreatedAt, + &i.UpdatedAt, + &i.IsShopBet, + &i.OutcomesHash, + &i.FastCode, + &i.Processed, + &i.Outcomes, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const UpdateBetOutcomeStatus = `-- name: UpdateBetOutcomeStatus :one UPDATE bet_outcomes SET status = $1 @@ -623,6 +676,22 @@ func (q *Queries) UpdateBetOutcomeStatusForEvent(ctx context.Context, arg Update return items, nil } +const UpdateBetWithCashback = `-- name: UpdateBetWithCashback :exec +UPDATE bets +SET processed = $1 +WHERE id = $2 +` + +type UpdateBetWithCashbackParams struct { + Processed bool `json:"processed"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateBetWithCashback(ctx context.Context, arg UpdateBetWithCashbackParams) error { + _, err := q.db.Exec(ctx, UpdateBetWithCashback, arg.Processed, arg.ID) + return err +} + const UpdateCashOut = `-- name: UpdateCashOut :exec UPDATE bets SET cashed_out = $2, diff --git a/gen/db/models.go b/gen/db/models.go index e378fe5..7d6f56d 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -90,6 +90,7 @@ type Bet struct { IsShopBet bool `json:"is_shop_bet"` OutcomesHash string `json:"outcomes_hash"` FastCode string `json:"fast_code"` + Processed bool `json:"processed"` } type BetOutcome struct { @@ -127,6 +128,7 @@ type BetWithOutcome struct { IsShopBet bool `json:"is_shop_bet"` OutcomesHash string `json:"outcomes_hash"` FastCode string `json:"fast_code"` + Processed bool `json:"processed"` Outcomes []BetOutcome `json:"outcomes"` } diff --git a/internal/repository/bet.go b/internal/repository/bet.go index 05ea998..fe6d019 100644 --- a/internal/repository/bet.go +++ b/internal/repository/bet.go @@ -293,6 +293,22 @@ func (s *Store) GetBetByFastCode(ctx context.Context, fastcode string) (domain.G return convertDBBetWithOutcomes(bet), nil } +func (s *Store) GetBetsForCashback(ctx context.Context) ([]domain.GetBet, error) { + bets, err := s.queries.GetBetsForCashback(ctx) + var res []domain.GetBet + + if err != nil { + return nil, err + } + + for _, bet := range bets { + cashbackBet := convertDBBetWithOutcomes(bet) + res = append(res, cashbackBet) + } + + return res, nil +} + func (s *Store) GetBetCount(ctx context.Context, UserID int64, outcomesHash string) (int64, error) { count, err := s.queries.GetBetCount(ctx, dbgen.GetBetCountParams{ UserID: pgtype.Int8{Int64: UserID, Valid: true}, @@ -439,6 +455,24 @@ func (s *Store) UpdateBetOutcomeStatusForEvent(ctx context.Context, eventID int6 return result, nil } +func (s *Store) UpdateBetWithCashback(ctx context.Context, betID int64, cashbackStatus bool) error { + err := s.queries.UpdateBetWithCashback(ctx, dbgen.UpdateBetWithCashbackParams{ + ID: betID, + Processed: cashbackStatus, + }) + + if err != nil { + domain.MongoDBLogger.Error("failed to update bet outcome status for event", + zap.Int64("betID", betID), + zap.Bool("status", cashbackStatus), + zap.Error(err), + ) + return err + } + + return nil +} + // GetBetSummary returns aggregated bet statistics func (s *Store) GetBetSummary(ctx context.Context, filter domain.ReportFilter) ( totalStakes domain.Currency, diff --git a/internal/services/bet/port.go b/internal/services/bet/port.go index 58a5610..dd0d80c 100644 --- a/internal/services/bet/port.go +++ b/internal/services/bet/port.go @@ -44,4 +44,7 @@ type BetStore interface { GetSportBetActivity(ctx context.Context, filter domain.ReportFilter) ([]domain.SportBetActivity, error) GetSportDetails(ctx context.Context, filter domain.ReportFilter) (map[string]string, error) GetSportMarketPopularity(ctx context.Context, filter domain.ReportFilter) (map[string]string, error) + + GetBetsForCashback(ctx context.Context) ([]domain.GetBet, error) + UpdateBetWithCashback(ctx context.Context, betID int64, cashbackStatus bool) error } diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go index e3444a3..fbcda23 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "log/slog" + "math" "math/big" random "math/rand" "sort" @@ -907,6 +908,71 @@ func (s *Service) SetBetToRemoved(ctx context.Context, id int64) error { return nil } +func (s *Service) ProcessBetCashback(ctx context.Context) error { + bets, err := s.betStore.GetBetsForCashback(ctx) + if err != nil { + s.mongoLogger.Error("failed to fetch bets", + zap.Error(err), + ) + return err + } + + for _, bet := range bets { + shouldProcess := true + loseCount := 0 + + for _, outcome := range bet.Outcomes { + // stop if other outcomes exists in bet outcomes + if outcome.Status != domain.OUTCOME_STATUS_LOSS && outcome.Status != domain.OUTCOME_STATUS_WIN { + shouldProcess = false + break + } + + if outcome.Status == domain.OUTCOME_STATUS_LOSS { + loseCount++ + // only process caseback if bet is lost by one + if loseCount > 1 { + break + } + } + } + + if !shouldProcess || loseCount != 1 { + continue + } + + if err := s.betStore.UpdateBetWithCashback(ctx, bet.ID, true); err != nil { + s.mongoLogger.Error("failed to process cashback for bet", + zap.Int64("betID", bet.ID), + zap.Error(err), + ) + continue + } + + wallets, err := s.walletSvc.GetCustomerWallet(ctx, bet.UserID.Value) + if err != nil { + s.mongoLogger.Error("failed to get wallets of a user", + zap.Int64("userID", bet.UserID.Value), + zap.Error(err), + ) + continue + } + + // TODO: get cashback amount cap (currently 1000) from settings in the db + cashbackAmount := math.Min(10, float64(calculateCashbackAmount(bet.Amount.Float32(), bet.TotalOdds))) + + _, err = s.walletSvc.AddToWallet(ctx, wallets.StaticID, domain.ToCurrency(float32(cashbackAmount)), domain.ValidInt64{}, domain.TRANSFER_DIRECT, + domain.PaymentDetails{}, fmt.Sprintf("cashback amount of %f added to users static wallet", cashbackAmount)) + + if err != nil { + s.mongoLogger.Error("Failed to update wallet for user", + zap.Int64("userID", bet.UserID.Value), + zap.Error(err)) + } + } + return nil +} + func generateOutcomeHash(outcomes []domain.CreateBetOutcome) (string, error) { // should always be in the same order for producing the same hash sort.Slice(outcomes, func(i, j int) bool { @@ -927,3 +993,29 @@ func generateOutcomeHash(outcomes []domain.CreateBetOutcome) (string, error) { sum := sha256.Sum256([]byte(sb.String())) return hex.EncodeToString(sum[:]), nil } + +func calculateCashbackAmount(amount, total_odds float32) float32 { + var multiplier float32 + + if total_odds < 18 { + multiplier = 0 + } else if total_odds >= 18 && total_odds <= 35 { + multiplier = 1 + } else if total_odds > 35 && total_odds <= 55 { + multiplier = 2 + } else if total_odds > 55 && total_odds <= 95 { + multiplier = 3 + } else if total_odds > 95 && total_odds <= 250 { + multiplier = 5 + } else if total_odds > 250 && total_odds <= 450 { + multiplier = 10 + } else if total_odds > 450 && total_odds <= 1000 { + multiplier = 50 + } else if total_odds > 1000 && total_odds <= 2000 { + multiplier = 100 + } else { + multiplier = 500 + } + + return amount * multiplier +} diff --git a/internal/web_server/cron.go b/internal/web_server/cron.go index 58e9e24..021cf88 100644 --- a/internal/web_server/cron.go +++ b/internal/web_server/cron.go @@ -8,6 +8,7 @@ import ( // "time" + betSvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" eventsvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" oddssvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/report" @@ -148,3 +149,34 @@ func SetupReportCronJobs(ctx context.Context, reportService *report.Service) { c.Start() log.Println("Cron jobs started for report generation service") } + +func ProcessBetCashback(ctx context.Context, betService *betSvc.Service) { + c := cron.New(cron.WithSeconds()) + + schedule := []struct { + spec string + task func() + }{ + { + spec: "*/10 * * * * *", // 10 seconds for testing + // spec: "0 0 0 * * *", // Daily at midnight + task: func() { + log.Println("process bet cashbacks...") + if err := betService.ProcessBetCashback(ctx); err != nil { + log.Printf("Failed to process bet cashbacks: %v", err) + } else { + log.Printf("Successfully processed bet cashbacks") + } + }, + }, + } + + for _, job := range schedule { + if _, err := c.AddFunc(job.spec, job.task); err != nil { + log.Fatalf("Failed to schedule cron job: %v", err) + } + } + + c.Start() + log.Println("Cron jobs started for bet cashbacks") +}