feat: popok itegratio

This commit is contained in:
dawitel 2025-04-12 10:06:56 +03:00
parent 8670fba6a4
commit 02e6f6ee6f
19 changed files with 831 additions and 26 deletions

View File

@ -125,6 +125,7 @@
│ ├── bet_handler.go │ ├── bet_handler.go
│ ├── handlers.go │ ├── handlers.go
│ ├── notification_handler.go │ ├── notification_handler.go
│ ├── referal_handlers.go
│ ├── ticket_handler.go │ ├── ticket_handler.go
│ ├── transaction_handler.go │ ├── transaction_handler.go
│ ├── user.go │ ├── user.go

View File

@ -17,6 +17,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "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" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server" httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server"
jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt"
@ -65,14 +66,16 @@ func main() {
notificationRepo := repository.NewNotificationRepository(store) notificationRepo := repository.NewNotificationRepository(store)
referalRepo := repository.NewReferralRepository(store) referalRepo := repository.NewReferralRepository(store)
vitualGameRepo := repository.NewVirtualGameRepository(store)
notificationSvc := notificationservice.New(notificationRepo, logger, cfg) notificationSvc := notificationservice.New(notificationRepo, logger, cfg)
referalSvc := referralservice.New(referalRepo, *walletSvc, store, cfg, logger) 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{ app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{
JwtAccessKey: cfg.JwtKey, JwtAccessKey: cfg.JwtKey,
JwtAccessExpiry: cfg.AccessExpiry, 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) logger.Info("Starting server", "port", cfg.Port)
if err := app.Run(); err != nil { if err := app.Run(); err != nil {

View File

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS virtual_game_transactions;
DROP TABLE IF EXISTS virtual_game_sessions;

View File

@ -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);

View File

@ -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;

View File

@ -189,6 +189,32 @@ type User struct {
ReferredBy pgtype.Text 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 { type Wallet struct {
ID int64 ID int64
Balance int64 Balance int64

View File

@ -184,8 +184,8 @@ WHERE referrer_id = $1
type GetReferralStatsRow struct { type GetReferralStatsRow struct {
TotalReferrals int64 TotalReferrals int64
CompletedReferrals int64 CompletedReferrals int64
TotalRewardEarned float64 TotalRewardEarned interface{}
PendingRewards float64 PendingRewards interface{}
} }
func (q *Queries) GetReferralStats(ctx context.Context, referrerID string) (GetReferralStatsRow, error) { func (q *Queries) GetReferralStats(ctx context.Context, referrerID string) (GetReferralStatsRow, error) {

180
gen/db/virtual_games.sql.go Normal file
View File

@ -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
}

View File

@ -6,6 +6,7 @@ import (
"os" "os"
"strconv" "strconv"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
@ -20,6 +21,10 @@ var (
ErrInvalidLevel = errors.New("invalid log level") ErrInvalidLevel = errors.New("invalid log level")
ErrInvalidEnv = errors.New("env not set or invalid") ErrInvalidEnv = errors.New("env not set or invalid")
ErrInvalidSMSAPIKey = errors.New("SMS API key is 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 { type Config struct {
@ -34,6 +39,7 @@ type Config struct {
AFRO_SMS_SENDER_NAME string AFRO_SMS_SENDER_NAME string
AFRO_SMS_RECEIVER_PHONE_NUMBER string AFRO_SMS_RECEIVER_PHONE_NUMBER string
ADRO_SMS_HOST_URL string ADRO_SMS_HOST_URL string
PopOK domain.PopOKConfig
} }
func NewConfig() (*Config, error) { func NewConfig() (*Config, error) {
@ -125,6 +131,31 @@ func (c *Config) loadEnv() error {
if c.ADRO_SMS_HOST_URL == "" { if c.ADRO_SMS_HOST_URL == "" {
c.ADRO_SMS_HOST_URL = "https://api.afrosms.com" 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 return nil
} }

View File

@ -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
}

View File

@ -96,8 +96,8 @@ func (r *ReferralRepo) GetReferralStats(ctx context.Context, userID string) (*do
return &domain.ReferralStats{ return &domain.ReferralStats{
TotalReferrals: int(stats.TotalReferrals), TotalReferrals: int(stats.TotalReferrals),
CompletedReferrals: int(stats.CompletedReferrals), CompletedReferrals: int(stats.CompletedReferrals),
TotalRewardEarned: float64(stats.TotalRewardEarned), TotalRewardEarned: stats.TotalRewardEarned.(float64),
PendingRewards: float64(stats.PendingRewards), PendingRewards: stats.PendingRewards.(float64),
}, nil }, nil
} }

View File

@ -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,
})
}

View File

@ -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
}

View File

@ -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
}

View File

@ -10,6 +10,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "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" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt"
customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator"
@ -28,6 +29,7 @@ type App struct {
authSvc *authentication.Service authSvc *authentication.Service
userSvc *user.Service userSvc *user.Service
betSvc *bet.Service betSvc *bet.Service
virtualGameSvc virtualgameservice.VirtualGameService
walletSvc *wallet.Service walletSvc *wallet.Service
transactionSvc *transaction.Service transactionSvc *transaction.Service
ticketSvc *ticket.Service ticketSvc *ticket.Service
@ -48,6 +50,7 @@ func NewApp(
transactionSvc *transaction.Service, transactionSvc *transaction.Service,
notidicationStore notificationservice.NotificationStore, notidicationStore notificationservice.NotificationStore,
referralSvc referralservice.ReferralStore, referralSvc referralservice.ReferralStore,
virtualGameSvc virtualgameservice.VirtualGameService,
) *App { ) *App {
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
CaseSensitive: true, CaseSensitive: true,
@ -70,6 +73,7 @@ func NewApp(
NotidicationStore: notidicationStore, NotidicationStore: notidicationStore,
referralSvc: referralSvc, referralSvc: referralSvc,
Logger: logger, Logger: logger,
virtualGameSvc: virtualGameSvc,
} }
s.initAppRoutes() s.initAppRoutes()

View File

@ -10,6 +10,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "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" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt"
customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator"
@ -24,13 +25,14 @@ type Handler struct {
transactionSvc *transaction.Service transactionSvc *transaction.Service
ticketSvc *ticket.Service ticketSvc *ticket.Service
betSvc *bet.Service betSvc *bet.Service
virtualGameSvc virtualgameservice.VirtualGameService
authSvc *authentication.Service authSvc *authentication.Service
jwtConfig jwtutil.JwtConfig jwtConfig jwtutil.JwtConfig
validator *customvalidator.CustomValidator validator *customvalidator.CustomValidator
} }
func New(logger *slog.Logger, notificationSvc notificationservice.NotificationStore, validator *customvalidator.CustomValidator, walletSvc *wallet.Service, 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{ return &Handler{
logger: logger, logger: logger,
notificationSvc: notificationSvc, notificationSvc: notificationSvc,
@ -41,6 +43,7 @@ func New(logger *slog.Logger, notificationSvc notificationservice.NotificationSt
transactionSvc: transactionSvc, transactionSvc: transactionSvc,
ticketSvc: ticketSvc, ticketSvc: ticketSvc,
betSvc: betSvc, betSvc: betSvc,
virtualGameSvc: virtualGameSvc,
authSvc: authSvc, authSvc: authSvc,
jwtConfig: jwtConfig, jwtConfig: jwtConfig,
} }

View File

@ -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)
}

View File

@ -8,14 +8,11 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
// type UserToken struct {
// UserId string
// }
var ( var (
ErrExpiredToken = errors.New("token expired") ErrExpiredToken = errors.New("token expired")
ErrMalformedToken = errors.New("token malformed") ErrMalformedToken = errors.New("token malformed")
ErrTokenNotExpired = errors.New("token not expired") 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 { type UserClaim struct {
@ -23,23 +20,61 @@ type UserClaim struct {
UserId int64 UserId int64
Role domain.Role 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 { type JwtConfig struct {
JwtAccessKey string JwtAccessKey string
JwtAccessExpiry int JwtAccessExpiry int
} }
func CreateJwt(userId int64, Role domain.Role, key string, expiry int) (string, error) { 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", token := jwt.NewWithClaims(jwt.SigningMethodHS256, UserClaim{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "github.com/lafetz/snippitstash",
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
Audience: jwt.ClaimStrings{"fortune.com"}, Audience: jwt.ClaimStrings{"fortune.com"},
NotBefore: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expiry) * time.Second))}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expiry) * time.Second)),
},
UserId: userId, UserId: userId,
Role: Role, Role: Role,
}) })
jwtToken, err := token.SignedString([]byte(key)) // jwtToken, err := token.SignedString([]byte(key))
return jwtToken, err 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) { func ParseJwt(jwtToken string, key string) (*UserClaim, error) {
token, err := jwt.ParseWithClaims(jwtToken, &UserClaim{}, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(jwtToken, &UserClaim{}, func(token *jwt.Token) (interface{}, error) {
return []byte(key), nil 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 { if claims, ok := token.Claims.(*UserClaim); ok && token.Valid {
return claims, nil return claims, nil
} }
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 return nil, err
} }
if claims, ok := token.Claims.(*PopOKClaim); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid PopOK token claims")
}

View File

@ -18,6 +18,7 @@ func (a *App) initAppRoutes() {
a.validator, a.validator,
a.walletSvc, a.walletSvc,
a.referralSvc, a.referralSvc,
a.virtualGameSvc,
a.userSvc, a.userSvc,
a.transactionSvc, a.transactionSvc,
a.ticketSvc, a.ticketSvc,
@ -101,6 +102,10 @@ func (a *App) initAppRoutes() {
a.fiber.Get("/notifications/ws/connect/:recipientID", h.ConnectSocket) a.fiber.Get("/notifications/ws/connect/:recipientID", h.ConnectSocket)
a.fiber.Post("/notifications/mark-as-read", h.MarkNotificationAsRead) a.fiber.Post("/notifications/mark-as-read", h.MarkNotificationAsRead)
a.fiber.Post("/notifications/create", h.CreateAndSendNotification) 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] // @Router /user/resetPassword [post]