fix: refactoring bonus

This commit is contained in:
Samuel Tariku 2025-09-10 23:27:11 +03:00
parent 5595600ede
commit 215eb5a1d8
23 changed files with 1116 additions and 500 deletions

View File

@ -142,7 +142,7 @@ func main() {
ticketSvc := ticket.NewService(store, eventSvc, *oddsSvc, domain.MongoDBLogger, *settingSvc, notificationSvc) ticketSvc := ticket.NewService(store, eventSvc, *oddsSvc, domain.MongoDBLogger, *settingSvc, notificationSvc)
betSvc := bet.NewService(store, eventSvc, *oddsSvc, *walletSvc, *branchSvc, *companySvc, *settingSvc, *userSvc, notificationSvc, logger, domain.MongoDBLogger) betSvc := bet.NewService(store, eventSvc, *oddsSvc, *walletSvc, *branchSvc, *companySvc, *settingSvc, *userSvc, notificationSvc, logger, domain.MongoDBLogger)
resultSvc := result.NewService(store, cfg, logger, domain.MongoDBLogger, *betSvc, *oddsSvc, eventSvc, leagueSvc, notificationSvc, messengerSvc, *userSvc) resultSvc := result.NewService(store, cfg, logger, domain.MongoDBLogger, *betSvc, *oddsSvc, eventSvc, leagueSvc, notificationSvc, messengerSvc, *userSvc)
bonusSvc := bonus.NewService(store) bonusSvc := bonus.NewService(store, walletSvc, settingSvc, domain.MongoDBLogger)
referalRepo := repository.NewReferralRepository(store) referalRepo := repository.NewReferralRepository(store)
vitualGameRepo := repository.NewVirtualGameRepository(store) vitualGameRepo := repository.NewVirtualGameRepository(store)
recommendationRepo := repository.NewRecommendationRepository(store) recommendationRepo := repository.NewRecommendationRepository(store)

View File

@ -86,7 +86,10 @@ VALUES ('sms_provider', 'afro_message'),
('default_winning_limit', '5000000'), ('default_winning_limit', '5000000'),
('referral_reward_amount', '10000'), ('referral_reward_amount', '10000'),
('cashback_percentage', '0.2'), ('cashback_percentage', '0.2'),
('default_max_referrals', '15') ON CONFLICT (key) DO NOTHING; ('default_max_referrals', '15'),
('minimum_bet_amount', '100'),
('send_email_on_bet_finish', 'true'),
('send_sms_on_bet_finish', 'false') ON CONFLICT (key) DO NOTHING;
-- Users -- Users
INSERT INTO users ( INSERT INTO users (
id, id,
@ -342,4 +345,3 @@ SET name = EXCLUDED.name,
is_active = EXCLUDED.is_active, is_active = EXCLUDED.is_active,
created_at = EXCLUDED.created_at, created_at = EXCLUDED.created_at,
updated_at = EXCLUDED.updated_at; updated_at = EXCLUDED.updated_at;

View File

@ -453,10 +453,17 @@ CREATE TABLE IF NOT EXISTS company_settings (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (company_id, key) PRIMARY KEY (company_id, key)
); );
CREATE TABLE bonus ( CREATE TABLE user_bonuses (
multiplier REAL NOT NULL, id BIGINT NOT NULL,
id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL,
balance_cap BIGINT NOT NULL DEFAULT 0 description TEXT NOT NULL,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
bonus_code TEXT NOT NULL UNIQUE,
reward_amount BIGINT NOT NULL,
is_claimed BOOLEAN NOT NULL DEFAULT false,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE flags ( CREATE TABLE flags (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,

View File

@ -1,17 +1,52 @@
-- name: CreateBonusMultiplier :exec -- name: CreateUserBonus :one
INSERT INTO bonus (multiplier, balance_cap) INSERT INTO user_bonuses (
VALUES ($1, $2); name,
description,
-- name: GetBonusMultiplier :many user_id,
SELECT id, multiplier bonus_code,
FROM bonus; reward_amount,
expires_at
-- name: GetBonusBalanceCap :many )
SELECT id, balance_cap VALUES ($1, $2, $3, $4, $5, $6)
FROM bonus; RETURNING *;
-- name: GetAllUserBonuses :many
-- name: UpdateBonusMultiplier :exec SELECT *
UPDATE bonus FROM user_bonuses;
SET multiplier = $1, -- name: GetUserBonusByID :one
balance_cap = $2 SELECT *
WHERE id = $3; FROM user_bonuses
WHERE id = $1;
-- name: GetBonusesByUserID :many
SELECT *
FROM user_bonuses
WHERE user_id = $1;
-- name: GetBonusStats :one
SELECT COUNT(*) AS total_bonuses,
COALESCE(SUM(reward_amount), 0)::bigint AS total_reward_earned,
COUNT(
CASE
WHEN is_claimed = true THEN 1
END
) AS claimed_bonuses,
COUNT(
CASE
WHEN expires_at > now() THEN 1
END
) AS expired_bonuses
FROM user_bonuses
JOIN users ON users.id = user_bonuses.user_id
WHERE (
company_id = sqlc.narg('company_id')
OR sqlc.narg('company_id') IS NULL
)
AND (
user_id = sqlc.narg('user_id')
OR sqlc.narg('user_id') IS NULL
);
-- name: UpdateUserBonus :exec
UPDATE user_bonuses
SET is_claimed = $2
WHERE id = $1;
-- name: DeleteUserBonus :exec
DELETE FROM user_bonuses
WHERE id = $1;

View File

@ -30,6 +30,19 @@ WHERE id = $1;
SELECT * SELECT *
FROM wallet_transfer_details FROM wallet_transfer_details
WHERE reference_number = $1; WHERE reference_number = $1;
-- name: GetTransferStats :one
SELECT COUNT(*) AS total_transfers, COUNT(*) FILTER (
WHERE type = 'deposit'
) AS total_deposits,
COUNT(*) FILTER (
WHERE type = 'withdraw'
) AS total_withdraw,
COUNT(*) FILTER (
WHERE type = 'wallet'
) AS total_wallet_to_wallet
FROM wallet_transfer
WHERE sender_wallet_id = $1
OR receiver_wallet_id = $1;
-- name: UpdateTransferVerification :exec -- name: UpdateTransferVerification :exec
UPDATE wallet_transfer UPDATE wallet_transfer
SET verified = $1, SET verified = $1,

View File

@ -7,43 +7,93 @@ package dbgen
import ( import (
"context" "context"
"github.com/jackc/pgx/v5/pgtype"
) )
const CreateBonusMultiplier = `-- name: CreateBonusMultiplier :exec const CreateUserBonus = `-- name: CreateUserBonus :one
INSERT INTO bonus (multiplier, balance_cap) INSERT INTO user_bonuses (
VALUES ($1, $2) name,
description,
user_id,
bonus_code,
reward_amount,
expires_at
)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, description, user_id, bonus_code, reward_amount, is_claimed, expires_at, created_at, updated_at
` `
type CreateBonusMultiplierParams struct { type CreateUserBonusParams struct {
Multiplier float32 `json:"multiplier"` Name string `json:"name"`
BalanceCap int64 `json:"balance_cap"` Description string `json:"description"`
UserID int64 `json:"user_id"`
BonusCode string `json:"bonus_code"`
RewardAmount int64 `json:"reward_amount"`
ExpiresAt pgtype.Timestamp `json:"expires_at"`
} }
func (q *Queries) CreateBonusMultiplier(ctx context.Context, arg CreateBonusMultiplierParams) error { func (q *Queries) CreateUserBonus(ctx context.Context, arg CreateUserBonusParams) (UserBonuse, error) {
_, err := q.db.Exec(ctx, CreateBonusMultiplier, arg.Multiplier, arg.BalanceCap) row := q.db.QueryRow(ctx, CreateUserBonus,
arg.Name,
arg.Description,
arg.UserID,
arg.BonusCode,
arg.RewardAmount,
arg.ExpiresAt,
)
var i UserBonuse
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.UserID,
&i.BonusCode,
&i.RewardAmount,
&i.IsClaimed,
&i.ExpiresAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const DeleteUserBonus = `-- name: DeleteUserBonus :exec
DELETE FROM user_bonuses
WHERE id = $1
`
func (q *Queries) DeleteUserBonus(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteUserBonus, id)
return err return err
} }
const GetBonusBalanceCap = `-- name: GetBonusBalanceCap :many const GetAllUserBonuses = `-- name: GetAllUserBonuses :many
SELECT id, balance_cap SELECT id, name, description, user_id, bonus_code, reward_amount, is_claimed, expires_at, created_at, updated_at
FROM bonus FROM user_bonuses
` `
type GetBonusBalanceCapRow struct { func (q *Queries) GetAllUserBonuses(ctx context.Context) ([]UserBonuse, error) {
ID int64 `json:"id"` rows, err := q.db.Query(ctx, GetAllUserBonuses)
BalanceCap int64 `json:"balance_cap"`
}
func (q *Queries) GetBonusBalanceCap(ctx context.Context) ([]GetBonusBalanceCapRow, error) {
rows, err := q.db.Query(ctx, GetBonusBalanceCap)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetBonusBalanceCapRow var items []UserBonuse
for rows.Next() { for rows.Next() {
var i GetBonusBalanceCapRow var i UserBonuse
if err := rows.Scan(&i.ID, &i.BalanceCap); err != nil { if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.UserID,
&i.BonusCode,
&i.RewardAmount,
&i.IsClaimed,
&i.ExpiresAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)
@ -54,26 +104,82 @@ func (q *Queries) GetBonusBalanceCap(ctx context.Context) ([]GetBonusBalanceCapR
return items, nil return items, nil
} }
const GetBonusMultiplier = `-- name: GetBonusMultiplier :many const GetBonusStats = `-- name: GetBonusStats :one
SELECT id, multiplier SELECT COUNT(*) AS total_bonuses,
FROM bonus COALESCE(SUM(reward_amount), 0)::bigint AS total_reward_earned,
COUNT(
CASE
WHEN is_claimed = true THEN 1
END
) AS claimed_bonuses,
COUNT(
CASE
WHEN expires_at > now() THEN 1
END
) AS expired_bonuses
FROM user_bonuses
JOIN users ON users.id = user_bonuses.user_id
WHERE (
company_id = $1
OR $1 IS NULL
)
AND (
user_id = $2
OR $2 IS NULL
)
` `
type GetBonusMultiplierRow struct { type GetBonusStatsParams struct {
ID int64 `json:"id"` CompanyID pgtype.Int8 `json:"company_id"`
Multiplier float32 `json:"multiplier"` UserID pgtype.Int8 `json:"user_id"`
} }
func (q *Queries) GetBonusMultiplier(ctx context.Context) ([]GetBonusMultiplierRow, error) { type GetBonusStatsRow struct {
rows, err := q.db.Query(ctx, GetBonusMultiplier) TotalBonuses int64 `json:"total_bonuses"`
TotalRewardEarned int64 `json:"total_reward_earned"`
ClaimedBonuses int64 `json:"claimed_bonuses"`
ExpiredBonuses int64 `json:"expired_bonuses"`
}
func (q *Queries) GetBonusStats(ctx context.Context, arg GetBonusStatsParams) (GetBonusStatsRow, error) {
row := q.db.QueryRow(ctx, GetBonusStats, arg.CompanyID, arg.UserID)
var i GetBonusStatsRow
err := row.Scan(
&i.TotalBonuses,
&i.TotalRewardEarned,
&i.ClaimedBonuses,
&i.ExpiredBonuses,
)
return i, err
}
const GetBonusesByUserID = `-- name: GetBonusesByUserID :many
SELECT id, name, description, user_id, bonus_code, reward_amount, is_claimed, expires_at, created_at, updated_at
FROM user_bonuses
WHERE user_id = $1
`
func (q *Queries) GetBonusesByUserID(ctx context.Context, userID int64) ([]UserBonuse, error) {
rows, err := q.db.Query(ctx, GetBonusesByUserID, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetBonusMultiplierRow var items []UserBonuse
for rows.Next() { for rows.Next() {
var i GetBonusMultiplierRow var i UserBonuse
if err := rows.Scan(&i.ID, &i.Multiplier); err != nil { if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.UserID,
&i.BonusCode,
&i.RewardAmount,
&i.IsClaimed,
&i.ExpiresAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)
@ -84,20 +190,42 @@ func (q *Queries) GetBonusMultiplier(ctx context.Context) ([]GetBonusMultiplierR
return items, nil return items, nil
} }
const UpdateBonusMultiplier = `-- name: UpdateBonusMultiplier :exec const GetUserBonusByID = `-- name: GetUserBonusByID :one
UPDATE bonus SELECT id, name, description, user_id, bonus_code, reward_amount, is_claimed, expires_at, created_at, updated_at
SET multiplier = $1, FROM user_bonuses
balance_cap = $2 WHERE id = $1
WHERE id = $3
` `
type UpdateBonusMultiplierParams struct { func (q *Queries) GetUserBonusByID(ctx context.Context, id int64) (UserBonuse, error) {
Multiplier float32 `json:"multiplier"` row := q.db.QueryRow(ctx, GetUserBonusByID, id)
BalanceCap int64 `json:"balance_cap"` var i UserBonuse
ID int64 `json:"id"` err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.UserID,
&i.BonusCode,
&i.RewardAmount,
&i.IsClaimed,
&i.ExpiresAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
} }
func (q *Queries) UpdateBonusMultiplier(ctx context.Context, arg UpdateBonusMultiplierParams) error { const UpdateUserBonus = `-- name: UpdateUserBonus :exec
_, err := q.db.Exec(ctx, UpdateBonusMultiplier, arg.Multiplier, arg.BalanceCap, arg.ID) UPDATE user_bonuses
SET is_claimed = $2
WHERE id = $1
`
type UpdateUserBonusParams struct {
ID int64 `json:"id"`
IsClaimed bool `json:"is_claimed"`
}
func (q *Queries) UpdateUserBonus(ctx context.Context, arg UpdateUserBonusParams) error {
_, err := q.db.Exec(ctx, UpdateUserBonus, arg.ID, arg.IsClaimed)
return err return err
} }

View File

@ -79,12 +79,6 @@ type BetWithOutcome struct {
Outcomes []BetOutcome `json:"outcomes"` Outcomes []BetOutcome `json:"outcomes"`
} }
type Bonu struct {
Multiplier float32 `json:"multiplier"`
ID int64 `json:"id"`
BalanceCap int64 `json:"balance_cap"`
}
type Branch struct { type Branch struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -752,6 +746,19 @@ type User struct {
Suspended bool `json:"suspended"` Suspended bool `json:"suspended"`
} }
type UserBonuse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
UserID int64 `json:"user_id"`
BonusCode string `json:"bonus_code"`
RewardAmount int64 `json:"reward_amount"`
IsClaimed bool `json:"is_claimed"`
ExpiresAt pgtype.Timestamp `json:"expires_at"`
CreatedAt pgtype.Timestamp `json:"created_at"`
UpdatedAt pgtype.Timestamp `json:"updated_at"`
}
type UserGameInteraction struct { type UserGameInteraction struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`

View File

@ -182,6 +182,40 @@ func (q *Queries) GetTransferByReference(ctx context.Context, referenceNumber st
return i, err return i, err
} }
const GetTransferStats = `-- name: GetTransferStats :one
SELECT COUNT(*) AS total_transfers, COUNT(*) FILTER (
WHERE type = 'deposit'
) AS total_deposits,
COUNT(*) FILTER (
WHERE type = 'withdraw'
) AS total_withdraw,
COUNT(*) FILTER (
WHERE type = 'wallet'
) AS total_wallet_to_wallet
FROM wallet_transfer
WHERE sender_wallet_id = $1
OR receiver_wallet_id = $1
`
type GetTransferStatsRow struct {
TotalTransfers int64 `json:"total_transfers"`
TotalDeposits int64 `json:"total_deposits"`
TotalWithdraw int64 `json:"total_withdraw"`
TotalWalletToWallet int64 `json:"total_wallet_to_wallet"`
}
func (q *Queries) GetTransferStats(ctx context.Context, senderWalletID pgtype.Int8) (GetTransferStatsRow, error) {
row := q.db.QueryRow(ctx, GetTransferStats, senderWalletID)
var i GetTransferStatsRow
err := row.Scan(
&i.TotalTransfers,
&i.TotalDeposits,
&i.TotalWithdraw,
&i.TotalWalletToWallet,
)
return i, err
}
const GetTransfersByWallet = `-- name: GetTransfersByWallet :many const GetTransfersByWallet = `-- name: GetTransfersByWallet :many
SELECT id, amount, message, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, session_id, status, payment_method, created_at, updated_at, first_name, last_name, phone_number SELECT id, amount, message, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, session_id, status, payment_method, created_at, updated_at, first_name, last_name, phone_number
FROM wallet_transfer_details FROM wallet_transfer_details

136
internal/domain/bonus.go Normal file
View File

@ -0,0 +1,136 @@
package domain
import (
"time"
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
"github.com/jackc/pgx/v5/pgtype"
)
type UserBonus struct {
ID int64
Name string
Description string
UserID int64
BonusCode string
RewardAmount Currency
IsClaimed bool
ExpiresAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type UserBonusRes struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
UserID int64 `json:"user_id"`
BonusCode string `json:"bonus_code"`
RewardAmount float32 `json:"reward_amount"`
IsClaimed bool `json:"is_claimed"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func ConvertToBonusRes(bonus UserBonus) UserBonusRes {
return UserBonusRes{
ID: bonus.ID,
Name: bonus.Name,
Description: bonus.Description,
UserID: bonus.UserID,
BonusCode: bonus.BonusCode,
RewardAmount: bonus.RewardAmount.Float32(),
IsClaimed: bonus.IsClaimed,
ExpiresAt: bonus.ExpiresAt,
CreatedAt: bonus.CreatedAt,
UpdatedAt: bonus.UpdatedAt,
}
}
type CreateBonus struct {
Name string
Description string
UserID int64
BonusCode string
RewardAmount Currency
ExpiresAt time.Time
}
type CreateBonusReq struct {
Name string `json:"name"`
Description string `json:"description"`
UserID int64 `json:"user_id"`
BonusCode string `json:"bonus_code"`
RewardAmount float32 `json:"reward_amount"`
ExpiresAt time.Time `json:"expires_at"`
}
func ConvertCreateBonusReq(bonus CreateBonusReq, companyID int64) CreateBonus {
return CreateBonus{
Name: bonus.Name,
Description: bonus.Description,
UserID: bonus.UserID,
BonusCode: bonus.BonusCode,
RewardAmount: ToCurrency(bonus.RewardAmount),
ExpiresAt: bonus.ExpiresAt,
}
}
func ConvertCreateBonus(bonus CreateBonus) dbgen.CreateUserBonusParams {
return dbgen.CreateUserBonusParams{
Name: bonus.Name,
Description: bonus.Description,
UserID: bonus.UserID,
BonusCode: bonus.BonusCode,
RewardAmount: int64(bonus.RewardAmount),
ExpiresAt: pgtype.Timestamp{
Time: bonus.ExpiresAt,
Valid: true,
},
}
}
func ConvertDBBonus(bonus dbgen.UserBonuse) UserBonus {
return UserBonus{
ID: bonus.ID,
Name: bonus.Name,
Description: bonus.Description,
UserID: bonus.UserID,
BonusCode: bonus.BonusCode,
RewardAmount: Currency(bonus.RewardAmount),
IsClaimed: bonus.IsClaimed,
ExpiresAt: bonus.ExpiresAt.Time,
CreatedAt: bonus.CreatedAt.Time,
UpdatedAt: bonus.UpdatedAt.Time,
}
}
func ConvertDBBonuses(bonuses []dbgen.UserBonuse) []UserBonus {
result := make([]UserBonus, len(bonuses))
for i, bonus := range bonuses {
result[i] = ConvertDBBonus(bonus)
}
return result
}
type BonusFilter struct {
UserID ValidInt64
CompanyID ValidInt64
}
type BonusStats struct {
TotalBonus int64
TotalRewardAmount Currency
ClaimedBonuses int64
ExpiredBonuses int64
}
func ConvertDBBonusStats(stats dbgen.GetBonusStatsRow) BonusStats {
return BonusStats{
TotalBonus: stats.TotalBonuses,
TotalRewardAmount: Currency(stats.TotalRewardEarned),
ClaimedBonuses: stats.ClaimedBonuses,
ExpiredBonuses: stats.ExpiredBonuses,
}
}

View File

@ -27,6 +27,15 @@ type SettingList struct {
ReferralRewardAmount Currency `json:"referral_reward_amount"` ReferralRewardAmount Currency `json:"referral_reward_amount"`
CashbackPercentage float32 `json:"cashback_percentage"` CashbackPercentage float32 `json:"cashback_percentage"`
DefaultMaxReferrals int64 `json:"default_max_referrals"` DefaultMaxReferrals int64 `json:"default_max_referrals"`
MinimumBetAmount Currency `json:"minimum_bet_amount"`
BetDuplicateLimit int64 `json:"bet_duplicate_limit"`
SendEmailOnBetFinish bool `json:"send_email_on_bet_finish"`
SendSMSOnBetFinish bool `json:"send_sms_on_bet_finish"`
WelcomeBonusActive bool `json:"welcome_bonus_active"`
WelcomeBonusMultiplier float32 `json:"welcome_bonus_multiplier"`
WelcomeBonusCap Currency `json:"welcome_bonus_cap"`
WelcomeBonusCount int64 `json:"welcome_bonus_count"`
WelcomeBonusExpire int64 `json:"welcome_bonus_expiry"`
} }
type SettingListRes struct { type SettingListRes struct {
@ -41,6 +50,10 @@ type SettingListRes struct {
ReferralRewardAmount float32 `json:"referral_reward_amount"` ReferralRewardAmount float32 `json:"referral_reward_amount"`
CashbackPercentage float32 `json:"cashback_percentage"` CashbackPercentage float32 `json:"cashback_percentage"`
DefaultMaxReferrals int64 `json:"default_max_referrals"` DefaultMaxReferrals int64 `json:"default_max_referrals"`
MinimumBetAmount float32 `json:"minimum_bet_amount"`
BetDuplicateLimit int64 `json:"bet_duplicate_limit"`
SendEmailOnBetFinish bool `json:"send_email_on_bet_finish"`
SendSMSOnBetFinish bool `json:"send_sms_on_bet_finish"`
} }
func ConvertSettingListRes(settings SettingList) SettingListRes { func ConvertSettingListRes(settings SettingList) SettingListRes {
@ -56,6 +69,10 @@ func ConvertSettingListRes(settings SettingList) SettingListRes {
ReferralRewardAmount: settings.ReferralRewardAmount.Float32(), ReferralRewardAmount: settings.ReferralRewardAmount.Float32(),
CashbackPercentage: settings.CashbackPercentage, CashbackPercentage: settings.CashbackPercentage,
DefaultMaxReferrals: settings.DefaultMaxReferrals, DefaultMaxReferrals: settings.DefaultMaxReferrals,
MinimumBetAmount: settings.MinimumBetAmount.Float32(),
BetDuplicateLimit: settings.BetDuplicateLimit,
SendEmailOnBetFinish: settings.SendEmailOnBetFinish,
SendSMSOnBetFinish: settings.SendSMSOnBetFinish,
} }
} }
@ -71,6 +88,10 @@ type SaveSettingListReq struct {
ReferralRewardAmount *float32 `json:"referral_reward_amount"` ReferralRewardAmount *float32 `json:"referral_reward_amount"`
CashbackPercentage *float32 `json:"cashback_percentage"` CashbackPercentage *float32 `json:"cashback_percentage"`
DefaultMaxReferrals *int64 `json:"default_max_referrals"` DefaultMaxReferrals *int64 `json:"default_max_referrals"`
MinimumBetAmount *float32 `json:"minimum_bet_amount"`
BetDuplicateLimit *int64 `json:"bet_duplicate_limit"`
SendEmailOnBetFinish *bool `json:"send_email_on_bet_finish"`
SendSMSOnBetFinish *bool `json:"send_sms_on_bet_finish"`
} }
type ValidSettingList struct { type ValidSettingList struct {
@ -85,6 +106,10 @@ type ValidSettingList struct {
ReferralRewardAmount ValidCurrency ReferralRewardAmount ValidCurrency
CashbackPercentage ValidFloat32 CashbackPercentage ValidFloat32
DefaultMaxReferrals ValidInt64 DefaultMaxReferrals ValidInt64
MinimumBetAmount ValidCurrency
BetDuplicateLimit ValidInt64
SendEmailOnBetFinish ValidBool
SendSMSOnBetFinish ValidBool
} }
func ConvertSaveSettingListReq(settings SaveSettingListReq) ValidSettingList { func ConvertSaveSettingListReq(settings SaveSettingListReq) ValidSettingList {
@ -100,6 +125,10 @@ func ConvertSaveSettingListReq(settings SaveSettingListReq) ValidSettingList {
ReferralRewardAmount: ConvertFloat32PtrToCurrency(settings.ReferralRewardAmount), ReferralRewardAmount: ConvertFloat32PtrToCurrency(settings.ReferralRewardAmount),
CashbackPercentage: ConvertFloat32Ptr(settings.CashbackPercentage), CashbackPercentage: ConvertFloat32Ptr(settings.CashbackPercentage),
DefaultMaxReferrals: ConvertInt64Ptr(settings.DefaultMaxReferrals), DefaultMaxReferrals: ConvertInt64Ptr(settings.DefaultMaxReferrals),
MinimumBetAmount: ConvertFloat32PtrToCurrency(settings.MinimumBetAmount),
BetDuplicateLimit: ConvertInt64Ptr(settings.BetDuplicateLimit),
SendEmailOnBetFinish: ConvertBoolPtr(settings.SendEmailOnBetFinish),
SendSMSOnBetFinish: ConvertBoolPtr(settings.SendSMSOnBetFinish),
} }
} }
@ -117,6 +146,10 @@ func (vsl *ValidSettingList) ToSettingList() SettingList {
ReferralRewardAmount: vsl.ReferralRewardAmount.Value, ReferralRewardAmount: vsl.ReferralRewardAmount.Value,
CashbackPercentage: vsl.CashbackPercentage.Value, CashbackPercentage: vsl.CashbackPercentage.Value,
DefaultMaxReferrals: vsl.DefaultMaxReferrals.Value, DefaultMaxReferrals: vsl.DefaultMaxReferrals.Value,
MinimumBetAmount: vsl.MinimumBetAmount.Value,
BetDuplicateLimit: vsl.BetDuplicateLimit.Value,
SendEmailOnBetFinish: vsl.SendEmailOnBetFinish.Value,
SendSMSOnBetFinish: vsl.SendSMSOnBetFinish.Value,
} }
} }
@ -134,6 +167,7 @@ func (vsl *ValidSettingList) GetInt64SettingsMap() map[string]*ValidInt64 {
"daily_ticket_limit": &vsl.DailyTicketPerIP, "daily_ticket_limit": &vsl.DailyTicketPerIP,
"default_winning_limit": &vsl.DefaultWinningLimit, "default_winning_limit": &vsl.DefaultWinningLimit,
"default_max_referrals": &vsl.DefaultMaxReferrals, "default_max_referrals": &vsl.DefaultMaxReferrals,
"bet_duplicate_limit": &vsl.BetDuplicateLimit,
} }
} }
@ -144,6 +178,7 @@ func (vsl *ValidSettingList) GetCurrencySettingsMap() map[string]*ValidCurrency
"amount_for_bet_referral": &vsl.AmountForBetReferral, "amount_for_bet_referral": &vsl.AmountForBetReferral,
"cashback_amount_cap": &vsl.CashbackAmountCap, "cashback_amount_cap": &vsl.CashbackAmountCap,
"referral_reward_amount": &vsl.ReferralRewardAmount, "referral_reward_amount": &vsl.ReferralRewardAmount,
"minimum_bet_amount": &vsl.MinimumBetAmount,
} }
} }
@ -154,7 +189,10 @@ func (vsl *ValidSettingList) GetStringSettingsMap() map[string]*ValidString {
} }
func (vsl *ValidSettingList) GetBoolSettingsMap() map[string]*ValidBool { func (vsl *ValidSettingList) GetBoolSettingsMap() map[string]*ValidBool {
return map[string]*ValidBool{} return map[string]*ValidBool{
"send_email_on_bet_finish": &vsl.SendEmailOnBetFinish,
"send_sms_on_bet_finish": &vsl.SendSMSOnBetFinish,
}
} }
func (vsl *ValidSettingList) GetFloat32SettingsMap() map[string]*ValidFloat32 { func (vsl *ValidSettingList) GetFloat32SettingsMap() map[string]*ValidFloat32 {
@ -167,7 +205,6 @@ func (vsl *ValidSettingList) GetTimeSettingsMap() map[string]*ValidTime {
return map[string]*ValidTime{} return map[string]*ValidTime{}
} }
// Setting Functions // Setting Functions
func (vsl *ValidSettingList) GetTotalSettings() int { func (vsl *ValidSettingList) GetTotalSettings() int {

View File

@ -105,3 +105,10 @@ type CreateTransfer struct {
Status string `json:"status"` Status string `json:"status"`
CashierID ValidInt64 `json:"cashier_id"` CashierID ValidInt64 `json:"cashier_id"`
} }
type TransferStats struct {
TotalTransfer int64
TotalDeposits int64
TotalWithdraws int64
TotalWalletToWallet int64
}

View File

@ -10,6 +10,7 @@ import (
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"go.uber.org/zap" "go.uber.org/zap"
) )
@ -220,6 +221,46 @@ func (s *Store) UpdateStatus(ctx context.Context, id int64, status domain.Outcom
return err return err
} }
func (s *Store) SettleWinningBet(ctx context.Context, betID int64, userID int64, amount domain.Currency, status domain.OutcomeStatus) error {
tx, err := s.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
qtx := s.queries.WithTx(tx)
wallet, err := qtx.GetCustomerWallet(ctx, userID)
if err != nil {
tx.Rollback(ctx)
return err
}
// 1. Update wallet
newAmount := wallet.RegularBalance + int64(amount)
if err := qtx.UpdateBalance(ctx, dbgen.UpdateBalanceParams{
Balance: newAmount,
ID: wallet.RegularID,
}); err != nil {
tx.Rollback(ctx)
return err
}
// 2. Update bet
if err := qtx.UpdateStatus(ctx, dbgen.UpdateStatusParams{
Status: int32(status),
ID: betID,
}); err != nil {
tx.Rollback(ctx)
return err
}
// 3. Commit both together
if err := tx.Commit(ctx); err != nil {
return err
}
return nil
}
func (s *Store) GetBetOutcomeByEventID(ctx context.Context, eventID int64, is_filtered bool) ([]domain.BetOutcome, error) { func (s *Store) GetBetOutcomeByEventID(ctx context.Context, eventID int64, is_filtered bool) ([]domain.BetOutcome, error) {
outcomes, err := s.queries.GetBetOutcomeByEventID(ctx, dbgen.GetBetOutcomeByEventIDParams{ outcomes, err := s.queries.GetBetOutcomeByEventID(ctx, dbgen.GetBetOutcomeByEventIDParams{

View File

@ -4,27 +4,75 @@ import (
"context" "context"
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
) )
func (s *Store) CreateBonusMultiplier(ctx context.Context, multiplier float32, balance_cap int64) error { func (s *Store) CreateUserBonus(ctx context.Context, bonus domain.CreateBonus) (domain.UserBonus, error) {
return s.queries.CreateBonusMultiplier(ctx, dbgen.CreateBonusMultiplierParams{ newBonus, err := s.queries.CreateUserBonus(ctx, domain.ConvertCreateBonus(bonus))
Multiplier: multiplier,
BalanceCap: balance_cap, if err != nil {
return domain.UserBonus{}, err
}
return domain.ConvertDBBonus(newBonus), nil
}
func (s *Store) GetAllUserBonuses(ctx context.Context) ([]domain.UserBonus, error) {
bonuses, err := s.queries.GetAllUserBonuses(ctx)
if err != nil {
return nil, err
}
return domain.ConvertDBBonuses(bonuses), nil
}
func (s *Store) GetBonusesByUserID(ctx context.Context, userID int64) ([]domain.UserBonus, error) {
bonuses, err := s.queries.GetBonusesByUserID(ctx, userID)
if err != nil {
return nil, err
}
return domain.ConvertDBBonuses(bonuses), nil
}
func (s *Store) GetBonusByID(ctx context.Context, bonusID int64) (domain.UserBonus, error) {
bonus, err := s.queries.GetUserBonusByID(ctx, bonusID)
if err != nil {
return domain.UserBonus{}, err
}
return domain.ConvertDBBonus(bonus), nil
}
func (s *Store) GetBonusStats(ctx context.Context, filter domain.BonusFilter) (domain.BonusStats, error) {
bonus, err := s.queries.GetBonusStats(ctx, dbgen.GetBonusStatsParams{
CompanyID: filter.CompanyID.ToPG(),
UserID: filter.UserID.ToPG(),
}) })
if err != nil {
return domain.BonusStats{}, err
}
return domain.ConvertDBBonusStats(bonus), nil
} }
func (s *Store) GetBonusMultiplier(ctx context.Context) ([]dbgen.GetBonusMultiplierRow, error) { func (s *Store) UpdateUserBonus(ctx context.Context, bonusID int64, IsClaimed bool) (error) {
return s.queries.GetBonusMultiplier(ctx) err := s.queries.UpdateUserBonus(ctx, dbgen.UpdateUserBonusParams{
} ID: bonusID,
IsClaimed: IsClaimed,
func (s *Store) GetBonusBalanceCap(ctx context.Context) ([]dbgen.GetBonusBalanceCapRow, error) {
return s.queries.GetBonusBalanceCap(ctx)
}
func (s *Store) UpdateBonusMultiplier(ctx context.Context, id int64, mulitplier float32, balance_cap int64) error {
return s.queries.UpdateBonusMultiplier(ctx, dbgen.UpdateBonusMultiplierParams{
ID: id,
Multiplier: mulitplier,
BalanceCap: balance_cap,
}) })
if err != nil {
return err
}
return nil
}
func (s *Store) DeleteUserBonus(ctx context.Context, bonusID int64) (error) {
err := s.queries.DeleteUserBonus(ctx, bonusID)
if err != nil {
return err
}
return nil
} }

View File

@ -148,6 +148,24 @@ func (s *Store) GetTransferByID(ctx context.Context, id int64) (domain.TransferD
return convertDBTransferDetail(transfer), nil return convertDBTransferDetail(transfer), nil
} }
func (s *Store) GetTransferStats(ctx context.Context, walletID int64) (domain.TransferStats, error) {
stats, err := s.queries.GetTransferStats(ctx, pgtype.Int8{
Int64: walletID,
Valid: true,
})
if err != nil {
return domain.TransferStats{}, err
}
return domain.TransferStats{
TotalTransfer: stats.TotalTransfers,
TotalDeposits: stats.TotalDeposits,
TotalWithdraws: stats.TotalWithdraw,
TotalWalletToWallet: stats.TotalWalletToWallet,
}, nil
}
func (s *Store) UpdateTransferVerification(ctx context.Context, id int64, verified bool) error { func (s *Store) UpdateTransferVerification(ctx context.Context, id int64, verified bool) error {
err := s.queries.UpdateTransferVerification(ctx, dbgen.UpdateTransferVerificationParams{ err := s.queries.UpdateTransferVerification(ctx, dbgen.UpdateTransferVerificationParams{
ID: id, ID: id,

View File

@ -31,20 +31,20 @@ import (
) )
var ( var (
ErrNoEventsAvailable = errors.New("Not enough events available with the given filters") ErrNoEventsAvailable = errors.New("not enough events available with the given filters")
ErrGenerateRandomOutcome = errors.New("Failed to generate any random outcome for events") ErrGenerateRandomOutcome = errors.New("failed to generate any random outcome for events")
ErrOutcomesNotCompleted = errors.New("Some bet outcomes are still pending") ErrOutcomesNotCompleted = errors.New("some bet outcomes are still pending")
ErrEventHasBeenRemoved = errors.New("Event has been removed") ErrEventHasBeenRemoved = errors.New("event has been removed")
ErrEventHasNotEnded = errors.New("Event has not ended yet") ErrEventHasNotEnded = errors.New("event has not ended yet")
ErrRawOddInvalid = errors.New("Prematch Raw Odd is Invalid") ErrRawOddInvalid = errors.New("prematch Raw Odd is Invalid")
ErrBranchIDRequired = errors.New("Branch ID required for this role") ErrBranchIDRequired = errors.New("branch ID required for this role")
ErrOutcomeLimit = errors.New("Too many outcomes on a single bet") ErrOutcomeLimit = errors.New("too many outcomes on a single bet")
ErrTotalBalanceNotEnough = errors.New("Total Wallet balance is insufficient to create bet") ErrTotalBalanceNotEnough = errors.New("total Wallet balance is insufficient to create bet")
ErrInvalidAmount = errors.New("Invalid amount") ErrInvalidAmount = errors.New("invalid amount")
ErrBetAmountTooHigh = errors.New("Cannot create a bet with an amount above limit") ErrBetAmountTooHigh = errors.New("cannot create a bet with an amount above limit")
ErrBetWinningTooHigh = errors.New("Total Winnings over set limit") ErrBetWinningTooHigh = errors.New("total Winnings over set limit")
) )
type Service struct { type Service struct {
@ -221,7 +221,7 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID
if err != nil { if err != nil {
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
if req.Amount < 1 { if req.Amount < settingsList.MinimumBetAmount.Float32() {
return domain.CreateBetRes{}, ErrInvalidAmount return domain.CreateBetRes{}, ErrInvalidAmount
} }
@ -284,9 +284,8 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
// TODO: Make this a setting if role == domain.RoleCustomer && count >= settingsList.BetDuplicateLimit {
if role == domain.RoleCustomer && count >= 10 { return domain.CreateBetRes{}, fmt.Errorf("max user limit for duplicate bet")
return domain.CreateBetRes{}, fmt.Errorf("max user limit for single outcome")
} }
fastCode := helpers.GenerateFastCode() fastCode := helpers.GenerateFastCode()
@ -387,7 +386,7 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID
zap.String("role", string(role)), zap.String("role", string(role)),
zap.Int64("user_id", userID), zap.Int64("user_id", userID),
) )
return domain.CreateBetRes{}, fmt.Errorf("Unknown Role Type") return domain.CreateBetRes{}, fmt.Errorf("unknown role type")
} }
bet, err := s.CreateBet(ctx, newBet) bet, err := s.CreateBet(ctx, newBet)
@ -588,25 +587,21 @@ func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID string,
var newOdds []domain.CreateBetOutcome var newOdds []domain.CreateBetOutcome
var totalOdds float32 = 1 var totalOdds float32 = 1
markets, err := s.prematchSvc.GetOddsByEventID(ctx, eventID, domain.OddMarketWithEventFilter{}) eventLogger := s.mongoLogger.With(
if err != nil {
s.logger.Error("failed to get odds for event", "event id", eventID, "error", err)
s.mongoLogger.Error("failed to get odds for event",
zap.String("eventID", eventID), zap.String("eventID", eventID),
zap.Int32("sportID", sportID), zap.Int32("sportID", sportID),
zap.String("homeTeam", HomeTeam), zap.String("homeTeam", HomeTeam),
zap.String("awayTeam", AwayTeam), zap.String("awayTeam", AwayTeam),
zap.Error(err)) )
markets, err := s.prematchSvc.GetOddsByEventID(ctx, eventID, domain.OddMarketWithEventFilter{})
if err != nil {
eventLogger.Error("failed to get odds for event", zap.Error(err))
return nil, 0, err return nil, 0, err
} }
if len(markets) == 0 { if len(markets) == 0 {
s.logger.Error("empty odds for event", "event id", eventID) eventLogger.Warn("empty odds for event")
s.mongoLogger.Warn("empty odds for event",
zap.String("eventID", eventID),
zap.Int32("sportID", sportID),
zap.String("homeTeam", HomeTeam),
zap.String("awayTeam", AwayTeam))
return nil, 0, fmt.Errorf("empty odds or event %v", eventID) return nil, 0, fmt.Errorf("empty odds or event %v", eventID)
} }
@ -635,19 +630,13 @@ func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID string,
err = json.Unmarshal(rawBytes, &selectedOdd) err = json.Unmarshal(rawBytes, &selectedOdd)
if err != nil { if err != nil {
s.logger.Error("Failed to unmarshal raw odd", "error", err) eventLogger.Warn("Failed to unmarshal raw odd", zap.Error(err))
s.mongoLogger.Warn("Failed to unmarshal raw odd",
zap.String("eventID", eventID),
zap.Int32("sportID", sportID),
zap.Error(err))
continue continue
} }
parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32)
if err != nil { if err != nil {
s.logger.Error("Failed to parse odd", "error", err) eventLogger.Warn("Failed to parse odd",
s.mongoLogger.Warn("Failed to parse odd",
zap.String("eventID", eventID),
zap.String("oddValue", selectedOdd.Odds), zap.String("oddValue", selectedOdd.Odds),
zap.Error(err)) zap.Error(err))
continue continue
@ -655,17 +644,13 @@ func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID string,
eventIDInt, err := strconv.ParseInt(eventID, 10, 64) eventIDInt, err := strconv.ParseInt(eventID, 10, 64)
if err != nil { if err != nil {
s.logger.Error("Failed to parse eventID", "error", err) eventLogger.Warn("Failed to parse eventID", zap.Error(err))
s.mongoLogger.Warn("Failed to parse eventID",
zap.String("eventID", eventID),
zap.Error(err))
continue continue
} }
oddID, err := strconv.ParseInt(selectedOdd.ID, 10, 64) oddID, err := strconv.ParseInt(selectedOdd.ID, 10, 64)
if err != nil { if err != nil {
s.logger.Error("Failed to parse oddID", "error", err) eventLogger.Warn("Failed to parse oddID",
s.mongoLogger.Warn("Failed to parse oddID",
zap.String("oddID", selectedOdd.ID), zap.String("oddID", selectedOdd.ID),
zap.Error(err)) zap.Error(err))
continue continue
@ -673,8 +658,7 @@ func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID string,
marketID, err := strconv.ParseInt(market.MarketID, 10, 64) marketID, err := strconv.ParseInt(market.MarketID, 10, 64)
if err != nil { if err != nil {
s.logger.Error("Failed to parse marketID", "error", err) eventLogger.Warn("Failed to parse marketID",
s.mongoLogger.Warn("Failed to parse marketID",
zap.String("marketID", market.MarketID), zap.String("marketID", market.MarketID),
zap.Error(err)) zap.Error(err))
continue continue
@ -701,22 +685,12 @@ func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID string,
} }
if len(newOdds) == 0 { if len(newOdds) == 0 {
s.logger.Error("Bet Outcomes is empty for market", "selectedMarkets", len(selectedMarkets)) eventLogger.Error("Bet Outcomes is empty for market", zap.Int("selectedMarkets", len(selectedMarkets)))
s.mongoLogger.Error("Bet Outcomes is empty for market",
zap.String("eventID", eventID),
zap.Int32("sportID", sportID),
zap.String("homeTeam", HomeTeam),
zap.String("awayTeam", AwayTeam),
zap.Int("selectedMarkets", len(selectedMarkets)))
return nil, 0, ErrGenerateRandomOutcome return nil, 0, ErrGenerateRandomOutcome
} }
// ✅ Final success log (optional) // ✅ Final success log (optional)
s.mongoLogger.Info("Random bet outcomes generated successfully", eventLogger.Info("Random bet outcomes generated successfully", zap.Int("numOutcomes", len(newOdds)), zap.Float32("totalOdds", totalOdds))
zap.String("eventID", eventID),
zap.Int32("sportID", sportID),
zap.Int("numOutcomes", len(newOdds)),
zap.Float32("totalOdds", totalOdds))
return newOdds, totalOdds, nil return newOdds, totalOdds, nil
} }
@ -724,7 +698,15 @@ func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID string,
func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID, companyID int64, leagueID domain.ValidInt64, sportID domain.ValidInt32, firstStartTime, lastStartTime domain.ValidTime) (domain.CreateBetRes, error) { func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID, companyID int64, leagueID domain.ValidInt64, sportID domain.ValidInt32, firstStartTime, lastStartTime domain.ValidTime) (domain.CreateBetRes, error) {
// Get a unexpired event id // Get a unexpired event id
randomBetLogger := s.mongoLogger.With(
zap.Int64("userID", userID),
zap.Int64("branchID", branchID),
zap.Int64("companyID", companyID),
zap.Any("leagueID", leagueID),
zap.Any("sportID", sportID),
zap.Any("firstStartTime", firstStartTime),
zap.Any("lastStartTime", lastStartTime),
)
events, _, err := s.eventSvc.GetPaginatedUpcomingEvents(ctx, events, _, err := s.eventSvc.GetPaginatedUpcomingEvents(ctx,
domain.EventFilter{ domain.EventFilter{
SportID: sportID, SportID: sportID,
@ -734,17 +716,12 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID, companyI
}) })
if err != nil { if err != nil {
s.mongoLogger.Error("failed to get paginated upcoming events", randomBetLogger.Error("failed to get paginated upcoming events", zap.Error(err))
zap.Int64("userID", userID),
zap.Int64("branchID", branchID),
zap.Error(err))
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
if len(events) == 0 { if len(events) == 0 {
s.mongoLogger.Warn("no events available for random bet", randomBetLogger.Warn("no events available for random bet")
zap.Int64("userID", userID),
zap.Int64("branchID", branchID))
return domain.CreateBetRes{}, ErrNoEventsAvailable return domain.CreateBetRes{}, ErrNoEventsAvailable
} }
@ -770,12 +747,7 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID, companyI
newOdds, total, err := s.GenerateRandomBetOutcomes(ctx, event.ID, event.SportID, event.HomeTeam, event.AwayTeam, event.StartTime, numMarketsPerBet) newOdds, total, err := s.GenerateRandomBetOutcomes(ctx, event.ID, event.SportID, event.HomeTeam, event.AwayTeam, event.StartTime, numMarketsPerBet)
if err != nil { if err != nil {
s.logger.Error("failed to generate random bet outcome", "event id", event.ID, "error", err) s.mongoLogger.Error("failed to generate random bet outcome", zap.String("eventID", event.ID), zap.Error(err))
s.mongoLogger.Error("failed to generate random bet outcome",
zap.Int64("userID", userID),
zap.Int64("branchID", branchID),
zap.String("eventID", event.ID),
zap.String("error", fmt.Sprintf("%v", err)))
continue continue
} }
@ -784,10 +756,7 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID, companyI
} }
if len(randomOdds) == 0 { if len(randomOdds) == 0 {
s.logger.Error("Failed to generate random any outcomes for all events") randomBetLogger.Error("Failed to generate random any outcomes for all events")
s.mongoLogger.Error("Failed to generate random any outcomes for all events",
zap.Int64("userID", userID),
zap.Int64("branchID", branchID))
return domain.CreateBetRes{}, ErrGenerateRandomOutcome return domain.CreateBetRes{}, ErrGenerateRandomOutcome
} }
@ -795,20 +764,13 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID, companyI
outcomesHash, err := generateOutcomeHash(randomOdds) outcomesHash, err := generateOutcomeHash(randomOdds)
if err != nil { if err != nil {
s.mongoLogger.Error("failed to generate outcome hash", randomBetLogger.Error("failed to generate outcome hash", zap.Error(err))
zap.Int64("user_id", userID),
zap.Error(err),
)
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
count, err := s.GetBetCountByUserID(ctx, userID, outcomesHash) count, err := s.GetBetCountByUserID(ctx, userID, outcomesHash)
if err != nil { if err != nil {
s.mongoLogger.Error("failed to get bet count", randomBetLogger.Error("failed to get bet count", zap.String("outcome_hash", outcomesHash), zap.Error(err))
zap.Int64("user_id", userID),
zap.String("outcome_hash", outcomesHash),
zap.Error(err),
)
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
@ -830,10 +792,7 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID, companyI
bet, err := s.CreateBet(ctx, newBet) bet, err := s.CreateBet(ctx, newBet)
if err != nil { if err != nil {
s.mongoLogger.Error("Failed to create a new random bet", randomBetLogger.Error("Failed to create a new random bet", zap.Error(err))
zap.Int64("userID", userID),
zap.Int64("branchID", branchID),
zap.String("bet", fmt.Sprintf("%+v", newBet)))
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
@ -843,19 +802,13 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID, companyI
rows, err := s.betStore.CreateBetOutcome(ctx, randomOdds) rows, err := s.betStore.CreateBetOutcome(ctx, randomOdds)
if err != nil { if err != nil {
s.mongoLogger.Error("Failed to create a new random bet outcome", randomBetLogger.Error("Failed to create a new random bet outcome", zap.Any("randomOdds", randomOdds))
zap.Int64("userID", userID),
zap.Int64("branchID", branchID),
zap.String("randomOdds", fmt.Sprintf("%+v", randomOdds)))
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
res := domain.ConvertCreateBetRes(bet, rows) res := domain.ConvertCreateBetRes(bet, rows)
s.mongoLogger.Info("Random bets placed successfully", randomBetLogger.Info("Random bets placed successfully")
zap.Int64("userID", userID),
zap.Int64("branchID", branchID),
zap.String("response", fmt.Sprintf("%+v", res)))
return res, nil return res, nil
} }
@ -902,53 +855,73 @@ func (s *Service) UpdateCashOut(ctx context.Context, id int64, cashedOut bool) e
return s.betStore.UpdateCashOut(ctx, id, cashedOut) return s.betStore.UpdateCashOut(ctx, id, cashedOut)
} }
func (s *Service) UpdateStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error { func (s *Service) UpdateStatus(ctx context.Context, betId int64, status domain.OutcomeStatus) error {
bet, err := s.GetBetByID(ctx, id)
if err != nil { updateLogger := s.mongoLogger.With(
s.mongoLogger.Error("failed to update bet status: invalid bet ID", zap.Int64("bet_id", betId),
zap.Int64("bet_id", id), zap.String("status", status.String()),
zap.Error(err),
) )
bet, err := s.GetBetByID(ctx, betId)
if err != nil {
updateLogger.Error("failed to update bet status: invalid bet ID", zap.Error(err))
return err
}
settingsList, err := s.settingSvc.GetOverrideSettingsList(ctx, bet.CompanyID)
if err != nil {
updateLogger.Error("failed to get settings", zap.Error(err))
return err return err
} }
if status == domain.OUTCOME_STATUS_ERROR || status == domain.OUTCOME_STATUS_PENDING { if status == domain.OUTCOME_STATUS_ERROR || status == domain.OUTCOME_STATUS_PENDING {
s.SendAdminErrorAlertNotification(ctx, status, "") if err := s.SendAdminAlertNotification(ctx, betId, status, "", bet.CompanyID); err != nil {
s.SendErrorStatusNotification(ctx, status, bet.UserID, "") updateLogger.Error("failed to send admin notification", zap.Error(err))
s.mongoLogger.Error("Bet Status is error",
zap.Int64("bet_id", id),
zap.Error(err),
)
return s.betStore.UpdateStatus(ctx, id, status)
}
if bet.IsShopBet {
return s.betStore.UpdateStatus(ctx, id, status)
}
customerWallet, err := s.walletSvc.GetCustomerWallet(ctx, id)
if err != nil {
s.mongoLogger.Error("failed to get customer wallet",
zap.Int64("bet_id", id),
zap.Error(err),
)
return err return err
} }
if err := s.SendErrorStatusNotification(ctx, betId, status, bet.UserID, ""); err != nil {
updateLogger.Error("failed to send error notification to user", zap.Error(err))
return err
}
updateLogger.Error("bet entered error/pending state")
return s.betStore.UpdateStatus(ctx, betId, status)
}
if bet.IsShopBet {
return s.betStore.UpdateStatus(ctx, betId, status)
}
// After this point the bet is known to be a online customer bet
customerWallet, err := s.walletSvc.GetCustomerWallet(ctx, bet.UserID)
if err != nil {
updateLogger.Error("failed to get customer wallet", zap.Error(err))
return err
}
resultNotification := SendResultNotificationParam{
BetID: betId,
Status: status,
UserID: bet.UserID,
SendEmail: settingsList.SendEmailOnBetFinish,
SendSMS: settingsList.SendSMSOnBetFinish,
}
var amount domain.Currency var amount domain.Currency
switch status { switch status {
case domain.OUTCOME_STATUS_LOSS: case domain.OUTCOME_STATUS_LOSS:
s.SendLosingStatusNotification(ctx, status, bet.UserID, "") err := s.SendLosingStatusNotification(ctx, resultNotification)
return s.betStore.UpdateStatus(ctx, id, status) if err != nil {
updateLogger.Error("failed to send notification", zap.Error(err))
return err
}
return s.betStore.UpdateStatus(ctx, betId, status)
case domain.OUTCOME_STATUS_WIN: case domain.OUTCOME_STATUS_WIN:
amount = domain.CalculateWinnings(bet.Amount, bet.TotalOdds) amount = domain.CalculateWinnings(bet.Amount, bet.TotalOdds)
s.SendWinningStatusNotification(ctx, status, bet.UserID, amount, "")
case domain.OUTCOME_STATUS_HALF: case domain.OUTCOME_STATUS_HALF:
amount = domain.CalculateWinnings(bet.Amount, bet.TotalOdds) / 2 amount = domain.CalculateWinnings(bet.Amount, bet.TotalOdds) / 2
s.SendWinningStatusNotification(ctx, status, bet.UserID, amount, "")
case domain.OUTCOME_STATUS_VOID: case domain.OUTCOME_STATUS_VOID:
amount = bet.Amount amount = bet.Amount
s.SendWinningStatusNotification(ctx, status, bet.UserID, amount, "")
default: default:
updateLogger.Error("invalid outcome status")
return fmt.Errorf("invalid outcome status") return fmt.Errorf("invalid outcome status")
} }
@ -956,7 +929,7 @@ func (s *Service) UpdateStatus(ctx context.Context, id int64, status domain.Outc
domain.TRANSFER_DIRECT, domain.PaymentDetails{}, fmt.Sprintf("Added %v to wallet by system for winning a bet", amount.Float32())) domain.TRANSFER_DIRECT, domain.PaymentDetails{}, fmt.Sprintf("Added %v to wallet by system for winning a bet", amount.Float32()))
if err != nil { if err != nil {
s.mongoLogger.Error("failed to add winnings to wallet", updateLogger.Error("failed to add winnings to wallet",
zap.Int64("wallet_id", customerWallet.RegularID), zap.Int64("wallet_id", customerWallet.RegularID),
zap.Float32("amount", float32(amount)), zap.Float32("amount", float32(amount)),
zap.Error(err), zap.Error(err),
@ -964,179 +937,211 @@ func (s *Service) UpdateStatus(ctx context.Context, id int64, status domain.Outc
return err return err
} }
return s.betStore.UpdateStatus(ctx, id, status) if err := s.betStore.UpdateStatus(ctx, betId, status); err != nil {
updateLogger.Error("failed to update bet status",
zap.String("status", status.String()),
zap.Error(err),
)
return err
}
resultNotification.WinningAmount = amount
if err := s.SendWinningStatusNotification(ctx, resultNotification); err != nil {
updateLogger.Error("failed to send winning notification",
zap.Error(err),
)
return err
}
return nil
} }
func (s *Service) SendWinningStatusNotification(ctx context.Context, status domain.OutcomeStatus, userID int64, winningAmount domain.Currency, extra string) error { func newBetResultNotification(userID int64, level domain.NotificationLevel, channel domain.DeliveryChannel, headline, message string, metadata any) *domain.Notification {
raw, _ := json.Marshal(metadata)
return &domain.Notification{
RecipientID: userID,
DeliveryStatus: domain.DeliveryStatusPending,
IsRead: false,
Type: domain.NOTIFICATION_TYPE_BET_RESULT,
Level: level,
Reciever: domain.NotificationRecieverSideCustomer,
DeliveryChannel: channel,
Payload: domain.NotificationPayload{
Headline: headline,
Message: message,
},
Priority: 2,
Metadata: raw,
}
}
type SendResultNotificationParam struct {
BetID int64
Status domain.OutcomeStatus
UserID int64
WinningAmount domain.Currency
Extra string
SendEmail bool
SendSMS bool
}
func (p SendResultNotificationParam) Validate() error {
if p.BetID == 0 {
return errors.New("BetID is required")
}
if p.UserID == 0 {
return errors.New("UserID is required")
}
return nil
}
func shouldSend(channel domain.DeliveryChannel, sendEmail, sendSMS bool) bool {
switch {
case channel == domain.DeliveryChannelEmail && sendEmail:
return true
case channel == domain.DeliveryChannelSMS && sendSMS:
return true
case channel == domain.DeliveryChannelInApp:
return true
default:
return false
}
}
func (s *Service) SendWinningStatusNotification(ctx context.Context, param SendResultNotificationParam) error {
if err := param.Validate(); err != nil {
return err
}
var headline string var headline string
var message string var message string
switch status { switch param.Status {
case domain.OUTCOME_STATUS_WIN: case domain.OUTCOME_STATUS_WIN:
headline = "You Bet Has Won!" headline = fmt.Sprintf("Bet #%v Won!", param.BetID)
message = fmt.Sprintf( message = fmt.Sprintf(
"You have been awarded %.2f", "Congratulations! Your bet #%v has won. %.2f has been credited to your wallet.",
winningAmount.Float32(), param.BetID,
param.WinningAmount.Float32(),
) )
case domain.OUTCOME_STATUS_HALF: case domain.OUTCOME_STATUS_HALF:
headline = "You have a half win" headline = fmt.Sprintf("Bet #%v Half-Win", param.BetID)
message = fmt.Sprintf( message = fmt.Sprintf(
"You have been awarded %.2f", "Your bet #%v resulted in a half-win. %.2f has been credited to your wallet.",
winningAmount.Float32(), param.BetID,
param.WinningAmount.Float32(),
) )
case domain.OUTCOME_STATUS_VOID: case domain.OUTCOME_STATUS_VOID:
headline = "Your bet has been refunded" headline = fmt.Sprintf("Bet #%v Refunded", param.BetID)
message = fmt.Sprintf( message = fmt.Sprintf(
"You have been awarded %.2f", "Your bet #%v has been voided. %.2f has been refunded to your wallet.",
winningAmount.Float32(), param.BetID,
param.WinningAmount.Float32(),
) )
default:
return fmt.Errorf("unsupported status: %v", param.Status)
} }
betNotification := &domain.Notification{ for _, channel := range []domain.DeliveryChannel{
RecipientID: userID, domain.DeliveryChannelInApp,
DeliveryStatus: domain.DeliveryStatusPending, domain.DeliveryChannelEmail,
IsRead: false, domain.DeliveryChannelSMS,
Type: domain.NOTIFICATION_TYPE_BET_RESULT, } {
Level: domain.NotificationLevelSuccess, if !shouldSend(channel, param.SendEmail, param.SendSMS) {
Reciever: domain.NotificationRecieverSideCustomer, continue
DeliveryChannel: domain.DeliveryChannelInApp,
Payload: domain.NotificationPayload{
Headline: headline,
Message: message,
},
Priority: 2,
Metadata: fmt.Appendf(nil, `{
"winning_amount":%.2f,
"status":%v
"more": %v
}`, winningAmount.Float32(), status, extra),
} }
n := newBetResultNotification(param.UserID, domain.NotificationLevelSuccess, channel, headline, message, map[string]any{
if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { "winning_amount": param.WinningAmount.Float32(),
"status": param.Status,
"more": param.Extra,
})
if err := s.notificationSvc.SendNotification(ctx, n); err != nil {
return err return err
} }
betNotification.DeliveryChannel = domain.DeliveryChannelEmail
if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil {
return err
} }
return nil return nil
} }
func (s *Service) SendLosingStatusNotification(ctx context.Context, status domain.OutcomeStatus, userID int64, extra string) error { func (s *Service) SendLosingStatusNotification(ctx context.Context, param SendResultNotificationParam) error {
if err := param.Validate(); err != nil {
return err
}
var headline string var headline string
var message string var message string
switch status { switch param.Status {
case domain.OUTCOME_STATUS_LOSS: case domain.OUTCOME_STATUS_LOSS:
headline = "Your bet has lost" headline = fmt.Sprintf("Bet #%v Lost", param.BetID)
message = "Better luck next time" message = "Unfortunately, your bet did not win this time. Better luck next time!"
default:
return fmt.Errorf("unsupported status: %v", param.Status)
} }
betNotification := &domain.Notification{ for _, channel := range []domain.DeliveryChannel{
RecipientID: userID, domain.DeliveryChannelInApp,
DeliveryStatus: domain.DeliveryStatusPending, domain.DeliveryChannelEmail,
IsRead: false, domain.DeliveryChannelSMS,
Type: domain.NOTIFICATION_TYPE_BET_RESULT, } {
Level: domain.NotificationLevelSuccess, if !shouldSend(channel, param.SendEmail, param.SendSMS) {
Reciever: domain.NotificationRecieverSideCustomer, continue
DeliveryChannel: domain.DeliveryChannelInApp,
Payload: domain.NotificationPayload{
Headline: headline,
Message: message,
},
Priority: 2,
Metadata: fmt.Appendf(nil, `{
"status":%v
"more": %v
}`, status, extra),
} }
n := newBetResultNotification(param.UserID, domain.NotificationLevelWarning, channel, headline, message, map[string]any{
if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { "status": param.Status,
"more": param.Extra,
})
if err := s.notificationSvc.SendNotification(ctx, n); err != nil {
return err return err
} }
betNotification.DeliveryChannel = domain.DeliveryChannelEmail
if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil {
return err
} }
return nil return nil
} }
func (s *Service) SendErrorStatusNotification(ctx context.Context, status domain.OutcomeStatus, userID int64, extra string) error { func (s *Service) SendErrorStatusNotification(ctx context.Context, betID int64, status domain.OutcomeStatus, userID int64, extra string) error {
var headline string var headline string
var message string var message string
switch status { switch status {
case domain.OUTCOME_STATUS_ERROR, domain.OUTCOME_STATUS_PENDING: case domain.OUTCOME_STATUS_ERROR, domain.OUTCOME_STATUS_PENDING:
headline = "There was an error with your bet" headline = fmt.Sprintf("Bet #%v Processing Issue", betID)
message = "We have encounter an error with your bet. We will fix it as soon as we can" message = "We encountered a problem while processing your bet. Our team is working to resolve it as soon as possible."
default:
return fmt.Errorf("unsupported status: %v", status)
} }
betNotification := &domain.Notification{ for _, channel := range []domain.DeliveryChannel{
RecipientID: userID, domain.DeliveryChannelInApp,
DeliveryStatus: domain.DeliveryStatusPending, domain.DeliveryChannelEmail,
IsRead: false, } {
Type: domain.NOTIFICATION_TYPE_BET_RESULT, n := newBetResultNotification(userID, domain.NotificationLevelError, channel, headline, message, map[string]any{
Level: domain.NotificationLevelSuccess, "status": status,
Reciever: domain.NotificationRecieverSideCustomer, "more": extra,
DeliveryChannel: domain.DeliveryChannelInApp, })
Payload: domain.NotificationPayload{ if err := s.notificationSvc.SendNotification(ctx, n); err != nil {
Headline: headline,
Message: message,
},
Priority: 1,
ErrorSeverity: domain.NotificationErrorSeverityHigh,
Metadata: fmt.Appendf(nil, `{
"status":%v
"more": %v
}`, status, extra),
}
if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil {
return err return err
} }
betNotification.DeliveryChannel = domain.DeliveryChannelEmail
if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil {
return err
} }
return nil return nil
} }
func (s *Service) SendAdminErrorAlertNotification(ctx context.Context, status domain.OutcomeStatus, extra string) error { func (s *Service) SendAdminAlertNotification(ctx context.Context, betID int64, status domain.OutcomeStatus, extra string, companyID int64) error {
var headline string var headline string
var message string var message string
switch status { switch status {
case domain.OUTCOME_STATUS_ERROR, domain.OUTCOME_STATUS_PENDING: case domain.OUTCOME_STATUS_ERROR, domain.OUTCOME_STATUS_PENDING:
headline = "There was an error processing bet" headline = fmt.Sprintf("Processing Error for Bet #%v", betID)
message = "We have encounter an error with bet. We will fix it as soon as we can" message = "A processing error occurred with this bet. Please review and take corrective action."
}
betNotification := &domain.Notification{ default:
ErrorSeverity: domain.NotificationErrorSeverityHigh, return fmt.Errorf("unsupported status: %v", status)
DeliveryStatus: domain.DeliveryStatusPending,
IsRead: false,
Type: domain.NOTIFICATION_TYPE_BET_RESULT,
Level: domain.NotificationLevelSuccess,
Reciever: domain.NotificationRecieverSideCustomer,
DeliveryChannel: domain.DeliveryChannelEmail,
Payload: domain.NotificationPayload{
Headline: headline,
Message: message,
},
Priority: 2,
Metadata: fmt.Appendf(nil, `{
"status":%v
"more": %v
}`, status, extra),
} }
super_admin_users, _, err := s.userSvc.GetAllUsers(ctx, domain.UserFilter{ super_admin_users, _, err := s.userSvc.GetAllUsers(ctx, domain.UserFilter{
@ -1153,6 +1158,10 @@ func (s *Service) SendAdminErrorAlertNotification(ctx context.Context, status do
admin_users, _, err := s.userSvc.GetAllUsers(ctx, domain.UserFilter{ admin_users, _, err := s.userSvc.GetAllUsers(ctx, domain.UserFilter{
Role: string(domain.RoleAdmin), Role: string(domain.RoleAdmin),
CompanyID: domain.ValidInt64{
Value: companyID,
Valid: true,
},
}) })
if err != nil { if err != nil {
@ -1166,23 +1175,17 @@ func (s *Service) SendAdminErrorAlertNotification(ctx context.Context, status do
users := append(super_admin_users, admin_users...) users := append(super_admin_users, admin_users...)
for _, user := range users { for _, user := range users {
betNotification.RecipientID = user.ID for _, channel := range []domain.DeliveryChannel{
if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { domain.DeliveryChannelInApp,
s.mongoLogger.Error("failed to send admin notification", domain.DeliveryChannelEmail,
zap.Int64("admin_id", user.ID), } {
zap.Error(err), n := newBetResultNotification(user.ID, domain.NotificationLevelError, channel, headline, message, map[string]any{
zap.Time("timestamp", time.Now()), "status": status,
) "more": extra,
})
if err := s.notificationSvc.SendNotification(ctx, n); err != nil {
return err return err
} }
betNotification.DeliveryChannel = domain.DeliveryChannelEmail
if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil {
s.mongoLogger.Error("failed to send email admin notification",
zap.Int64("admin_id", user.ID),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return err
} }
} }
@ -1366,6 +1369,14 @@ func (s *Service) ProcessBetCashback(ctx context.Context) error {
} }
settingsList, err := s.settingSvc.GetOverrideSettingsList(ctx, bet.CompanyID) settingsList, err := s.settingSvc.GetOverrideSettingsList(ctx, bet.CompanyID)
if err != nil {
s.mongoLogger.Error("Failed to get settings",
zap.Int64("userID", bet.UserID),
zap.Error(err))
return err
}
cashbackAmount := math.Min(float64(settingsList.CashbackAmountCap.Float32()), float64(calculateCashbackAmount(bet.Amount.Float32(), bet.TotalOdds))) cashbackAmount := math.Min(float64(settingsList.CashbackAmountCap.Float32()), float64(calculateCashbackAmount(bet.Amount.Float32(), bet.TotalOdds)))
_, err = s.walletSvc.AddToWallet(ctx, wallets.StaticID, domain.ToCurrency(float32(cashbackAmount)), domain.ValidInt64{}, domain.TRANSFER_DIRECT, _, err = s.walletSvc.AddToWallet(ctx, wallets.StaticID, domain.ToCurrency(float32(cashbackAmount)), domain.ValidInt64{}, domain.TRANSFER_DIRECT,

View File

@ -3,12 +3,15 @@ package bonus
import ( import (
"context" "context"
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
) )
type BonusStore interface { type BonusStore interface {
CreateBonusMultiplier(ctx context.Context, multiplier float32, balance_cap int64) error CreateUserBonus(ctx context.Context, bonus domain.CreateBonus) (domain.UserBonus, error)
GetBonusMultiplier(ctx context.Context) ([]dbgen.GetBonusMultiplierRow, error) GetAllUserBonuses(ctx context.Context) ([]domain.UserBonus, error)
GetBonusBalanceCap(ctx context.Context) ([]dbgen.GetBonusBalanceCapRow, error) GetBonusesByUserID(ctx context.Context, userID int64) ([]domain.UserBonus, error)
UpdateBonusMultiplier(ctx context.Context, id int64, mulitplier float32, balance_cap int64) error GetBonusByID(ctx context.Context, bonusID int64) (domain.UserBonus, error)
GetBonusStats(ctx context.Context, filter domain.BonusFilter) (domain.BonusStats, error)
UpdateUserBonus(ctx context.Context, bonusID int64, IsClaimed bool) error
DeleteUserBonus(ctx context.Context, bonusID int64) error
} }

View File

@ -2,32 +2,112 @@ package bonus
import ( import (
"context" "context"
"errors"
"math"
"time"
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/pkgs/helpers"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/settings"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
"go.uber.org/zap"
) )
type Service struct { type Service struct {
bonusStore BonusStore bonusStore BonusStore
walletSvc *wallet.Service
settingSvc *settings.Service
mongoLogger *zap.Logger
} }
func NewService(bonusStore BonusStore) *Service { func NewService(bonusStore BonusStore, walletSvc *wallet.Service, settingSvc *settings.Service, mongoLogger *zap.Logger) *Service {
return &Service{ return &Service{
bonusStore: bonusStore, bonusStore: bonusStore,
walletSvc: walletSvc,
settingSvc: settingSvc,
mongoLogger: mongoLogger,
} }
} }
func (s *Service) CreateBonusMultiplier(ctx context.Context, multiplier float32, balance_cap int64) error { var (
return s.bonusStore.CreateBonusMultiplier(ctx, multiplier, balance_cap) ErrWelcomeBonusNotActive = errors.New("welcome bonus is not active")
ErrWelcomeBonusCountReached = errors.New("welcome bonus max deposit count reached")
)
func (s *Service) ProcessWelcomeBonus(ctx context.Context, amount domain.Currency, companyID int64, userID int64) error {
settingsList, err := s.settingSvc.GetOverrideSettingsList(ctx, companyID)
if err != nil {
s.mongoLogger.Error("Failed to get settings",
zap.Int64("companyID", companyID),
zap.Error(err))
return err
}
if !settingsList.WelcomeBonusActive {
return ErrWelcomeBonusNotActive
}
wallet, err := s.walletSvc.GetCustomerWallet(ctx, userID)
if err != nil {
return err
}
stats, err := s.walletSvc.GetTransferStats(ctx, wallet.ID)
if err != nil {
return err
}
if stats.TotalDeposits > settingsList.WelcomeBonusCount {
return ErrWelcomeBonusCountReached
}
newBalance := math.Min(float64(amount)*float64(settingsList.WelcomeBonusMultiplier), float64(settingsList.WelcomeBonusCap))
_, err = s.CreateUserBonus(ctx, domain.CreateBonus{
Name: "Welcome Bonus",
Description: "Awarded when the user logged in for the first time",
UserID: userID,
BonusCode: helpers.GenerateFastCode(),
RewardAmount: domain.Currency(newBalance),
ExpiresAt: time.Now().Add(time.Duration(settingsList.WelcomeBonusExpire) * 24 * time.Hour),
})
if err != nil {
return err
}
// TODO: Add a claim function that adds to the static wallet when the user inputs his bonus code
// _, err = s.walletSvc.AddToWallet(ctx, wallet.StaticID, domain.ToCurrency(float32(newBalance)), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{},
// fmt.Sprintf("Added %v to static wallet because of deposit bonus using multiplier %v", newBalance, settingsList.WelcomeBonusMultiplier),
// )
// if err != nil {
// return err
// }
return nil
} }
func (s *Service) GetBonusMultiplier(ctx context.Context) ([]dbgen.GetBonusMultiplierRow, error) { func (s *Service) CreateUserBonus(ctx context.Context, bonus domain.CreateBonus) (domain.UserBonus, error) {
return s.bonusStore.GetBonusMultiplier(ctx) return s.bonusStore.CreateUserBonus(ctx, bonus)
} }
func (s *Service) GetAllUserBonuses(ctx context.Context) ([]domain.UserBonus, error) {
func (s *Service) GetBonusBalanceCap(ctx context.Context) ([]dbgen.GetBonusBalanceCapRow, error) { return s.bonusStore.GetAllUserBonuses(ctx)
return s.bonusStore.GetBonusBalanceCap(ctx)
} }
func (s *Service) GetBonusesByUserID(ctx context.Context, userID int64) ([]domain.UserBonus, error) {
func (s *Service) UpdateBonusMultiplier(ctx context.Context, id int64, mulitplier float32, balance_cap int64) error { return s.bonusStore.GetBonusesByUserID(ctx, userID)
return s.bonusStore.UpdateBonusMultiplier(ctx, id, mulitplier, balance_cap) }
func (s *Service) GetBonusByID(ctx context.Context, bonusID int64) (domain.UserBonus, error) {
return s.bonusStore.GetBonusByID(ctx, bonusID)
}
func (s *Service) GetBonusStats(ctx context.Context, filter domain.BonusFilter) (domain.BonusStats, error) {
return s.bonusStore.GetBonusStats(ctx, filter)
}
func (s *Service) UpdateUserBonus(ctx context.Context, bonusID int64, IsClaimed bool) error {
return s.bonusStore.UpdateUserBonus(ctx, bonusID, IsClaimed)
}
func (s *Service) DeleteUserBonus(ctx context.Context, bonusID int64) error {
return s.bonusStore.DeleteUserBonus(ctx, bonusID)
} }

View File

@ -30,6 +30,7 @@ type TransferStore interface {
GetTransfersByWallet(ctx context.Context, walletID int64) ([]domain.TransferDetail, error) GetTransfersByWallet(ctx context.Context, walletID int64) ([]domain.TransferDetail, error)
GetTransferByReference(ctx context.Context, reference string) (domain.TransferDetail, error) GetTransferByReference(ctx context.Context, reference string) (domain.TransferDetail, error)
GetTransferByID(ctx context.Context, id int64) (domain.TransferDetail, error) GetTransferByID(ctx context.Context, id int64) (domain.TransferDetail, error)
GetTransferStats(ctx context.Context, walletID int64) (domain.TransferStats, error)
UpdateTransferVerification(ctx context.Context, id int64, verified bool) error UpdateTransferVerification(ctx context.Context, id int64, verified bool) error
UpdateTransferStatus(ctx context.Context, id int64, status string) error UpdateTransferStatus(ctx context.Context, id int64, status string) error
// InitiateTransfer(ctx context.Context, transfer domain.CreateTransfer) (domain.Transfer, error) // InitiateTransfer(ctx context.Context, transfer domain.CreateTransfer) (domain.Transfer, error)

View File

@ -35,6 +35,9 @@ func (s *Service) GetTransfersByWallet(ctx context.Context, walletID int64) ([]d
return s.transferStore.GetTransfersByWallet(ctx, walletID) return s.transferStore.GetTransfersByWallet(ctx, walletID)
} }
func (s *Service) GetTransferStats(ctx context.Context, walletID int64) (domain.TransferStats, error) {
return s.transferStore.GetTransferStats(ctx, walletID)
}
func (s *Service) UpdateTransferVerification(ctx context.Context, id int64, verified bool) error { func (s *Service) UpdateTransferVerification(ctx context.Context, id int64, verified bool) error {
return s.transferStore.UpdateTransferVerification(ctx, id, verified) return s.transferStore.UpdateTransferVerification(ctx, id, verified)
} }

View File

@ -215,6 +215,9 @@ func (s *Service) DeductFromWallet(ctx context.Context, id int64, amount domain.
return newTransfer, err return newTransfer, err
} }
// Directly Refilling wallet without // Directly Refilling wallet without
// func (s *Service) RefillWallet(ctx context.Context, transfer domain.CreateTransfer) (domain.Transfer, error) { // func (s *Service) RefillWallet(ctx context.Context, transfer domain.CreateTransfer) (domain.Transfer, error) {
// receiverWallet, err := s.GetWalletByID(ctx, transfer.ReceiverWalletID) // receiverWallet, err := s.GetWalletByID(ctx, transfer.ReceiverWalletID)

View File

@ -1,98 +1,98 @@
package handlers package handlers
import ( // import (
"time" // "time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" // "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response"
"github.com/gofiber/fiber/v2" // "github.com/gofiber/fiber/v2"
"go.uber.org/zap" // "go.uber.org/zap"
) // )
func (h *Handler) CreateBonusMultiplier(c *fiber.Ctx) error { // func (h *Handler) CreateBonusMultiplier(c *fiber.Ctx) error {
var req struct { // var req struct {
Multiplier float32 `json:"multiplier"` // Multiplier float32 `json:"multiplier"`
BalanceCap int64 `json:"balance_cap"` // BalanceCap int64 `json:"balance_cap"`
} // }
if err := c.BodyParser(&req); err != nil { // if err := c.BodyParser(&req); err != nil {
h.logger.Error("failed to parse bonus multiplier request", "error", err) // h.logger.Error("failed to parse bonus multiplier request", "error", err)
h.mongoLoggerSvc.Info("failed to parse bonus multiplier", // h.mongoLoggerSvc.Info("failed to parse bonus multiplier",
zap.Int("status_code", fiber.StatusBadRequest), // zap.Int("status_code", fiber.StatusBadRequest),
zap.Error(err), // zap.Error(err),
zap.Time("timestamp", time.Now()), // zap.Time("timestamp", time.Now()),
) // )
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body:"+err.Error()) // return fiber.NewError(fiber.StatusBadRequest, "Invalid request body:"+err.Error())
} // }
// currently only one multiplier is allowed // // currently only one multiplier is allowed
// we can add an active bool in the db and have mulitple bonus if needed // // we can add an active bool in the db and have mulitple bonus if needed
multipliers, err := h.bonusSvc.GetBonusMultiplier(c.Context()) // multipliers, err := h.bonusSvc.GetBonusMultiplier(c.Context())
if err != nil { // if err != nil {
h.logger.Error("failed to get bonus multiplier", "error", err) // h.logger.Error("failed to get bonus multiplier", "error", err)
h.mongoLoggerSvc.Info("Failed to get bonus multiplier", // h.mongoLoggerSvc.Info("Failed to get bonus multiplier",
zap.Int("status_code", fiber.StatusBadRequest), // zap.Int("status_code", fiber.StatusBadRequest),
zap.Error(err), // zap.Error(err),
zap.Time("timestamp", time.Now()), // zap.Time("timestamp", time.Now()),
) // )
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body:"+err.Error()) // return fiber.NewError(fiber.StatusBadRequest, "Invalid request body:"+err.Error())
} // }
if len(multipliers) > 0 { // if len(multipliers) > 0 {
return fiber.NewError(fiber.StatusBadRequest, "only one multiplier is allowed") // return fiber.NewError(fiber.StatusBadRequest, "only one multiplier is allowed")
} // }
if err := h.bonusSvc.CreateBonusMultiplier(c.Context(), req.Multiplier, req.BalanceCap); err != nil { // if err := h.bonusSvc.CreateBonusMultiplier(c.Context(), req.Multiplier, req.BalanceCap); err != nil {
h.mongoLoggerSvc.Error("failed to create bonus multiplier", // h.mongoLoggerSvc.Error("failed to create bonus multiplier",
zap.Int("status_code", fiber.StatusInternalServerError), // zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err), // zap.Error(err),
zap.Time("timestamp", time.Now()), // zap.Time("timestamp", time.Now()),
) // )
return fiber.NewError(fiber.StatusInternalServerError, "failed to create bonus multiplier"+err.Error()) // return fiber.NewError(fiber.StatusInternalServerError, "failed to create bonus multiplier"+err.Error())
} // }
return response.WriteJSON(c, fiber.StatusOK, "Create bonus multiplier successfully", nil, nil) // return response.WriteJSON(c, fiber.StatusOK, "Create bonus multiplier successfully", nil, nil)
} // }
func (h *Handler) GetBonusMultiplier(c *fiber.Ctx) error { // func (h *Handler) GetBonusMultiplier(c *fiber.Ctx) error {
multipliers, err := h.bonusSvc.GetBonusMultiplier(c.Context()) // multipliers, err := h.bonusSvc.GetBonusMultiplier(c.Context())
if err != nil { // if err != nil {
h.mongoLoggerSvc.Info("failed to get bonus multiplier", // h.mongoLoggerSvc.Info("failed to get bonus multiplier",
zap.Int("status_code", fiber.StatusBadRequest), // zap.Int("status_code", fiber.StatusBadRequest),
zap.Error(err), // zap.Error(err),
zap.Time("timestamp", time.Now()), // zap.Time("timestamp", time.Now()),
) // )
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body"+err.Error()) // return fiber.NewError(fiber.StatusBadRequest, "Invalid request body"+err.Error())
} // }
return response.WriteJSON(c, fiber.StatusOK, "Fetched bonus multiplier successfully", multipliers, nil) // return response.WriteJSON(c, fiber.StatusOK, "Fetched bonus multiplier successfully", multipliers, nil)
} // }
func (h *Handler) UpdateBonusMultiplier(c *fiber.Ctx) error { // func (h *Handler) UpdateBonusMultiplier(c *fiber.Ctx) error {
var req struct { // var req struct {
ID int64 `json:"id"` // ID int64 `json:"id"`
Multiplier float32 `json:"multiplier"` // Multiplier float32 `json:"multiplier"`
BalanceCap int64 `json:"balance_cap"` // BalanceCap int64 `json:"balance_cap"`
} // }
if err := c.BodyParser(&req); err != nil { // if err := c.BodyParser(&req); err != nil {
h.mongoLoggerSvc.Info("failed to parse bonus multiplier", // h.mongoLoggerSvc.Info("failed to parse bonus multiplier",
zap.Int("status_code", fiber.StatusBadRequest), // zap.Int("status_code", fiber.StatusBadRequest),
zap.Error(err), // zap.Error(err),
zap.Time("timestamp", time.Now()), // zap.Time("timestamp", time.Now()),
) // )
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body:"+err.Error()) // return fiber.NewError(fiber.StatusBadRequest, "Invalid request body:"+err.Error())
} // }
if err := h.bonusSvc.UpdateBonusMultiplier(c.Context(), req.ID, req.Multiplier, req.BalanceCap); err != nil { // if err := h.bonusSvc.UpdateBonusMultiplier(c.Context(), req.ID, req.Multiplier, req.BalanceCap); err != nil {
h.logger.Error("failed to update bonus multiplier", "error", err) // h.logger.Error("failed to update bonus multiplier", "error", err)
h.mongoLoggerSvc.Error("failed to update bonus multiplier", // h.mongoLoggerSvc.Error("failed to update bonus multiplier",
zap.Int64("id", req.ID), // zap.Int64("id", req.ID),
zap.Int("status_code", fiber.StatusInternalServerError), // zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err), // zap.Error(err),
zap.Time("timestamp", time.Now()), // zap.Time("timestamp", time.Now()),
) // )
return fiber.NewError(fiber.StatusInternalServerError, "failed to update bonus multiplier:"+err.Error()) // return fiber.NewError(fiber.StatusInternalServerError, "failed to update bonus multiplier:"+err.Error())
} // }
return response.WriteJSON(c, fiber.StatusOK, "Updated bonus multiplier successfully", nil, nil) // return response.WriteJSON(c, fiber.StatusOK, "Updated bonus multiplier successfully", nil, nil)
} // }

View File

@ -2,7 +2,6 @@ package handlers
import ( import (
"fmt" "fmt"
"math"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@ -53,36 +52,39 @@ func (h *Handler) InitiateDeposit(c *fiber.Ctx) error {
} }
// get static wallet of user // get static wallet of user
wallet, err := h.walletSvc.GetCustomerWallet(c.Context(), userID) // wallet, err := h.walletSvc.GetCustomerWallet(c.Context(), userID)
if err != nil { // if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ // return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(), // Error: err.Error(),
Message: "Failed to initiate Chapa deposit", // Message: "Failed to initiate Chapa deposit",
}) // })
} // }
var multiplier float32 = 1 // var multiplier float32 = 1
bonusMultiplier, err := h.bonusSvc.GetBonusMultiplier(c.Context()) // bonusMultiplier, err := h.bonusSvc.GetBonusMultiplier(c.Context())
if err == nil { // if err == nil {
multiplier = bonusMultiplier[0].Multiplier // multiplier = bonusMultiplier[0].Multiplier
} // }
var balanceCap int64 = 0 // var balanceCap int64 = 0
bonusBalanceCap, err := h.bonusSvc.GetBonusBalanceCap(c.Context()) // bonusBalanceCap, err := h.bonusSvc.GetBonusBalanceCap(c.Context())
if err == nil { // if err == nil {
balanceCap = bonusBalanceCap[0].BalanceCap // balanceCap = bonusBalanceCap[0].BalanceCap
} // }
capedBalanceAmount := domain.Currency((math.Min(req.Amount, float64(balanceCap)) * float64(multiplier)) * 100) // capedBalanceAmount := domain.Currency((math.Min(req.Amount, float64(balanceCap)) * float64(multiplier)) * 100)
_, err = h.walletSvc.AddToWallet(c.Context(), wallet.StaticID, capedBalanceAmount, domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, // _, err = h.walletSvc.AddToWallet(c.Context(), wallet.StaticID, capedBalanceAmount, domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{},
fmt.Sprintf("Added %v to static wallet because of deposit bonus using multiplier %v", capedBalanceAmount, multiplier), // fmt.Sprintf("Added %v to static wallet because of deposit bonus using multiplier %v", capedBalanceAmount, multiplier),
) // )
if err != nil { // if err != nil {
h.logger.Error("Failed to add bonus to static wallet", "walletID", wallet.StaticID, "user id", userID, "error", err) // h.logger.Error("Failed to add bonus to static wallet", "walletID", wallet.StaticID, "user id", userID, "error", err)
return err // return err
} // }
// if err := h.bonusSvc.ProcessWelcomeBonus(c.Context(), domain.ToCurrency(float32(req.Amount)), 0, userID); err != nil {
// return err
// }
return c.Status(fiber.StatusOK).JSON(domain.Response{ return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Chapa deposit process initiated successfully", Message: "Chapa deposit process initiated successfully",
Data: checkoutURL, Data: checkoutURL,

View File

@ -206,9 +206,9 @@ func (a *App) initAppRoutes() {
a.fiber.Get("/raffle-ticket/unsuspend/:id", a.authMiddleware, h.UnSuspendRaffleTicket) a.fiber.Get("/raffle-ticket/unsuspend/:id", a.authMiddleware, h.UnSuspendRaffleTicket)
// Bonus Routes // Bonus Routes
groupV1.Get("/bonus", a.authMiddleware, h.GetBonusMultiplier) // groupV1.Get("/bonus", a.authMiddleware, h.GetBonusMultiplier)
groupV1.Post("/bonus/create", a.authMiddleware, h.CreateBonusMultiplier) // groupV1.Post("/bonus/create", a.authMiddleware, h.CreateBonusMultiplier)
groupV1.Put("/bonus/update", a.authMiddleware, h.UpdateBonusMultiplier) // groupV1.Put("/bonus/update", a.authMiddleware, h.UpdateBonusMultiplier)
groupV1.Get("/cashiers", a.authMiddleware, h.GetAllCashiers) groupV1.Get("/cashiers", a.authMiddleware, h.GetAllCashiers)
groupV1.Get("/cashiers/:id", a.authMiddleware, h.GetCashierByID) groupV1.Get("/cashiers/:id", a.authMiddleware, h.GetCashierByID)