diff --git a/cmd/main.go b/cmd/main.go index c5eebd7..a474956 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -30,6 +30,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" virtualgameservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame" alea "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame/Alea" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame/veli" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" // "github.com/SamuelTariku/FortuneBet-Backend/internal/utils" @@ -99,13 +100,20 @@ func main() { logger, ) + veliService := veli.NewVeliPlayService( + vitualGameRepo, + *walletSvc, + cfg, + logger, + ) + httpserver.StartDataFetchingCrons(eventSvc, oddsSvc, resultSvc) app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{ JwtAccessKey: cfg.JwtKey, JwtAccessExpiry: cfg.AccessExpiry, }, userSvc, - ticketSvc, betSvc, walletSvc, transactionSvc, branchSvc, companySvc, notificationSvc, oddsSvc, eventSvc, referalSvc, virtualGameSvc, aleaService, resultSvc, cfg) + ticketSvc, betSvc, walletSvc, transactionSvc, branchSvc, companySvc, notificationSvc, oddsSvc, eventSvc, referalSvc, virtualGameSvc, aleaService, veliService, resultSvc, cfg) logger.Info("Starting server", "port", cfg.Port) if err := app.Run(); err != nil { diff --git a/docs/docs.go b/docs/docs.go index 875d103..47695ae 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -442,6 +442,76 @@ const docTemplate = `{ } } }, + "/api/veli/launch/{game_id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Generates authenticated launch URL for Veli games", + "tags": [ + "Veli Games" + ], + "summary": "Launch a Veli game", + "parameters": [ + { + "type": "string", + "description": "Game ID (e.g., veli_aviator_v1)", + "name": "game_id", + "in": "path", + "required": true + }, + { + "type": "string", + "default": "USD", + "description": "Currency code", + "name": "currency", + "in": "query" + }, + { + "enum": [ + "real", + "demo" + ], + "type": "string", + "default": "real", + "description": "Game mode", + "name": "mode", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns launch URL", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/auth/login": { "post": { "description": "Login customer", @@ -3627,6 +3697,70 @@ const docTemplate = `{ } } } + }, + "/webhooks/veli": { + "post": { + "description": "Processes game round settlements from Veli", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Virtual Games" + ], + "summary": "Veli Games webhook handler", + "parameters": [ + { + "description": "Callback payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.VeliCallback" + } + } + ], + "responses": { + "200": { + "description": "Callback processed", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid payload", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Invalid signature", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Processing error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } } }, "definitions": { @@ -4291,6 +4425,51 @@ const docTemplate = `{ } } }, + "domain.VeliCallback": { + "type": "object", + "properties": { + "amount": { + "description": "Transaction amount", + "type": "number" + }, + "currency": { + "description": "e.g., \"USD\"", + "type": "string" + }, + "event_type": { + "description": "\"bet_placed\", \"game_result\", etc.", + "type": "string" + }, + "game_id": { + "description": "e.g., \"veli_aviator_v1\"", + "type": "string" + }, + "multiplier": { + "description": "For games with multipliers (Aviator/Plinko)", + "type": "number" + }, + "round_id": { + "description": "Unique round identifier (replaces transaction_id)", + "type": "string" + }, + "session_id": { + "description": "Matches VirtualGameSession.SessionToken", + "type": "string" + }, + "signature": { + "description": "HMAC-SHA256", + "type": "string" + }, + "timestamp": { + "description": "Unix timestamp", + "type": "integer" + }, + "user_id": { + "description": "Veli's user identifier", + "type": "string" + } + } + }, "domain.VerifyTransactionResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 4c54116..a0fc42c 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -434,6 +434,76 @@ } } }, + "/api/veli/launch/{game_id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Generates authenticated launch URL for Veli games", + "tags": [ + "Veli Games" + ], + "summary": "Launch a Veli game", + "parameters": [ + { + "type": "string", + "description": "Game ID (e.g., veli_aviator_v1)", + "name": "game_id", + "in": "path", + "required": true + }, + { + "type": "string", + "default": "USD", + "description": "Currency code", + "name": "currency", + "in": "query" + }, + { + "enum": [ + "real", + "demo" + ], + "type": "string", + "default": "real", + "description": "Game mode", + "name": "mode", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns launch URL", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/auth/login": { "post": { "description": "Login customer", @@ -3619,6 +3689,70 @@ } } } + }, + "/webhooks/veli": { + "post": { + "description": "Processes game round settlements from Veli", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Virtual Games" + ], + "summary": "Veli Games webhook handler", + "parameters": [ + { + "description": "Callback payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.VeliCallback" + } + } + ], + "responses": { + "200": { + "description": "Callback processed", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid payload", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Invalid signature", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Processing error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } } }, "definitions": { @@ -4283,6 +4417,51 @@ } } }, + "domain.VeliCallback": { + "type": "object", + "properties": { + "amount": { + "description": "Transaction amount", + "type": "number" + }, + "currency": { + "description": "e.g., \"USD\"", + "type": "string" + }, + "event_type": { + "description": "\"bet_placed\", \"game_result\", etc.", + "type": "string" + }, + "game_id": { + "description": "e.g., \"veli_aviator_v1\"", + "type": "string" + }, + "multiplier": { + "description": "For games with multipliers (Aviator/Plinko)", + "type": "number" + }, + "round_id": { + "description": "Unique round identifier (replaces transaction_id)", + "type": "string" + }, + "session_id": { + "description": "Matches VirtualGameSession.SessionToken", + "type": "string" + }, + "signature": { + "description": "HMAC-SHA256", + "type": "string" + }, + "timestamp": { + "description": "Unix timestamp", + "type": "integer" + }, + "user_id": { + "description": "Veli's user identifier", + "type": "string" + } + } + }, "domain.VerifyTransactionResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 33afae3..2efae74 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -454,6 +454,39 @@ definitions: description: Converted from "time" field in UNIX format type: string type: object + domain.VeliCallback: + properties: + amount: + description: Transaction amount + type: number + currency: + description: e.g., "USD" + type: string + event_type: + description: '"bet_placed", "game_result", etc.' + type: string + game_id: + description: e.g., "veli_aviator_v1" + type: string + multiplier: + description: For games with multipliers (Aviator/Plinko) + type: number + round_id: + description: Unique round identifier (replaces transaction_id) + type: string + session_id: + description: Matches VirtualGameSession.SessionToken + type: string + signature: + description: HMAC-SHA256 + type: string + timestamp: + description: Unix timestamp + type: integer + user_id: + description: Veli's user identifier + type: string + type: object domain.VerifyTransactionResponse: properties: data: @@ -1598,6 +1631,52 @@ paths: summary: Process Alea Play game callback tags: - Alea Virtual Games + /api/veli/launch/{game_id}: + get: + description: Generates authenticated launch URL for Veli games + parameters: + - description: Game ID (e.g., veli_aviator_v1) + in: path + name: game_id + required: true + type: string + - default: USD + description: Currency code + in: query + name: currency + type: string + - default: real + description: Game mode + enum: + - real + - demo + in: query + name: mode + type: string + responses: + "200": + description: Returns launch URL + schema: + additionalProperties: + type: string + type: object + "400": + description: Invalid request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Launch a Veli game + tags: + - Veli Games /auth/login: post: consumes: @@ -3689,6 +3768,48 @@ paths: summary: Activate and Deactivate Wallet tags: - wallet + /webhooks/veli: + post: + consumes: + - application/json + description: Processes game round settlements from Veli + parameters: + - description: Callback payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/domain.VeliCallback' + produces: + - application/json + responses: + "200": + description: Callback processed + schema: + additionalProperties: + type: string + type: object + "400": + description: Invalid payload + schema: + additionalProperties: + type: string + type: object + "403": + description: Invalid signature + schema: + additionalProperties: + type: string + type: object + "500": + description: Processing error + schema: + additionalProperties: + type: string + type: object + summary: Veli Games webhook handler + tags: + - Virtual Games securityDefinitions: Bearer: in: header diff --git a/internal/config/config.go b/internal/config/config.go index 5187325..eba9702 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "errors" + "fmt" "log/slog" "os" "strconv" @@ -26,20 +27,32 @@ var ( ErrInvalidPopOKSecretKey = errors.New("PopOK secret key is invalid") ErrInvalidPopOKBaseURL = errors.New("PopOK base URL is invalid") ErrInvalidPopOKCallbackURL = errors.New("PopOK callback URL is invalid") + ErrInvalidVeliAPIURL = errors.New("Veli API URL is invalid") + ErrInvalidVeliOperatorKey = errors.New("Veli operator key is invalid") + ErrInvalidVeliSecretKey = errors.New("Veli secret key is invalid") ) type AleaPlayConfig struct { - Enabled bool `mapstructure:"enabled"` - BaseURL string `mapstructure:"base_url"` // "https://api.aleaplay.com" - OperatorID string `mapstructure:"operator_id"` // Your operator ID with Alea - SecretKey string `mapstructure:"secret_key"` // API secret for signatures - GameListURL string `mapstructure:"game_list_url"` // Endpoint to fetch available games - - // Optional settings + Enabled bool `mapstructure:"enabled"` + BaseURL string `mapstructure:"base_url"` // "https://api.aleaplay.com" + OperatorID string `mapstructure:"operator_id"` // Your operator ID with Alea + SecretKey string `mapstructure:"secret_key"` // API secret for signatures + GameListURL string `mapstructure:"game_list_url"` // Endpoint to fetch available games DefaultCurrency string `mapstructure:"default_currency"` // "USD", "EUR", etc. SessionTimeout int `mapstructure:"session_timeout"` // In hours } +type VeliGamesConfig struct { + Enabled bool `mapstructure:"enabled"` + APIURL string `mapstructure:"api_url"` + OperatorKey string `mapstructure:"operator_key"` + SecretKey string `mapstructure:"secret_key"` + DefaultCurrency string `mapstructure:"default_currency"` + GameIDs struct { + Aviator string `mapstructure:"aviator"` + } `mapstructure:"game_ids"` +} + type Config struct { Port int DbUrl string @@ -60,7 +73,8 @@ type Config struct { CHAPA_RETURN_URL string Bet365Token string PopOK domain.PopOKConfig - AleaPlay AleaPlayConfig `mapstructure:"alea_play"` + AleaPlay AleaPlayConfig `mapstructure:"alea_play"` + VeliGames VeliGamesConfig `mapstructure:"veli_games"` } func NewConfig() (*Config, error) { @@ -135,6 +149,7 @@ func (c *Config) loadEnv() error { return ErrInvalidLevel } + //Chapa c.CHAPA_SECRET_KEY = os.Getenv("CHAPA_SECRET_KEY") c.CHAPA_PUBLIC_KEY = os.Getenv("CHAPA_PUBLIC_KEY") c.CHAPA_ENCRYPTION_KEY = os.Getenv("CHAPA_ENCRYPTION_KEY") @@ -145,6 +160,84 @@ func (c *Config) loadEnv() error { c.CHAPA_CALLBACK_URL = os.Getenv("CHAPA_CALLBACK_URL") c.CHAPA_RETURN_URL = os.Getenv("CHAPA_RETURN_URL") + //Alea Play + aleaEnabled := os.Getenv("ALEA_ENABLED") + if aleaEnabled == "" { + aleaEnabled = "false" // Default disabled + } + + if enabled, err := strconv.ParseBool(aleaEnabled); err != nil { + return fmt.Errorf("invalid ALEA_ENABLED value: %w", err) + } else { + c.AleaPlay.Enabled = enabled + } + + c.AleaPlay.BaseURL = os.Getenv("ALEA_BASE_URL") + if c.AleaPlay.BaseURL == "" && c.AleaPlay.Enabled { + return errors.New("ALEA_BASE_URL is required when Alea is enabled") + } + + c.AleaPlay.OperatorID = os.Getenv("ALEA_OPERATOR_ID") + if c.AleaPlay.OperatorID == "" && c.AleaPlay.Enabled { + return errors.New("ALEA_OPERATOR_ID is required when Alea is enabled") + } + + c.AleaPlay.SecretKey = os.Getenv("ALEA_SECRET_KEY") + if c.AleaPlay.SecretKey == "" && c.AleaPlay.Enabled { + return errors.New("ALEA_SECRET_KEY is required when Alea is enabled") + } + + c.AleaPlay.GameListURL = os.Getenv("ALEA_GAME_LIST_URL") + c.AleaPlay.DefaultCurrency = os.Getenv("ALEA_DEFAULT_CURRENCY") + if c.AleaPlay.DefaultCurrency == "" { + c.AleaPlay.DefaultCurrency = "USD" + } + + sessionTimeoutStr := os.Getenv("ALEA_SESSION_TIMEOUT") + if sessionTimeoutStr != "" { + timeout, err := strconv.Atoi(sessionTimeoutStr) + if err == nil { + c.AleaPlay.SessionTimeout = timeout + } + } + + //Veli Games + veliEnabled := os.Getenv("VELI_ENABLED") + if veliEnabled == "" { + veliEnabled = "false" // Default to disabled if not specified + } + + if enabled, err := strconv.ParseBool(veliEnabled); err != nil { + return fmt.Errorf("invalid VELI_ENABLED value: %w", err) + } else { + c.VeliGames.Enabled = enabled + } + + apiURL := os.Getenv("VELI_API_URL") + if apiURL == "" { + apiURL = "https://api.velitech.games" // Default production URL + } + c.VeliGames.APIURL = apiURL + + operatorKey := os.Getenv("VELI_OPERATOR_KEY") + if operatorKey == "" && c.VeliGames.Enabled { + return ErrInvalidVeliOperatorKey + } + c.VeliGames.OperatorKey = operatorKey + + secretKey := os.Getenv("VELI_SECRET_KEY") + if secretKey == "" && c.VeliGames.Enabled { + return ErrInvalidVeliSecretKey + } + c.VeliGames.SecretKey = secretKey + c.VeliGames.GameIDs.Aviator = os.Getenv("VELI_GAME_ID_AVIATOR") + + defaultCurrency := os.Getenv("VELI_DEFAULT_CURRENCY") + if defaultCurrency == "" { + defaultCurrency = "USD" // Default currency + } + c.VeliGames.DefaultCurrency = defaultCurrency + c.LogLevel = lvl c.AFRO_SMS_API_KEY = os.Getenv("AFRO_SMS_API_KEY") diff --git a/internal/domain/virtual_game.go b/internal/domain/virtual_game.go index 1866519..3663aee 100644 --- a/internal/domain/virtual_game.go +++ b/internal/domain/virtual_game.go @@ -39,6 +39,9 @@ type VirtualGameTransaction struct { Multiplier float64 `json:"multiplier"` // For games like Aviator IsFreeRound bool `json:"is_free_round"` // For bonus play OperatorID string `json:"operator_id"` // Your operator ID + + // Veli specific fields + GameSpecificData GameSpecificData `json:"game_specific_data"` } // type VirtualGameTransaction struct { @@ -96,19 +99,21 @@ type AleaPlayCallback struct { Signature string `json:"signature"` } -// // Extend VirtualGameTransaction for Alea compatibility -// type VirtualGameTransaction struct { -// ID string `json:"id"` -// SessionID string `json:"session_id"` -// UserID int64 `json:"user_id"` -// WalletID int64 `json:"wallet_id"` -// TransactionType string `json:"transaction_type"` // Matches Alea's types -// Amount int64 `json:"amount"` // In cents -// Currency string `json:"currency"` -// ExternalTransactionID string `json:"external_transaction_id"` -// Status string `json:"status"` -// GameID string `json:"game_id"` // Track which game this was for -// RoundID string `json:"round_id,omitempty"` -// CreatedAt time.Time `json:"created_at"` -// UpdatedAt time.Time `json:"updated_at"` -// } +type VeliCallback struct { + EventType string `json:"event_type"` // "bet_placed", "game_result", etc. + RoundID string `json:"round_id"` // Unique round identifier (replaces transaction_id) + SessionID string `json:"session_id"` // Matches VirtualGameSession.SessionToken + UserID string `json:"user_id"` // Veli's user identifier + GameID string `json:"game_id"` // e.g., "veli_aviator_v1" + Amount float64 `json:"amount"` // Transaction amount + Multiplier float64 `json:"multiplier"` // For games with multipliers (Aviator/Plinko) + Currency string `json:"currency"` // e.g., "USD" + Timestamp int64 `json:"timestamp"` // Unix timestamp + Signature string `json:"signature"` // HMAC-SHA256 +} + +type GameSpecificData struct { + Multiplier float64 `json:"multiplier,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` // For Mines + BucketIndex int `json:"bucket_index,omitempty"` // For Plinko +} diff --git a/internal/services/virtualGame/veli/port.go b/internal/services/virtualGame/veli/port.go new file mode 100644 index 0000000..c2e7277 --- /dev/null +++ b/internal/services/virtualGame/veli/port.go @@ -0,0 +1,13 @@ +// services/veli/service.go +package veli + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type VeliVirtualGameService interface { + GenerateGameLaunchURL(ctx context.Context, userID int64, gameID, currency, mode string) (string, error) + HandleCallback(ctx context.Context, callback *domain.VeliCallback) error +} diff --git a/internal/services/virtualGame/veli/service.go b/internal/services/virtualGame/veli/service.go new file mode 100644 index 0000000..33adb25 --- /dev/null +++ b/internal/services/virtualGame/veli/service.go @@ -0,0 +1,161 @@ +package veli + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "net/url" + "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" +) + +type VeliPlayService struct { + repo repository.VirtualGameRepository + walletSvc wallet.Service + config *config.VeliGamesConfig + logger *slog.Logger +} + +func NewVeliPlayService( + repo repository.VirtualGameRepository, + walletSvc wallet.Service, + cfg *config.Config, + logger *slog.Logger, +) *VeliPlayService { + return &VeliPlayService{ + repo: repo, + walletSvc: walletSvc, + config: &cfg.VeliGames, + logger: logger, + } +} + +// GenerateGameLaunchURL mirrors Alea's pattern but uses Veli's auth requirements +func (s *VeliPlayService) GenerateGameLaunchURL(ctx context.Context, userID int64, gameID, currency, mode string) (string, error) { + session := &domain.VirtualGameSession{ + UserID: userID, + GameID: gameID, + SessionToken: generateSessionToken(userID), + Currency: currency, + Status: "ACTIVE", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + ExpiresAt: time.Now().Add(24 * time.Hour), + } + + if err := s.repo.CreateVirtualGameSession(ctx, session); err != nil { + return "", fmt.Errorf("failed to create game session: %w", err) + } + + // Veli-specific parameters + params := url.Values{ + "operator_key": []string{s.config.OperatorKey}, // Different from Alea's operator_id + "user_id": []string{fmt.Sprintf("%d", userID)}, + "game_id": []string{gameID}, + "currency": []string{currency}, + "mode": []string{mode}, + "timestamp": []string{fmt.Sprintf("%d", time.Now().Unix())}, + } + + signature := s.generateSignature(params.Encode()) + params.Add("signature", signature) + + return fmt.Sprintf("%s/launch?%s", s.config.APIURL, params.Encode()), nil +} + +// HandleCallback processes Veli's webhooks (similar structure to Alea) +func (s *VeliPlayService) HandleCallback(ctx context.Context, callback *domain.VeliCallback) error { + if !s.verifyCallbackSignature(callback) { + return errors.New("invalid callback signature") + } + + // Veli uses round_id instead of transaction_id for idempotency + existing, err := s.repo.GetVirtualGameTransactionByExternalID(ctx, callback.RoundID) + if err != nil || existing != nil { + s.logger.Warn("duplicate round detected", "round_id", callback.RoundID) + return nil + } + + session, err := s.repo.GetVirtualGameSessionByToken(ctx, callback.SessionID) + if err != nil { + return fmt.Errorf("failed to get game session: %w", err) + } + + // Convert amount based on event type (BET, WIN, etc.) + amount := convertAmount(callback.Amount, callback.EventType) + + tx := &domain.VirtualGameTransaction{ + SessionID: session.ID, + UserID: session.UserID, + TransactionType: callback.EventType, // e.g., "bet_placed", "game_result" + Amount: amount, + Currency: callback.Currency, + ExternalTransactionID: callback.RoundID, // Veli uses round_id as the unique identifier + Status: "COMPLETED", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + GameSpecificData: domain.GameSpecificData{ + Multiplier: callback.Multiplier, // Used for Aviator/Plinko + }, + } + + if err := s.processTransaction(ctx, tx, session.UserID); err != nil { + return fmt.Errorf("failed to process transaction: %w", err) + } + + return nil +} + +// Shared helper methods (same pattern as Alea) +func (s *VeliPlayService) generateSignature(data string) string { + h := hmac.New(sha256.New, []byte(s.config.SecretKey)) + h.Write([]byte(data)) + return hex.EncodeToString(h.Sum(nil)) +} + +func (s *VeliPlayService) verifyCallbackSignature(cb *domain.VeliCallback) bool { + signData := fmt.Sprintf("%s%s%s%.2f%s%d", + cb.RoundID, // Veli uses round_id instead of transaction_id + cb.SessionID, + cb.EventType, + cb.Amount, + cb.Currency, + cb.Timestamp, + ) + expectedSig := s.generateSignature(signData) + return expectedSig == cb.Signature +} + +func convertAmount(amount float64, eventType string) int64 { + cents := int64(amount * 100) + if eventType == "bet_placed" { + return -cents // Debit for bets + } + return cents // Credit for wins/results +} + +func generateSessionToken(userID int64) string { + return fmt.Sprintf("veli-%d-%d", userID, time.Now().UnixNano()) +} + +func (s *VeliPlayService) processTransaction(ctx context.Context, tx *domain.VirtualGameTransaction, userID int64) error { + wallets, err := s.walletSvc.GetWalletsByUser(ctx, userID) + if err != nil || len(wallets) == 0 { + return errors.New("no wallet available for user") + } + tx.WalletID = wallets[0].ID + + if err := s.walletSvc.AddToWallet(ctx, tx.WalletID, domain.Currency(tx.Amount)); err != nil { + return fmt.Errorf("wallet update failed: %w", err) + } + + return s.repo.CreateVirtualGameTransaction(ctx, tx) +} diff --git a/internal/web_server/app.go b/internal/web_server/app.go index e036a6b..2c7c9d2 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -18,6 +18,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" virtualgameservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame" alea "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame/Alea" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame/veli" "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" @@ -39,6 +40,7 @@ type App struct { betSvc *bet.Service virtualGameSvc virtualgameservice.VirtualGameService aleaVirtualGameService alea.AleaVirtualGameService + veliVirtualGameService veli.VeliVirtualGameService walletSvc *wallet.Service transactionSvc *transaction.Service ticketSvc *ticket.Service @@ -71,6 +73,7 @@ func NewApp( referralSvc referralservice.ReferralStore, virtualGameSvc virtualgameservice.VirtualGameService, aleaVirtualGameService alea.AleaVirtualGameService, + veliVirtualGameService veli.VeliVirtualGameService, resultSvc *result.Service, cfg *config.Config, ) *App { @@ -109,6 +112,7 @@ func NewApp( eventSvc: eventSvc, virtualGameSvc: virtualGameSvc, aleaVirtualGameService: aleaVirtualGameService, + veliVirtualGameService: veliVirtualGameService, resultSvc: resultSvc, cfg: cfg, } diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index ac4d495..c81b43f 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -17,6 +17,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" virtualgameservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame" alea "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame/Alea" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame/veli" "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" @@ -37,6 +38,7 @@ type Handler struct { eventSvc event.Service virtualGameSvc virtualgameservice.VirtualGameService aleaVirtualGameSvc alea.AleaVirtualGameService + veliVirtualGameSvc veli.VeliVirtualGameService authSvc *authentication.Service jwtConfig jwtutil.JwtConfig validator *customvalidator.CustomValidator @@ -51,6 +53,7 @@ func New( referralSvc referralservice.ReferralStore, virtualGameSvc virtualgameservice.VirtualGameService, aleaVirtualGameSvc alea.AleaVirtualGameService, + veliVirtualGameSvc veli.VeliVirtualGameService, userSvc *user.Service, transactionSvc *transaction.Service, ticketSvc *ticket.Service, @@ -79,6 +82,7 @@ func New( eventSvc: eventSvc, virtualGameSvc: virtualGameSvc, aleaVirtualGameSvc: aleaVirtualGameSvc, + veliVirtualGameSvc: veliVirtualGameSvc, authSvc: authSvc, jwtConfig: jwtConfig, Cfg: cfg, diff --git a/internal/web_server/handlers/veli_games.go b/internal/web_server/handlers/veli_games.go new file mode 100644 index 0000000..a972bc6 --- /dev/null +++ b/internal/web_server/handlers/veli_games.go @@ -0,0 +1,75 @@ +package handlers + +import ( + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/gofiber/fiber/v2" +) + +// LaunchVeliGame godoc +// @Summary Launch a Veli game +// @Description Generates authenticated launch URL for Veli games +// @Tags Veli Games +// @Security BearerAuth +// @Param game_id path string true "Game ID (e.g., veli_aviator_v1)" +// @Param currency query string false "Currency code" default(USD) +// @Param mode query string false "Game mode" Enums(real, demo) default(real) +// @Success 200 {object} map[string]string "Returns launch URL" +// @Failure 400 {object} map[string]string "Invalid request" +// @Failure 500 {object} map[string]string "Internal server error" +// @Router /api/veli/launch/{game_id} [get] +func (h *Handler) LaunchVeliGame(c *fiber.Ctx) error { + userID := c.Locals("userID").(int64) + gameID := c.Params("game_id") + currency := c.Query("currency", "USD") + mode := c.Query("mode", "real") + + launchURL, err := h.veliVirtualGameSvc.GenerateGameLaunchURL(c.Context(), userID, gameID, currency, mode) + if err != nil { + h.logger.Error("failed to generate Veli launch URL", + "error", err, + "userID", userID, + "gameID", gameID) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "failed to launch game", + }) + } + + return c.JSON(fiber.Map{ + "launch_url": launchURL, + }) +} + +// HandleVeliCallback godoc +// @Summary Veli Games webhook handler +// @Description Processes game round settlements from Veli +// @Tags Virtual Games +// @Accept json +// @Produce json +// @Param payload body domain.VeliCallback true "Callback payload" +// @Success 200 {object} map[string]string "Callback processed" +// @Failure 400 {object} map[string]string "Invalid payload" +// @Failure 403 {object} map[string]string "Invalid signature" +// @Failure 500 {object} map[string]string "Processing error" +// @Router /webhooks/veli [post] +func (h *Handler) HandleVeliCallback(c *fiber.Ctx) error { + var cb domain.VeliCallback + if err := c.BodyParser(&cb); err != nil { + h.logger.Error("invalid Veli callback format", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid payload format", + }) + } + + if err := h.veliVirtualGameSvc.HandleCallback(c.Context(), &cb); err != nil { + h.logger.Error("failed to process Veli callback", + "roundID", cb.RoundID, + "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "failed to process callback", + }) + } + + return c.JSON(fiber.Map{ + "status": "processed", + }) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 7a18943..3fb78b5 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -22,6 +22,7 @@ func (a *App) initAppRoutes() { a.referralSvc, a.virtualGameSvc, a.aleaVirtualGameService, + a.veliVirtualGameService, a.userSvc, a.transactionSvc, a.ticketSvc, @@ -166,17 +167,18 @@ func (a *App) initAppRoutes() { a.fiber.Post("/transfer/refill/:id", a.authMiddleware, h.RefillWallet) //Chapa Routes - a.fiber.Post("/api/v1/chapa/payments/initialize", h.InitializePayment) - a.fiber.Get("/api/v1/chapa/payments/verify/:tx_ref", h.VerifyTransaction) - a.fiber.Post("/api/v1/chapa/payments/callback", h.ReceiveWebhook) - a.fiber.Get("/api/v1/chapa/banks", h.GetBanks) - a.fiber.Post("/api/v1/chapa/transfers", h.CreateTransfer) - a.fiber.Get("/api/v1/chapa/transfers/verify/:transfer_ref", h.VerifyTransfer) + group := a.fiber.Group("/api/v1") + + group.Post("/chapa/payments/initialize", h.InitializePayment) + group.Get("/chapa/payments/verify/:tx_ref", h.VerifyTransaction) + group.Post("/chapa/payments/callback", h.ReceiveWebhook) + group.Get("/chapa/banks", h.GetBanks) + group.Post("/chapa/transfers", h.CreateTransfer) + group.Get("/chapa/transfers/verify/:transfer_ref", h.VerifyTransfer) //Alea Play Virtual Game Routes - a.fiber.Get("/api/v1/alea-games/launch", a.authMiddleware, h.LaunchAleaGame) - a.fiber.Post("/api/v1/webhooks/alea", a.authMiddleware, h.HandleAleaCallback) - // a.fiber.Post("/webhooks/alea", middleware.AleaWebhookMiddleware(a.cfg.AleaPlay.SecretKey), h.HandleAleaCallback) + group.Get("/alea-games/launch", a.authMiddleware, h.LaunchAleaGame) + group.Post("/webhooks/alea-games", a.authMiddleware, h.HandleAleaCallback) // Transactions /transactions a.fiber.Post("/transaction", a.authMiddleware, h.CreateTransaction)