From 02e6f6ee6f716a8698a64f17cf6b8ac00ad5aae0 Mon Sep 17 00:00:00 2001 From: dawitel Date: Sat, 12 Apr 2025 10:06:56 +0300 Subject: [PATCH] feat: popok itegratio --- README.md | 1 + cmd/main.go | 5 +- .../000004_virtual_game_Sessios.down.sql | 3 + .../000004_virtual_game_Sessios.up.sql | 29 +++ db/query/virtual_games.sql | 33 ++++ gen/db/models.go | 26 +++ gen/db/referal.sql.go | 4 +- gen/db/virtual_games.sql.go | 180 ++++++++++++++++++ internal/config/config.go | 49 ++++- internal/domain/virtual_game.go | 55 ++++++ internal/repository/referal.go | 4 +- internal/repository/virtual_game.go | 114 +++++++++++ internal/services/virtualGame/port.go | 12 ++ internal/services/virtualGame/service.go | 169 ++++++++++++++++ internal/web_server/app.go | 4 + internal/web_server/handlers/handlers.go | 5 +- .../handlers/virtual_games_hadlers.go | 83 ++++++++ internal/web_server/jwt/jwt.go | 76 ++++++-- internal/web_server/routes.go | 5 + 19 files changed, 831 insertions(+), 26 deletions(-) create mode 100644 db/migrations/000004_virtual_game_Sessios.down.sql create mode 100644 db/migrations/000004_virtual_game_Sessios.up.sql create mode 100644 db/query/virtual_games.sql create mode 100644 gen/db/virtual_games.sql.go create mode 100644 internal/domain/virtual_game.go create mode 100644 internal/repository/virtual_game.go create mode 100644 internal/services/virtualGame/port.go create mode 100644 internal/services/virtualGame/service.go create mode 100644 internal/web_server/handlers/virtual_games_hadlers.go diff --git a/README.md b/README.md index 7f428e7..2f525d1 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ │ ├── bet_handler.go │ ├── handlers.go │ ├── notification_handler.go + │ ├── referal_handlers.go │ ├── ticket_handler.go │ ├── transaction_handler.go │ ├── user.go diff --git a/cmd/main.go b/cmd/main.go index 88a3b8e..28648bd 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,6 +17,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" + virtualgameservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" @@ -65,14 +66,16 @@ func main() { notificationRepo := repository.NewNotificationRepository(store) referalRepo := repository.NewReferralRepository(store) + vitualGameRepo := repository.NewVirtualGameRepository(store) notificationSvc := notificationservice.New(notificationRepo, logger, cfg) referalSvc := referralservice.New(referalRepo, *walletSvc, store, cfg, logger) + virtualGameSvc := virtualgameservice.New(vitualGameRepo, *walletSvc, store, cfg, logger) app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{ JwtAccessKey: cfg.JwtKey, JwtAccessExpiry: cfg.AccessExpiry, - }, userSvc, ticketSvc, betSvc, walletSvc, transactionSvc, notificationSvc, referalSvc) + }, userSvc, ticketSvc, betSvc, walletSvc, transactionSvc, notificationSvc, referalSvc, virtualGameSvc) logger.Info("Starting server", "port", cfg.Port) if err := app.Run(); err != nil { diff --git a/db/migrations/000004_virtual_game_Sessios.down.sql b/db/migrations/000004_virtual_game_Sessios.down.sql new file mode 100644 index 0000000..58a1d58 --- /dev/null +++ b/db/migrations/000004_virtual_game_Sessios.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS virtual_game_transactions; + +DROP TABLE IF EXISTS virtual_game_sessions; diff --git a/db/migrations/000004_virtual_game_Sessios.up.sql b/db/migrations/000004_virtual_game_Sessios.up.sql new file mode 100644 index 0000000..4907d9a --- /dev/null +++ b/db/migrations/000004_virtual_game_Sessios.up.sql @@ -0,0 +1,29 @@ +CREATE TABLE virtual_game_sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + game_id VARCHAR(50) NOT NULL, + session_token VARCHAR(255) NOT NULL UNIQUE, + currency VARCHAR(3) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE, COMPLETED, FAILED + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE TABLE virtual_game_transactions ( + id BIGSERIAL PRIMARY KEY, + session_id BIGINT NOT NULL REFERENCES virtual_game_sessions(id), + user_id BIGINT NOT NULL REFERENCES users(id), + wallet_id BIGINT NOT NULL REFERENCES wallets(id), + transaction_type VARCHAR(20) NOT NULL, + amount BIGINT NOT NULL, + currency VARCHAR(3) NOT NULL, + external_transaction_id VARCHAR(100) NOT NULL UNIQUE, -- PopOK transaction ID + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, COMPLETED, FAILED + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_virtual_game_sessions_user_id ON virtual_game_sessions(user_id); +CREATE INDEX idx_virtual_game_transactions_session_id ON virtual_game_transactions(session_id); +CREATE INDEX idx_virtual_game_transactions_user_id ON virtual_game_transactions(user_id); diff --git a/db/query/virtual_games.sql b/db/query/virtual_games.sql new file mode 100644 index 0000000..e04a24e --- /dev/null +++ b/db/query/virtual_games.sql @@ -0,0 +1,33 @@ +-- name: CreateVirtualGameSession :one +INSERT INTO virtual_game_sessions ( + user_id, game_id, session_token, currency, status, expires_at +) VALUES ( + $1, $2, $3, $4, $5, $6 +) RETURNING id, user_id, game_id, session_token, currency, status, created_at, updated_at, expires_at; + +-- name: GetVirtualGameSessionByToken :one +SELECT id, user_id, game_id, session_token, currency, status, created_at, updated_at, expires_at +FROM virtual_game_sessions +WHERE session_token = $1; + +-- name: UpdateVirtualGameSessionStatus :exec +UPDATE virtual_game_sessions +SET status = $2, updated_at = CURRENT_TIMESTAMP +WHERE id = $1; + +-- name: CreateVirtualGameTransaction :one +INSERT INTO virtual_game_transactions ( + session_id, user_id, wallet_id, transaction_type, amount, currency, external_transaction_id, status +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 +) RETURNING id, session_id, user_id, wallet_id, transaction_type, amount, currency, external_transaction_id, status, created_at, updated_at; + +-- name: GetVirtualGameTransactionByExternalID :one +SELECT id, session_id, user_id, wallet_id, transaction_type, amount, currency, external_transaction_id, status, created_at, updated_at +FROM virtual_game_transactions +WHERE external_transaction_id = $1; + +-- name: UpdateVirtualGameTransactionStatus :exec +UPDATE virtual_game_transactions +SET status = $2, updated_at = CURRENT_TIMESTAMP +WHERE id = $1; diff --git a/gen/db/models.go b/gen/db/models.go index 63f96fe..37cb284 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -189,6 +189,32 @@ type User struct { ReferredBy pgtype.Text } +type VirtualGameSession struct { + ID int64 + UserID int64 + GameID string + SessionToken string + Currency string + Status string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + ExpiresAt pgtype.Timestamptz +} + +type VirtualGameTransaction struct { + ID int64 + SessionID int64 + UserID int64 + WalletID int64 + TransactionType string + Amount int64 + Currency string + ExternalTransactionID string + Status string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + type Wallet struct { ID int64 Balance int64 diff --git a/gen/db/referal.sql.go b/gen/db/referal.sql.go index 9fcc1f1..7c53bc9 100644 --- a/gen/db/referal.sql.go +++ b/gen/db/referal.sql.go @@ -184,8 +184,8 @@ WHERE referrer_id = $1 type GetReferralStatsRow struct { TotalReferrals int64 CompletedReferrals int64 - TotalRewardEarned float64 - PendingRewards float64 + TotalRewardEarned interface{} + PendingRewards interface{} } func (q *Queries) GetReferralStats(ctx context.Context, referrerID string) (GetReferralStatsRow, error) { diff --git a/gen/db/virtual_games.sql.go b/gen/db/virtual_games.sql.go new file mode 100644 index 0000000..a9c6cd3 --- /dev/null +++ b/gen/db/virtual_games.sql.go @@ -0,0 +1,180 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: virtual_games.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CreateVirtualGameSession = `-- name: CreateVirtualGameSession :one +INSERT INTO virtual_game_sessions ( + user_id, game_id, session_token, currency, status, expires_at +) VALUES ( + $1, $2, $3, $4, $5, $6 +) RETURNING id, user_id, game_id, session_token, currency, status, created_at, updated_at, expires_at +` + +type CreateVirtualGameSessionParams struct { + UserID int64 + GameID string + SessionToken string + Currency string + Status string + ExpiresAt pgtype.Timestamptz +} + +func (q *Queries) CreateVirtualGameSession(ctx context.Context, arg CreateVirtualGameSessionParams) (VirtualGameSession, error) { + row := q.db.QueryRow(ctx, CreateVirtualGameSession, + arg.UserID, + arg.GameID, + arg.SessionToken, + arg.Currency, + arg.Status, + arg.ExpiresAt, + ) + var i VirtualGameSession + err := row.Scan( + &i.ID, + &i.UserID, + &i.GameID, + &i.SessionToken, + &i.Currency, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.ExpiresAt, + ) + return i, err +} + +const CreateVirtualGameTransaction = `-- name: CreateVirtualGameTransaction :one +INSERT INTO virtual_game_transactions ( + session_id, user_id, wallet_id, transaction_type, amount, currency, external_transaction_id, status +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 +) RETURNING id, session_id, user_id, wallet_id, transaction_type, amount, currency, external_transaction_id, status, created_at, updated_at +` + +type CreateVirtualGameTransactionParams struct { + SessionID int64 + UserID int64 + WalletID int64 + TransactionType string + Amount int64 + Currency string + ExternalTransactionID string + Status string +} + +func (q *Queries) CreateVirtualGameTransaction(ctx context.Context, arg CreateVirtualGameTransactionParams) (VirtualGameTransaction, error) { + row := q.db.QueryRow(ctx, CreateVirtualGameTransaction, + arg.SessionID, + arg.UserID, + arg.WalletID, + arg.TransactionType, + arg.Amount, + arg.Currency, + arg.ExternalTransactionID, + arg.Status, + ) + var i VirtualGameTransaction + err := row.Scan( + &i.ID, + &i.SessionID, + &i.UserID, + &i.WalletID, + &i.TransactionType, + &i.Amount, + &i.Currency, + &i.ExternalTransactionID, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetVirtualGameSessionByToken = `-- name: GetVirtualGameSessionByToken :one +SELECT id, user_id, game_id, session_token, currency, status, created_at, updated_at, expires_at +FROM virtual_game_sessions +WHERE session_token = $1 +` + +func (q *Queries) GetVirtualGameSessionByToken(ctx context.Context, sessionToken string) (VirtualGameSession, error) { + row := q.db.QueryRow(ctx, GetVirtualGameSessionByToken, sessionToken) + var i VirtualGameSession + err := row.Scan( + &i.ID, + &i.UserID, + &i.GameID, + &i.SessionToken, + &i.Currency, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.ExpiresAt, + ) + return i, err +} + +const GetVirtualGameTransactionByExternalID = `-- name: GetVirtualGameTransactionByExternalID :one +SELECT id, session_id, user_id, wallet_id, transaction_type, amount, currency, external_transaction_id, status, created_at, updated_at +FROM virtual_game_transactions +WHERE external_transaction_id = $1 +` + +func (q *Queries) GetVirtualGameTransactionByExternalID(ctx context.Context, externalTransactionID string) (VirtualGameTransaction, error) { + row := q.db.QueryRow(ctx, GetVirtualGameTransactionByExternalID, externalTransactionID) + var i VirtualGameTransaction + err := row.Scan( + &i.ID, + &i.SessionID, + &i.UserID, + &i.WalletID, + &i.TransactionType, + &i.Amount, + &i.Currency, + &i.ExternalTransactionID, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const UpdateVirtualGameSessionStatus = `-- name: UpdateVirtualGameSessionStatus :exec +UPDATE virtual_game_sessions +SET status = $2, updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +` + +type UpdateVirtualGameSessionStatusParams struct { + ID int64 + Status string +} + +func (q *Queries) UpdateVirtualGameSessionStatus(ctx context.Context, arg UpdateVirtualGameSessionStatusParams) error { + _, err := q.db.Exec(ctx, UpdateVirtualGameSessionStatus, arg.ID, arg.Status) + return err +} + +const UpdateVirtualGameTransactionStatus = `-- name: UpdateVirtualGameTransactionStatus :exec +UPDATE virtual_game_transactions +SET status = $2, updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +` + +type UpdateVirtualGameTransactionStatusParams struct { + ID int64 + Status string +} + +func (q *Queries) UpdateVirtualGameTransactionStatus(ctx context.Context, arg UpdateVirtualGameTransactionStatusParams) error { + _, err := q.db.Exec(ctx, UpdateVirtualGameTransactionStatus, arg.ID, arg.Status) + return err +} diff --git a/internal/config/config.go b/internal/config/config.go index 84280b7..e041a8b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,20 +6,25 @@ import ( "os" "strconv" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" "github.com/joho/godotenv" ) var ( - ErrInvalidDbUrl = errors.New("db url is invalid") - ErrInvalidPort = errors.New("port number is invalid") - ErrRefreshExpiry = errors.New("refresh token expiry is invalid") - ErrAccessExpiry = errors.New("access token expiry is invalid") - ErrInvalidJwtKey = errors.New("jwt key is invalid") - ErrLogLevel = errors.New("log level not set") - ErrInvalidLevel = errors.New("invalid log level") - ErrInvalidEnv = errors.New("env not set or invalid") - ErrInvalidSMSAPIKey = errors.New("SMS API key is invalid") + ErrInvalidDbUrl = errors.New("db url is invalid") + ErrInvalidPort = errors.New("port number is invalid") + ErrRefreshExpiry = errors.New("refresh token expiry is invalid") + ErrAccessExpiry = errors.New("access token expiry is invalid") + ErrInvalidJwtKey = errors.New("jwt key is invalid") + ErrLogLevel = errors.New("log level not set") + ErrInvalidLevel = errors.New("invalid log level") + ErrInvalidEnv = errors.New("env not set or invalid") + ErrInvalidSMSAPIKey = errors.New("SMS API key is invalid") + ErrInvalidPopOKClientID = errors.New("PopOK client ID is invalid") + ErrInvalidPopOKSecretKey = errors.New("PopOK secret key is invalid") + ErrInvalidPopOKBaseURL = errors.New("PopOK base URL is invalid") + ErrInvalidPopOKCallbackURL = errors.New("PopOK callback URL is invalid") ) type Config struct { @@ -34,6 +39,7 @@ type Config struct { AFRO_SMS_SENDER_NAME string AFRO_SMS_RECEIVER_PHONE_NUMBER string ADRO_SMS_HOST_URL string + PopOK domain.PopOKConfig } func NewConfig() (*Config, error) { @@ -125,6 +131,31 @@ func (c *Config) loadEnv() error { if c.ADRO_SMS_HOST_URL == "" { c.ADRO_SMS_HOST_URL = "https://api.afrosms.com" } + popOKClientID := os.Getenv("POPOK_CLIENT_ID") + if popOKClientID == "" { + return ErrInvalidPopOKClientID + } + popOKSecretKey := os.Getenv("POPOK_SECRET_KEY") + if popOKSecretKey == "" { + return ErrInvalidPopOKSecretKey + } + + popOKBaseURL := os.Getenv("POPOK_BASE_URL") + if popOKBaseURL == "" { + return ErrInvalidPopOKBaseURL + } + + popOKCallbackURL := os.Getenv("POPOK_CALLBACK_URL") + if popOKCallbackURL == "" { + return ErrInvalidPopOKCallbackURL + } + + c.PopOK = domain.PopOKConfig{ + ClientID: popOKClientID, + SecretKey: popOKSecretKey, + BaseURL: popOKBaseURL, + CallbackURL: popOKCallbackURL, + } return nil } diff --git a/internal/domain/virtual_game.go b/internal/domain/virtual_game.go new file mode 100644 index 0000000..8b981af --- /dev/null +++ b/internal/domain/virtual_game.go @@ -0,0 +1,55 @@ +package domain + +import ( + "time" +) + +type VirtualGameSession struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + GameID string `json:"game_id"` + SessionToken string `json:"session_token"` + Currency string `json:"currency"` + Status string `json:"status"` // ACTIVE, COMPLETED, FAILED + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +type VirtualGameTransaction struct { + ID int64 `json:"id"` + SessionID int64 `json:"session_id"` + UserID int64 `json:"user_id"` + WalletID int64 `json:"wallet_id"` + TransactionType string `json:"transaction_type"` // BET, WIN, REFUND, JACKPOT_WIN + Amount int64 `json:"amount"` + Currency string `json:"currency"` + ExternalTransactionID string `json:"external_transaction_id"` + Status string `json:"status"` // PENDING, COMPLETED, FAILED + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CreateVirtualGameSession struct { + UserID int64 + GameID string + Currency string + Mode string // REAL, DEMO +} + +type PopOKConfig struct { + ClientID string + SecretKey string + BaseURL string + CallbackURL string +} + +type PopOKCallback struct { + TransactionID string `json:"transaction_id"` + SessionID string `json:"session_id"` + Type string `json:"type"` // BET, WIN, REFUND, JACKPOT_WIN + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Timestamp int64 `json:"timestamp"` + Signature string `json:"signature"` // HMAC-SHA256 signature for verification +} diff --git a/internal/repository/referal.go b/internal/repository/referal.go index 4320f0d..274acd9 100644 --- a/internal/repository/referal.go +++ b/internal/repository/referal.go @@ -96,8 +96,8 @@ func (r *ReferralRepo) GetReferralStats(ctx context.Context, userID string) (*do return &domain.ReferralStats{ TotalReferrals: int(stats.TotalReferrals), CompletedReferrals: int(stats.CompletedReferrals), - TotalRewardEarned: float64(stats.TotalRewardEarned), - PendingRewards: float64(stats.PendingRewards), + TotalRewardEarned: stats.TotalRewardEarned.(float64), + PendingRewards: stats.PendingRewards.(float64), }, nil } diff --git a/internal/repository/virtual_game.go b/internal/repository/virtual_game.go new file mode 100644 index 0000000..cfa6fee --- /dev/null +++ b/internal/repository/virtual_game.go @@ -0,0 +1,114 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + + dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/jackc/pgx/v5/pgtype" +) + +type VirtualGameRepository interface { + CreateVirtualGameSession(ctx context.Context, session *domain.VirtualGameSession) error + GetVirtualGameSessionByToken(ctx context.Context, token string) (*domain.VirtualGameSession, error) + UpdateVirtualGameSessionStatus(ctx context.Context, id int64, status string) error + CreateVirtualGameTransaction(ctx context.Context, tx *domain.VirtualGameTransaction) error + GetVirtualGameTransactionByExternalID(ctx context.Context, externalID string) (*domain.VirtualGameTransaction, error) + UpdateVirtualGameTransactionStatus(ctx context.Context, id int64, status string) error +} + +type VirtualGameRepo struct { + store *Store +} + +func NewVirtualGameRepository(store *Store) VirtualGameRepository { + return &VirtualGameRepo{store: store} +} + +func (r *VirtualGameRepo) CreateVirtualGameSession(ctx context.Context, session *domain.VirtualGameSession) error { + params := dbgen.CreateVirtualGameSessionParams{ + UserID: session.UserID, + GameID: session.GameID, + SessionToken: session.SessionToken, + Currency: session.Currency, + Status: session.Status, + ExpiresAt: pgtype.Timestamptz{Time: session.ExpiresAt, Valid: true}, + } + _, err := r.store.queries.CreateVirtualGameSession(ctx, params) + return err +} + +func (r *VirtualGameRepo) GetVirtualGameSessionByToken(ctx context.Context, token string) (*domain.VirtualGameSession, error) { + dbSession, err := r.store.queries.GetVirtualGameSessionByToken(ctx, token) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &domain.VirtualGameSession{ + ID: dbSession.ID, + UserID: dbSession.UserID, + GameID: dbSession.GameID, + SessionToken: dbSession.SessionToken, + Currency: dbSession.Currency, + Status: dbSession.Status, + CreatedAt: dbSession.CreatedAt.Time, + UpdatedAt: dbSession.UpdatedAt.Time, + ExpiresAt: dbSession.ExpiresAt.Time, + }, nil +} + +func (r *VirtualGameRepo) UpdateVirtualGameSessionStatus(ctx context.Context, id int64, status string) error { + return r.store.queries.UpdateVirtualGameSessionStatus(ctx, dbgen.UpdateVirtualGameSessionStatusParams{ + ID: id, + Status: status, + }) +} + +func (r *VirtualGameRepo) CreateVirtualGameTransaction(ctx context.Context, tx *domain.VirtualGameTransaction) error { + params := dbgen.CreateVirtualGameTransactionParams{ + SessionID: tx.SessionID, + UserID: tx.UserID, + WalletID: tx.WalletID, + TransactionType: tx.TransactionType, + Amount: tx.Amount, + Currency: tx.Currency, + ExternalTransactionID: tx.ExternalTransactionID, + Status: tx.Status, + } + _, err := r.store.queries.CreateVirtualGameTransaction(ctx, params) + return err +} + +func (r *VirtualGameRepo) GetVirtualGameTransactionByExternalID(ctx context.Context, externalID string) (*domain.VirtualGameTransaction, error) { + dbTx, err := r.store.queries.GetVirtualGameTransactionByExternalID(ctx, externalID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &domain.VirtualGameTransaction{ + ID: dbTx.ID, + SessionID: dbTx.SessionID, + UserID: dbTx.UserID, + WalletID: dbTx.WalletID, + TransactionType: dbTx.TransactionType, + Amount: dbTx.Amount, + Currency: dbTx.Currency, + ExternalTransactionID: dbTx.ExternalTransactionID, + Status: dbTx.Status, + CreatedAt: dbTx.CreatedAt.Time, + UpdatedAt: dbTx.UpdatedAt.Time, + }, nil +} + +func (r *VirtualGameRepo) UpdateVirtualGameTransactionStatus(ctx context.Context, id int64, status string) error { + return r.store.queries.UpdateVirtualGameTransactionStatus(ctx, dbgen.UpdateVirtualGameTransactionStatusParams{ + ID: id, + Status: status, + }) +} diff --git a/internal/services/virtualGame/port.go b/internal/services/virtualGame/port.go new file mode 100644 index 0000000..d473355 --- /dev/null +++ b/internal/services/virtualGame/port.go @@ -0,0 +1,12 @@ +package virtualgameservice + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type VirtualGameService interface { + GenerateGameLaunchURL(ctx context.Context, userID int64, gameID, currency, mode string) (string, error) + HandleCallback(ctx context.Context, callback *domain.PopOKCallback) error +} diff --git a/internal/services/virtualGame/service.go b/internal/services/virtualGame/service.go new file mode 100644 index 0000000..802fa38 --- /dev/null +++ b/internal/services/virtualGame/service.go @@ -0,0 +1,169 @@ +package virtualgameservice + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" + jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" +) + +type service struct { + repo repository.VirtualGameRepository + walletSvc wallet.Service + store *repository.Store + config *config.Config + logger *slog.Logger +} + +func New(repo repository.VirtualGameRepository, walletSvc wallet.Service, store *repository.Store, cfg *config.Config, logger *slog.Logger) VirtualGameService { + return &service{ + repo: repo, + walletSvc: walletSvc, + store: store, + config: cfg, + logger: logger, + } +} + +func (s *service) GenerateGameLaunchURL(ctx context.Context, userID int64, gameID, currency, mode string) (string, error) { + user, err := s.store.GetUserByID(ctx, userID) + if err != nil { + s.logger.Error("Failed to get user", "userID", userID, "error", err) + return "", err + } + + sessionToken := fmt.Sprintf("%d-%s-%d", userID, gameID, time.Now().UnixNano()) + token, err := jwtutil.CreatePopOKJwt( + userID, + user.PhoneNumber, + currency, + "en", + mode, + sessionToken, + s.config.PopOK.SecretKey, + 24*time.Hour, + ) + if err != nil { + s.logger.Error("Failed to create PopOK JWT", "userID", userID, "error", err) + return "", err + } + + params := fmt.Sprintf( + "client_id=%s&game_id=%s¤cy=%s&lang=en&mode=%s&token=%s", + s.config.PopOK.ClientID, gameID, currency, mode, token, + ) + signature := s.generateSignature(params) + return fmt.Sprintf("%s/game/launch?%s&signature=%s", s.config.PopOK.BaseURL, params, signature), nil +} + +func (s *service) HandleCallback(ctx context.Context, callback *domain.PopOKCallback) error { + s.logger.Info("Handling PopOK callback", "transactionID", callback.TransactionID, "type", callback.Type) + + if !s.verifySignature(callback) { + s.logger.Error("Invalid callback signature", "transactionID", callback.TransactionID) + return errors.New("invalid signature") + } + + existingTx, err := s.repo.GetVirtualGameTransactionByExternalID(ctx, callback.TransactionID) + if err != nil { + s.logger.Error("Failed to check existing transaction", "transactionID", callback.TransactionID, "error", err) + return err + } + if existingTx != nil { + s.logger.Warn("Transaction already processed", "transactionID", callback.TransactionID) + return nil // Idempotency + } + + session, err := s.repo.GetVirtualGameSessionByToken(ctx, callback.SessionID) + if err != nil || session == nil { + s.logger.Error("Invalid or missing session", "sessionID", callback.SessionID, "error", err) + return errors.New("invalid session") + } + + wallets, err := s.walletSvc.GetWalletsByUser(ctx, session.UserID) + if err != nil || len(wallets) == 0 { + s.logger.Error("Failed to get wallets or no wallet found", "userID", session.UserID, "error", err) + return errors.New("user has no wallet") + } + + walletID := wallets[0].ID + amount := int64(callback.Amount * 100) // Convert to cents + transactionType := callback.Type + + switch transactionType { + case "BET": + amount = -amount // Debit for bets + case "WIN", "JACKPOT_WIN", "REFUND": + default: + s.logger.Error("Unknown transaction type", "transactionID", callback.TransactionID, "type", transactionType) + return errors.New("unknown transaction type") + } + + err = s.walletSvc.Add(ctx, walletID, domain.Currency(amount)) + if err != nil { + s.logger.Error("Failed to update wallet", "walletID", walletID, "userID", session.UserID, "amount", amount, "error", err) + return err + } + + // Record transaction + tx := &domain.VirtualGameTransaction{ + SessionID: session.ID, + UserID: session.UserID, + WalletID: walletID, + TransactionType: transactionType, + Amount: amount, + Currency: callback.Currency, + ExternalTransactionID: callback.TransactionID, + Status: "COMPLETED", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := s.repo.CreateVirtualGameTransaction(ctx, tx); err != nil { + s.logger.Error("Failed to create transaction", "transactionID", callback.TransactionID, "error", err) + return err + } + + s.logger.Info("Callback processed successfully", "transactionID", callback.TransactionID, "type", transactionType, "amount", callback.Amount) + return nil +} + +func (s *service) generateSignature(params string) string { + h := hmac.New(sha256.New, []byte(s.config.PopOK.SecretKey)) + h.Write([]byte(params)) + return hex.EncodeToString(h.Sum(nil)) +} + +func (s *service) verifySignature(callback *domain.PopOKCallback) bool { + data, _ := json.Marshal(struct { + TransactionID string `json:"transaction_id"` + SessionID string `json:"session_id"` + Type string `json:"type"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Timestamp int64 `json:"timestamp"` + }{ + TransactionID: callback.TransactionID, + SessionID: callback.SessionID, + Type: callback.Type, + Amount: callback.Amount, + Currency: callback.Currency, + Timestamp: callback.Timestamp, + }) + + h := hmac.New(sha256.New, []byte(s.config.PopOK.SecretKey)) + h.Write(data) + expected := hex.EncodeToString(h.Sum(nil)) + return expected == callback.Signature +} diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 34fdd5b..e44e602 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -10,6 +10,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" + virtualgameservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" @@ -28,6 +29,7 @@ type App struct { authSvc *authentication.Service userSvc *user.Service betSvc *bet.Service + virtualGameSvc virtualgameservice.VirtualGameService walletSvc *wallet.Service transactionSvc *transaction.Service ticketSvc *ticket.Service @@ -48,6 +50,7 @@ func NewApp( transactionSvc *transaction.Service, notidicationStore notificationservice.NotificationStore, referralSvc referralservice.ReferralStore, + virtualGameSvc virtualgameservice.VirtualGameService, ) *App { app := fiber.New(fiber.Config{ CaseSensitive: true, @@ -70,6 +73,7 @@ func NewApp( NotidicationStore: notidicationStore, referralSvc: referralSvc, Logger: logger, + virtualGameSvc: virtualGameSvc, } s.initAppRoutes() diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 6e3eb50..71d936f 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -10,6 +10,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" + virtualgameservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" @@ -24,13 +25,14 @@ type Handler struct { transactionSvc *transaction.Service ticketSvc *ticket.Service betSvc *bet.Service + virtualGameSvc virtualgameservice.VirtualGameService authSvc *authentication.Service jwtConfig jwtutil.JwtConfig validator *customvalidator.CustomValidator } func New(logger *slog.Logger, notificationSvc notificationservice.NotificationStore, validator *customvalidator.CustomValidator, walletSvc *wallet.Service, - referralSvc referralservice.ReferralStore, userSvc *user.Service, transactionSvc *transaction.Service, ticketSvc *ticket.Service, betSvc *bet.Service, authSvc *authentication.Service, jwtConfig jwtutil.JwtConfig) *Handler { + referralSvc referralservice.ReferralStore, virtualGameSvc virtualgameservice.VirtualGameService, userSvc *user.Service, transactionSvc *transaction.Service, ticketSvc *ticket.Service, betSvc *bet.Service, authSvc *authentication.Service, jwtConfig jwtutil.JwtConfig) *Handler { return &Handler{ logger: logger, notificationSvc: notificationSvc, @@ -41,6 +43,7 @@ func New(logger *slog.Logger, notificationSvc notificationservice.NotificationSt transactionSvc: transactionSvc, ticketSvc: ticketSvc, betSvc: betSvc, + virtualGameSvc: virtualGameSvc, authSvc: authSvc, jwtConfig: jwtConfig, } diff --git a/internal/web_server/handlers/virtual_games_hadlers.go b/internal/web_server/handlers/virtual_games_hadlers.go new file mode 100644 index 0000000..ddd6bb5 --- /dev/null +++ b/internal/web_server/handlers/virtual_games_hadlers.go @@ -0,0 +1,83 @@ +package handlers + +import ( + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" + "github.com/gofiber/fiber/v2" +) + +// LaunchVirtualGame godoc +// @Summary Launch a PopOK virtual game +// @Description Generates a URL to launch a PopOK game +// @Tags virtual-game +// @Accept json +// @Produce json +// @Security Bearer +// @Param launch body launchVirtualGameReq true "Game launch details" +// @Success 200 {object} launchVirtualGameRes +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /virtual-game/launch [post] +func (h *Handler) LaunchVirtualGame(c *fiber.Ctx) error { + type launchVirtualGameReq struct { + GameID string `json:"game_id" validate:"required" example:"crash_001"` + Currency string `json:"currency" validate:"required,len=3" example:"USD"` + Mode string `json:"mode" validate:"required,oneof=REAL DEMO" example:"REAL"` + } + + type launchVirtualGameRes struct { + LaunchURL string `json:"launch_url"` + } + + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + h.logger.Error("Invalid user ID in context") + return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification") + } + + var req launchVirtualGameReq + if err := c.BodyParser(&req); err != nil { + h.logger.Error("Failed to parse LaunchVirtualGame request", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + if valErrs, ok := h.validator.Validate(c, req); !ok { + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + } + + url, err := h.virtualGameSvc.GenerateGameLaunchURL(c.Context(), userID, req.GameID, req.Currency, req.Mode) + if err != nil { + h.logger.Error("Failed to generate game launch URL", "userID", userID, "gameID", req.GameID, "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to launch game") + } + + res := launchVirtualGameRes{LaunchURL: url} + return response.WriteJSON(c, fiber.StatusOK, "Game launched successfully", res, nil) +} + +// HandleVirtualGameCallback godoc +// @Summary Handle PopOK game callback +// @Description Processes callbacks from PopOK for game events +// @Tags virtual-game +// @Accept json +// @Produce json +// @Param callback body domain.PopOKCallback true "Callback data" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /virtual-game/callback [post] +func (h *Handler) HandleVirtualGameCallback(c *fiber.Ctx) error { + var callback domain.PopOKCallback + if err := c.BodyParser(&callback); err != nil { + h.logger.Error("Failed to parse callback", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid callback data") + } + + if err := h.virtualGameSvc.HandleCallback(c.Context(), &callback); err != nil { + h.logger.Error("Failed to handle callback", "transactionID", callback.TransactionID, "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to process callback") + } + + return response.WriteJSON(c, fiber.StatusOK, "Callback processed successfully", nil, nil) +} diff --git a/internal/web_server/jwt/jwt.go b/internal/web_server/jwt/jwt.go index 530eb12..35f8a94 100644 --- a/internal/web_server/jwt/jwt.go +++ b/internal/web_server/jwt/jwt.go @@ -8,14 +8,11 @@ import ( "github.com/golang-jwt/jwt/v5" ) -// type UserToken struct { -// UserId string -// } var ( ErrExpiredToken = errors.New("token expired") ErrMalformedToken = errors.New("token malformed") ErrTokenNotExpired = errors.New("token not expired") - ErrRefreshTokenNotFound = errors.New("refresh token not found") // i.e login again + ErrRefreshTokenNotFound = errors.New("refresh token not found") ) type UserClaim struct { @@ -23,23 +20,61 @@ type UserClaim struct { UserId int64 Role domain.Role } + +type PopOKClaim struct { + jwt.RegisteredClaims + UserID int64 `json:"user_id"` + Username string `json:"username"` + Currency string `json:"currency"` + Lang string `json:"lang"` + Mode string `json:"mode"` + SessionID string `json:"session_id"` +} + type JwtConfig struct { JwtAccessKey string JwtAccessExpiry int } func CreateJwt(userId int64, Role domain.Role, key string, expiry int) (string, error) { - token := jwt.NewWithClaims(jwt.SigningMethodHS256, UserClaim{RegisteredClaims: jwt.RegisteredClaims{Issuer: "github.com/lafetz/snippitstash", - IssuedAt: jwt.NewNumericDate(time.Now()), - Audience: jwt.ClaimStrings{"fortune.com"}, - NotBefore: jwt.NewNumericDate(time.Now()), - ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expiry) * time.Second))}, + token := jwt.NewWithClaims(jwt.SigningMethodHS256, UserClaim{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "github.com/lafetz/snippitstash", + IssuedAt: jwt.NewNumericDate(time.Now()), + Audience: jwt.ClaimStrings{"fortune.com"}, + NotBefore: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expiry) * time.Second)), + }, UserId: userId, Role: Role, }) - jwtToken, err := token.SignedString([]byte(key)) // + jwtToken, err := token.SignedString([]byte(key)) return jwtToken, err } + +func CreatePopOKJwt(userID int64, username, currency, lang, mode, sessionID, key string, expiry time.Duration) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, PopOKClaim{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "fortune-bet", + IssuedAt: jwt.NewNumericDate(time.Now()), + Audience: jwt.ClaimStrings{"popokgaming.com"}, + NotBefore: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)), + }, + UserID: userID, + Username: username, + Currency: currency, + Lang: lang, + Mode: mode, + SessionID: sessionID, + }) + jwtToken, err := token.SignedString([]byte(key)) + if err != nil { + return "", err + } + return jwtToken, nil +} + func ParseJwt(jwtToken string, key string) (*UserClaim, error) { token, err := jwt.ParseWithClaims(jwtToken, &UserClaim{}, func(token *jwt.Token) (interface{}, error) { return []byte(key), nil @@ -56,5 +91,24 @@ func ParseJwt(jwtToken string, key string) (*UserClaim, error) { if claims, ok := token.Claims.(*UserClaim); ok && token.Valid { return claims, nil } - return nil, err + return nil, errors.New("invalid token claims") +} + +func ParsePopOKJwt(jwtToken string, key string) (*PopOKClaim, error) { + token, err := jwt.ParseWithClaims(jwtToken, &PopOKClaim{}, func(token *jwt.Token) (interface{}, error) { + return []byte(key), nil + }) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrExpiredToken + } + if errors.Is(err, jwt.ErrTokenMalformed) { + return nil, ErrMalformedToken + } + return nil, err + } + if claims, ok := token.Claims.(*PopOKClaim); ok && token.Valid { + return claims, nil + } + return nil, errors.New("invalid PopOK token claims") } diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index e333f1f..910e4eb 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -18,6 +18,7 @@ func (a *App) initAppRoutes() { a.validator, a.walletSvc, a.referralSvc, + a.virtualGameSvc, a.userSvc, a.transactionSvc, a.ticketSvc, @@ -101,6 +102,10 @@ func (a *App) initAppRoutes() { a.fiber.Get("/notifications/ws/connect/:recipientID", h.ConnectSocket) a.fiber.Post("/notifications/mark-as-read", h.MarkNotificationAsRead) a.fiber.Post("/notifications/create", h.CreateAndSendNotification) + + // Virtual Game Routes + a.fiber.Post("/virtual-game/launch", a.authMiddleware, h.LaunchVirtualGame) + a.fiber.Post("/virtual-game/callback", h.HandleVirtualGameCallback) } // @Router /user/resetPassword [post]