diff --git a/cmd/main.go b/cmd/main.go index fa4b9fc..90f5dc7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,12 +7,15 @@ import ( "os" "github.com/go-playground/validator/v10" + // "github.com/gofiber/fiber/v2" "github.com/SamuelTariku/FortuneBet-Backend/internal/config" customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" mockemail "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_email" mocksms "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_sms" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" + + // "github.com/SamuelTariku/FortuneBet-Backend/internal/router" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" @@ -26,7 +29,11 @@ import ( "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" + 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" httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" @@ -46,6 +53,7 @@ import ( // @name Authorization // @BasePath / func main() { + cfg, err := config.NewConfig() if err != nil { slog.Error(" Config error:", "err", err) @@ -84,6 +92,19 @@ func main() { notificationSvc := notificationservice.New(notificationRepo, logger, cfg) referalSvc := referralservice.New(referalRepo, *walletSvc, store, cfg, logger) virtualGameSvc := virtualgameservice.New(vitualGameRepo, *walletSvc, store, cfg, logger) + aleaService := alea.NewAleaPlayService( + vitualGameRepo, + *walletSvc, + cfg, + logger, + ) + + veliService := veli.NewVeliPlayService( + vitualGameRepo, + *walletSvc, + cfg, + logger, + ) httpserver.StartDataFetchingCrons(eventSvc, oddsSvc, resultSvc) httpserver.StartTicketCrons(*ticketSvc) @@ -92,7 +113,7 @@ func main() { JwtAccessKey: cfg.JwtKey, JwtAccessExpiry: cfg.AccessExpiry, }, userSvc, - ticketSvc, betSvc, walletSvc, transactionSvc, branchSvc, companySvc, notificationSvc, oddsSvc, eventSvc, referalSvc, virtualGameSvc, resultSvc) + 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 c3a8ea7..5836188 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -129,6 +129,489 @@ const docTemplate = `{ } } }, + "/admin/{id}": { + "get": { + "description": "Get a single admin by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Get admin by id", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "put": { + "description": "Update Admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Update Admin", + "parameters": [ + { + "description": "Update Admin", + "name": "admin", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateAdminReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/api/v1/alea-games/launch": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Generates an authenticated launch URL for Alea Play virtual games", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Alea Virtual Games" + ], + "summary": "Launch an Alea Play virtual game", + "parameters": [ + { + "type": "string", + "description": "Game identifier (e.g., 'aviator', 'plinko')", + "name": "game_id", + "in": "query", + "required": true + }, + { + "enum": [ + "USD", + "EUR", + "GBP" + ], + "type": "string", + "default": "USD", + "description": "Currency code (ISO 4217)", + "name": "currency", + "in": "query" + }, + { + "enum": [ + "real", + "demo" + ], + "type": "string", + "default": "real", + "description": "Game mode", + "name": "mode", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns authenticated game launch URL", + "schema": { + "type": "object", + "additionalProperties": { + "allOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "launch_url": { + "type": "string" + } + } + } + ] + } + } + } + } + } + }, + "/api/v1/chapa/banks": { + "get": { + "description": "Fetch all supported banks from Chapa", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Get list of banks", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.ChapaSupportedBanksResponse" + } + } + } + } + }, + "/api/v1/chapa/payments/callback": { + "post": { + "description": "Endpoint to receive webhook payloads from Chapa", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Receive Chapa webhook", + "parameters": [ + { + "description": "Webhook Payload (dynamic)", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/chapa/payments/initialize": { + "post": { + "description": "Initiate a payment through Chapa", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Initialize a payment transaction", + "parameters": [ + { + "description": "Payment initialization request", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.InitPaymentRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.InitPaymentResponse" + } + } + } + } + }, + "/api/v1/chapa/payments/verify/{tx_ref}": { + "get": { + "description": "Verify the transaction status from Chapa using tx_ref", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Verify a payment transaction", + "parameters": [ + { + "type": "string", + "description": "Transaction Reference", + "name": "tx_ref", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.VerifyTransactionResponse" + } + } + } + } + }, + "/api/v1/chapa/transfers": { + "post": { + "description": "Initiate a transfer request via Chapa", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Create a money transfer", + "parameters": [ + { + "description": "Transfer request body", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.TransferRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.CreateTransferResponse" + } + } + } + } + }, + "/api/v1/chapa/transfers/verify/{transfer_ref}": { + "get": { + "description": "Check the status of a money transfer via reference", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Verify a transfer", + "parameters": [ + { + "type": "string", + "description": "Transfer Reference", + "name": "transfer_ref", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.VerifyTransferResponse" + } + } + } + } + }, + "/api/v1/webhooks/alea": { + "post": { + "description": "Handles webhook callbacks from Alea Play virtual games for bet settlement", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Alea Virtual Games" + ], + "summary": "Process Alea Play game callback", + "parameters": [ + { + "description": "Callback payload", + "name": "callback", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.AleaPlayCallback" + } + } + ], + "responses": { + "200": { + "description": "Callback processed successfully", + "schema": { + "type": "object", + "additionalProperties": { + "allOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + ] + } + } + } + } + } + }, + "/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", @@ -988,6 +1471,56 @@ const docTemplate = `{ } } }, + "/cashier/{id}": { + "get": { + "description": "Get a single cashier by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cashier" + ], + "summary": "Get cashier by id", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.UserProfileRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/cashiers": { "get": { "description": "Get all cashiers", @@ -1120,7 +1653,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.updateUserReq" + "$ref": "#/definitions/handlers.updateCashierReq" } } ], @@ -1571,6 +2104,54 @@ const docTemplate = `{ } }, "/managers/{id}": { + "get": { + "description": "Get a single manager by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "manager" + ], + "summary": "Get manager by id", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ManagersRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, "put": { "description": "Update Managers", "consumes": [ @@ -1580,7 +2161,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Managers" + "manager" ], "summary": "Update Managers", "parameters": [ @@ -1590,7 +2171,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.updateUserReq" + "$ref": "#/definitions/handlers.updateManagerReq" } } ], @@ -3509,9 +4090,121 @@ 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": { + "domain.AleaPlayCallback": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "game_id": { + "type": "string" + }, + "is_free_round": { + "type": "boolean" + }, + "multiplier": { + "type": "number" + }, + "operator_id": { + "type": "string" + }, + "round_id": { + "type": "string" + }, + "session_id": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "transaction_id": { + "type": "string" + }, + "type": { + "description": "BET, WIN, CASHOUT, etc.", + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, "domain.BetOutcome": { "type": "object", "properties": { @@ -3640,6 +4333,73 @@ const docTemplate = `{ } } }, + "domain.ChapaSupportedBank": { + "type": "object", + "properties": { + "acct_length": { + "type": "integer" + }, + "acct_number_regex": { + "type": "string" + }, + "active": { + "type": "integer" + }, + "country_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "example_value": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_24hrs": { + "type": "integer" + }, + "is_active": { + "type": "integer" + }, + "is_mobilemoney": { + "type": "integer" + }, + "is_rtgs": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "swift": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "domain.ChapaSupportedBanksResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.ChapaSupportedBank" + } + }, + "message": { + "type": "string" + } + } + }, "domain.CreateBetOutcomeReq": { "type": "object", "properties": { @@ -3692,6 +4452,76 @@ const docTemplate = `{ } } }, + "domain.CreateTransferResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.TransferData" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "domain.InitPaymentData": { + "type": "object", + "properties": { + "checkout_url": { + "type": "string" + }, + "tx_ref": { + "type": "string" + } + } + }, + "domain.InitPaymentRequest": { + "type": "object", + "properties": { + "amount": { + "type": "string" + }, + "callback_url": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "email": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "return_url": { + "type": "string" + }, + "tx_ref": { + "type": "string" + } + } + }, + "domain.InitPaymentResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.InitPaymentData" + }, + "message": { + "description": "e.g., \"Payment initialized\"", + "type": "string" + }, + "status": { + "description": "\"success\"", + "type": "string" + } + } + }, "domain.Odd": { "type": "object", "properties": { @@ -3982,6 +4812,86 @@ const docTemplate = `{ } } }, + "domain.TransactionData": { + "type": "object", + "properties": { + "amount": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "email": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tx_ref": { + "type": "string" + } + } + }, + "domain.TransferData": { + "type": "object", + "properties": { + "amount": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "domain.TransferRequest": { + "type": "object", + "properties": { + "account_number": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "bank_code": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "recipient_name": { + "type": "string" + }, + "reference": { + "type": "string" + } + } + }, + "domain.TransferVerificationData": { + "type": "object", + "properties": { + "account_name": { + "type": "string" + }, + "bank_code": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "domain.UpcomingEvent": { "type": "object", "properties": { @@ -4039,6 +4949,79 @@ 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": { + "data": { + "$ref": "#/definitions/domain.TransactionData" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "domain.VerifyTransferResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.TransferVerificationData" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "handlers.AdminRes": { "type": "object", "properties": { @@ -4504,10 +5487,6 @@ const docTemplate = `{ "handlers.CustomerWalletRes": { "type": "object", "properties": { - "company_id": { - "type": "integer", - "example": 1 - }, "created_at": { "type": "string" }, @@ -4546,6 +5525,9 @@ const docTemplate = `{ "handlers.GetCashierRes": { "type": "object", "properties": { + "branch_id": { + "type": "integer" + }, "created_at": { "type": "string" }, @@ -4719,8 +5701,11 @@ const docTemplate = `{ "handlers.SearchUserByNameOrPhoneReq": { "type": "object", "properties": { - "searchString": { + "query": { "type": "string" + }, + "role": { + "$ref": "#/definitions/domain.Role" } } }, @@ -5157,9 +6142,51 @@ const docTemplate = `{ } } }, - "handlers.updateUserReq": { + "handlers.updateAdminReq": { "type": "object", "properties": { + "company_id": { + "type": "integer", + "example": 1 + }, + "first_name": { + "type": "string", + "example": "John" + }, + "last_name": { + "type": "string", + "example": "Doe" + }, + "suspended": { + "type": "boolean", + "example": false + } + } + }, + "handlers.updateCashierReq": { + "type": "object", + "properties": { + "first_name": { + "type": "string", + "example": "John" + }, + "last_name": { + "type": "string", + "example": "Doe" + }, + "suspended": { + "type": "boolean", + "example": false + } + } + }, + "handlers.updateManagerReq": { + "type": "object", + "properties": { + "company_id": { + "type": "integer", + "example": 1 + }, "first_name": { "type": "string", "example": "John" diff --git a/docs/swagger.json b/docs/swagger.json index 272d16b..15ade76 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -121,6 +121,489 @@ } } }, + "/admin/{id}": { + "get": { + "description": "Get a single admin by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Get admin by id", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "put": { + "description": "Update Admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Update Admin", + "parameters": [ + { + "description": "Update Admin", + "name": "admin", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateAdminReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/api/v1/alea-games/launch": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Generates an authenticated launch URL for Alea Play virtual games", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Alea Virtual Games" + ], + "summary": "Launch an Alea Play virtual game", + "parameters": [ + { + "type": "string", + "description": "Game identifier (e.g., 'aviator', 'plinko')", + "name": "game_id", + "in": "query", + "required": true + }, + { + "enum": [ + "USD", + "EUR", + "GBP" + ], + "type": "string", + "default": "USD", + "description": "Currency code (ISO 4217)", + "name": "currency", + "in": "query" + }, + { + "enum": [ + "real", + "demo" + ], + "type": "string", + "default": "real", + "description": "Game mode", + "name": "mode", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns authenticated game launch URL", + "schema": { + "type": "object", + "additionalProperties": { + "allOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "launch_url": { + "type": "string" + } + } + } + ] + } + } + } + } + } + }, + "/api/v1/chapa/banks": { + "get": { + "description": "Fetch all supported banks from Chapa", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Get list of banks", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.ChapaSupportedBanksResponse" + } + } + } + } + }, + "/api/v1/chapa/payments/callback": { + "post": { + "description": "Endpoint to receive webhook payloads from Chapa", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Receive Chapa webhook", + "parameters": [ + { + "description": "Webhook Payload (dynamic)", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/chapa/payments/initialize": { + "post": { + "description": "Initiate a payment through Chapa", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Initialize a payment transaction", + "parameters": [ + { + "description": "Payment initialization request", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.InitPaymentRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.InitPaymentResponse" + } + } + } + } + }, + "/api/v1/chapa/payments/verify/{tx_ref}": { + "get": { + "description": "Verify the transaction status from Chapa using tx_ref", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Verify a payment transaction", + "parameters": [ + { + "type": "string", + "description": "Transaction Reference", + "name": "tx_ref", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.VerifyTransactionResponse" + } + } + } + } + }, + "/api/v1/chapa/transfers": { + "post": { + "description": "Initiate a transfer request via Chapa", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Create a money transfer", + "parameters": [ + { + "description": "Transfer request body", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.TransferRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.CreateTransferResponse" + } + } + } + } + }, + "/api/v1/chapa/transfers/verify/{transfer_ref}": { + "get": { + "description": "Check the status of a money transfer via reference", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Verify a transfer", + "parameters": [ + { + "type": "string", + "description": "Transfer Reference", + "name": "transfer_ref", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.VerifyTransferResponse" + } + } + } + } + }, + "/api/v1/webhooks/alea": { + "post": { + "description": "Handles webhook callbacks from Alea Play virtual games for bet settlement", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Alea Virtual Games" + ], + "summary": "Process Alea Play game callback", + "parameters": [ + { + "description": "Callback payload", + "name": "callback", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.AleaPlayCallback" + } + } + ], + "responses": { + "200": { + "description": "Callback processed successfully", + "schema": { + "type": "object", + "additionalProperties": { + "allOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + ] + } + } + } + } + } + }, + "/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", @@ -980,6 +1463,56 @@ } } }, + "/cashier/{id}": { + "get": { + "description": "Get a single cashier by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cashier" + ], + "summary": "Get cashier by id", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.UserProfileRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/cashiers": { "get": { "description": "Get all cashiers", @@ -1112,7 +1645,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.updateUserReq" + "$ref": "#/definitions/handlers.updateCashierReq" } } ], @@ -1563,6 +2096,54 @@ } }, "/managers/{id}": { + "get": { + "description": "Get a single manager by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "manager" + ], + "summary": "Get manager by id", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ManagersRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, "put": { "description": "Update Managers", "consumes": [ @@ -1572,7 +2153,7 @@ "application/json" ], "tags": [ - "Managers" + "manager" ], "summary": "Update Managers", "parameters": [ @@ -1582,7 +2163,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.updateUserReq" + "$ref": "#/definitions/handlers.updateManagerReq" } } ], @@ -3501,9 +4082,121 @@ } } } + }, + "/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": { + "domain.AleaPlayCallback": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "game_id": { + "type": "string" + }, + "is_free_round": { + "type": "boolean" + }, + "multiplier": { + "type": "number" + }, + "operator_id": { + "type": "string" + }, + "round_id": { + "type": "string" + }, + "session_id": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "transaction_id": { + "type": "string" + }, + "type": { + "description": "BET, WIN, CASHOUT, etc.", + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, "domain.BetOutcome": { "type": "object", "properties": { @@ -3632,6 +4325,73 @@ } } }, + "domain.ChapaSupportedBank": { + "type": "object", + "properties": { + "acct_length": { + "type": "integer" + }, + "acct_number_regex": { + "type": "string" + }, + "active": { + "type": "integer" + }, + "country_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "example_value": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_24hrs": { + "type": "integer" + }, + "is_active": { + "type": "integer" + }, + "is_mobilemoney": { + "type": "integer" + }, + "is_rtgs": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "swift": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "domain.ChapaSupportedBanksResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.ChapaSupportedBank" + } + }, + "message": { + "type": "string" + } + } + }, "domain.CreateBetOutcomeReq": { "type": "object", "properties": { @@ -3684,6 +4444,76 @@ } } }, + "domain.CreateTransferResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.TransferData" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "domain.InitPaymentData": { + "type": "object", + "properties": { + "checkout_url": { + "type": "string" + }, + "tx_ref": { + "type": "string" + } + } + }, + "domain.InitPaymentRequest": { + "type": "object", + "properties": { + "amount": { + "type": "string" + }, + "callback_url": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "email": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "return_url": { + "type": "string" + }, + "tx_ref": { + "type": "string" + } + } + }, + "domain.InitPaymentResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.InitPaymentData" + }, + "message": { + "description": "e.g., \"Payment initialized\"", + "type": "string" + }, + "status": { + "description": "\"success\"", + "type": "string" + } + } + }, "domain.Odd": { "type": "object", "properties": { @@ -3974,6 +4804,86 @@ } } }, + "domain.TransactionData": { + "type": "object", + "properties": { + "amount": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "email": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tx_ref": { + "type": "string" + } + } + }, + "domain.TransferData": { + "type": "object", + "properties": { + "amount": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "domain.TransferRequest": { + "type": "object", + "properties": { + "account_number": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "bank_code": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "recipient_name": { + "type": "string" + }, + "reference": { + "type": "string" + } + } + }, + "domain.TransferVerificationData": { + "type": "object", + "properties": { + "account_name": { + "type": "string" + }, + "bank_code": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "domain.UpcomingEvent": { "type": "object", "properties": { @@ -4031,6 +4941,79 @@ } } }, + "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": { + "data": { + "$ref": "#/definitions/domain.TransactionData" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "domain.VerifyTransferResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.TransferVerificationData" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "handlers.AdminRes": { "type": "object", "properties": { @@ -4496,10 +5479,6 @@ "handlers.CustomerWalletRes": { "type": "object", "properties": { - "company_id": { - "type": "integer", - "example": 1 - }, "created_at": { "type": "string" }, @@ -4538,6 +5517,9 @@ "handlers.GetCashierRes": { "type": "object", "properties": { + "branch_id": { + "type": "integer" + }, "created_at": { "type": "string" }, @@ -4711,8 +5693,11 @@ "handlers.SearchUserByNameOrPhoneReq": { "type": "object", "properties": { - "searchString": { + "query": { "type": "string" + }, + "role": { + "$ref": "#/definitions/domain.Role" } } }, @@ -5149,9 +6134,51 @@ } } }, - "handlers.updateUserReq": { + "handlers.updateAdminReq": { "type": "object", "properties": { + "company_id": { + "type": "integer", + "example": 1 + }, + "first_name": { + "type": "string", + "example": "John" + }, + "last_name": { + "type": "string", + "example": "Doe" + }, + "suspended": { + "type": "boolean", + "example": false + } + } + }, + "handlers.updateCashierReq": { + "type": "object", + "properties": { + "first_name": { + "type": "string", + "example": "John" + }, + "last_name": { + "type": "string", + "example": "Doe" + }, + "suspended": { + "type": "boolean", + "example": false + } + } + }, + "handlers.updateManagerReq": { + "type": "object", + "properties": { + "company_id": { + "type": "integer", + "example": 1 + }, "first_name": { "type": "string", "example": "John" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 3dd0f4b..7ddc40e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,4 +1,36 @@ definitions: + domain.AleaPlayCallback: + properties: + amount: + type: number + currency: + type: string + event_id: + type: string + game_id: + type: string + is_free_round: + type: boolean + multiplier: + type: number + operator_id: + type: string + round_id: + type: string + session_id: + type: string + signature: + type: string + timestamp: + type: integer + transaction_id: + type: string + type: + description: BET, WIN, CASHOUT, etc. + type: string + user_id: + type: string + type: object domain.BetOutcome: properties: away_team_name: @@ -89,6 +121,50 @@ definitions: example: 2 type: integer type: object + domain.ChapaSupportedBank: + properties: + acct_length: + type: integer + acct_number_regex: + type: string + active: + type: integer + country_id: + type: integer + created_at: + type: string + currency: + type: string + example_value: + type: string + id: + type: integer + is_24hrs: + type: integer + is_active: + type: integer + is_mobilemoney: + type: integer + is_rtgs: + type: integer + name: + type: string + slug: + type: string + swift: + type: string + updated_at: + type: string + type: object + domain.ChapaSupportedBanksResponse: + properties: + data: + items: + $ref: '#/definitions/domain.ChapaSupportedBank' + type: array + message: + type: string + type: object domain.CreateBetOutcomeReq: properties: event_id: @@ -124,6 +200,52 @@ definitions: - $ref: '#/definitions/domain.OutcomeStatus' example: 1 type: object + domain.CreateTransferResponse: + properties: + data: + $ref: '#/definitions/domain.TransferData' + message: + type: string + status: + type: string + type: object + domain.InitPaymentData: + properties: + checkout_url: + type: string + tx_ref: + type: string + type: object + domain.InitPaymentRequest: + properties: + amount: + type: string + callback_url: + type: string + currency: + type: string + email: + type: string + first_name: + type: string + last_name: + type: string + return_url: + type: string + tx_ref: + type: string + type: object + domain.InitPaymentResponse: + properties: + data: + $ref: '#/definitions/domain.InitPaymentData' + message: + description: e.g., "Payment initialized" + type: string + status: + description: '"success"' + type: string + type: object domain.Odd: properties: category: @@ -329,6 +451,58 @@ definitions: example: 1 type: integer type: object + domain.TransactionData: + properties: + amount: + type: string + currency: + type: string + email: + type: string + status: + type: string + tx_ref: + type: string + type: object + domain.TransferData: + properties: + amount: + type: string + currency: + type: string + reference: + type: string + status: + type: string + type: object + domain.TransferRequest: + properties: + account_number: + type: string + amount: + type: string + bank_code: + type: string + currency: + type: string + reason: + type: string + recipient_name: + type: string + reference: + type: string + type: object + domain.TransferVerificationData: + properties: + account_name: + type: string + bank_code: + type: string + reference: + type: string + status: + type: string + type: object domain.UpcomingEvent: properties: awayKitImage: @@ -371,6 +545,57 @@ 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: + $ref: '#/definitions/domain.TransactionData' + message: + type: string + status: + type: string + type: object + domain.VerifyTransferResponse: + properties: + data: + $ref: '#/definitions/domain.TransferVerificationData' + message: + type: string + status: + type: string + type: object handlers.AdminRes: properties: created_at: @@ -699,9 +924,6 @@ definitions: type: object handlers.CustomerWalletRes: properties: - company_id: - example: 1 - type: integer created_at: type: string customer_id: @@ -729,6 +951,8 @@ definitions: type: object handlers.GetCashierRes: properties: + branch_id: + type: integer created_at: type: string email: @@ -848,8 +1072,10 @@ definitions: type: object handlers.SearchUserByNameOrPhoneReq: properties: - searchString: + query: type: string + role: + $ref: '#/definitions/domain.Role' type: object handlers.SupportedOperationRes: properties: @@ -1152,8 +1378,38 @@ definitions: - access_token - refresh_token type: object - handlers.updateUserReq: + handlers.updateAdminReq: properties: + company_id: + example: 1 + type: integer + first_name: + example: John + type: string + last_name: + example: Doe + type: string + suspended: + example: false + type: boolean + type: object + handlers.updateCashierReq: + properties: + first_name: + example: John + type: string + last_name: + example: Doe + type: string + suspended: + example: false + type: boolean + type: object + handlers.updateManagerReq: + properties: + company_id: + example: 1 + type: integer first_name: example: John type: string @@ -1269,6 +1525,318 @@ paths: summary: Create Admin tags: - admin + /admin/{id}: + get: + consumes: + - application/json + description: Get a single admin by id + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.AdminRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Get admin by id + tags: + - admin + put: + consumes: + - application/json + description: Update Admin + parameters: + - description: Update Admin + in: body + name: admin + required: true + schema: + $ref: '#/definitions/handlers.updateAdminReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Update Admin + tags: + - admin + /api/v1/alea-games/launch: + get: + consumes: + - application/json + description: Generates an authenticated launch URL for Alea Play virtual games + parameters: + - description: Game identifier (e.g., 'aviator', 'plinko') + in: query + name: game_id + required: true + type: string + - default: USD + description: Currency code (ISO 4217) + enum: + - USD + - EUR + - GBP + in: query + name: currency + type: string + - default: real + description: Game mode + enum: + - real + - demo + in: query + name: mode + type: string + produces: + - application/json + responses: + "200": + description: Returns authenticated game launch URL + schema: + additionalProperties: + allOf: + - type: string + - properties: + launch_url: + type: string + type: object + type: object + security: + - BearerAuth: [] + summary: Launch an Alea Play virtual game + tags: + - Alea Virtual Games + /api/v1/chapa/banks: + get: + consumes: + - application/json + description: Fetch all supported banks from Chapa + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.ChapaSupportedBanksResponse' + summary: Get list of banks + tags: + - Chapa + /api/v1/chapa/payments/callback: + post: + consumes: + - application/json + description: Endpoint to receive webhook payloads from Chapa + parameters: + - description: Webhook Payload (dynamic) + in: body + name: payload + required: true + schema: + type: object + produces: + - application/json + responses: + "200": + description: ok + schema: + type: string + summary: Receive Chapa webhook + tags: + - Chapa + /api/v1/chapa/payments/initialize: + post: + consumes: + - application/json + description: Initiate a payment through Chapa + parameters: + - description: Payment initialization request + in: body + name: payload + required: true + schema: + $ref: '#/definitions/domain.InitPaymentRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.InitPaymentResponse' + summary: Initialize a payment transaction + tags: + - Chapa + /api/v1/chapa/payments/verify/{tx_ref}: + get: + consumes: + - application/json + description: Verify the transaction status from Chapa using tx_ref + parameters: + - description: Transaction Reference + in: path + name: tx_ref + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.VerifyTransactionResponse' + summary: Verify a payment transaction + tags: + - Chapa + /api/v1/chapa/transfers: + post: + consumes: + - application/json + description: Initiate a transfer request via Chapa + parameters: + - description: Transfer request body + in: body + name: payload + required: true + schema: + $ref: '#/definitions/domain.TransferRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.CreateTransferResponse' + summary: Create a money transfer + tags: + - Chapa + /api/v1/chapa/transfers/verify/{transfer_ref}: + get: + consumes: + - application/json + description: Check the status of a money transfer via reference + parameters: + - description: Transfer Reference + in: path + name: transfer_ref + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.VerifyTransferResponse' + summary: Verify a transfer + tags: + - Chapa + /api/v1/webhooks/alea: + post: + consumes: + - application/json + description: Handles webhook callbacks from Alea Play virtual games for bet + settlement + parameters: + - description: Callback payload + in: body + name: callback + required: true + schema: + $ref: '#/definitions/domain.AleaPlayCallback' + produces: + - application/json + responses: + "200": + description: Callback processed successfully + schema: + additionalProperties: + allOf: + - type: string + - properties: + status: + type: string + type: object + type: object + 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: @@ -1836,6 +2404,39 @@ paths: summary: Get all branch wallets tags: - wallet + /cashier/{id}: + get: + consumes: + - application/json + description: Get a single cashier by id + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.UserProfileRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Get cashier by id + tags: + - cashier /cashiers: get: consumes: @@ -1921,7 +2522,7 @@ paths: name: cashier required: true schema: - $ref: '#/definitions/handlers.updateUserReq' + $ref: '#/definitions/handlers.updateCashierReq' produces: - application/json responses: @@ -2221,6 +2822,38 @@ paths: tags: - manager /managers/{id}: + get: + consumes: + - application/json + description: Get a single manager by id + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ManagersRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Get manager by id + tags: + - manager put: consumes: - application/json @@ -2231,7 +2864,7 @@ paths: name: Managers required: true schema: - $ref: '#/definitions/handlers.updateUserReq' + $ref: '#/definitions/handlers.updateManagerReq' produces: - application/json responses: @@ -2253,7 +2886,7 @@ paths: $ref: '#/definitions/response.APIResponse' summary: Update Managers tags: - - Managers + - manager /operation: post: consumes: @@ -3488,6 +4121,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/go.mod b/go.mod index 308822c..5a55392 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/andybalholm/brotli v1.1.1 // indirect + // github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect diff --git a/internal/config/config.go b/internal/config/config.go index e58f153..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" @@ -21,13 +22,37 @@ var ( ErrInvalidLevel = errors.New("invalid log level") ErrInvalidEnv = errors.New("env not set or invalid") ErrInvalidSMSAPIKey = errors.New("SMS API key is invalid") - ErrMissingBetToken = errors.New("missing BET365_TOKEN in .env") + ErrMissingBetToken = errors.New("missing BET365_TOKEN in .env") 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") + 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 + 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 @@ -40,8 +65,16 @@ type Config struct { AFRO_SMS_SENDER_NAME string AFRO_SMS_RECEIVER_PHONE_NUMBER string ADRO_SMS_HOST_URL string - Bet365Token string + CHAPA_SECRET_KEY string + CHAPA_PUBLIC_KEY string + CHAPA_BASE_URL string + CHAPA_ENCRYPTION_KEY string + CHAPA_CALLBACK_URL string + CHAPA_RETURN_URL string + Bet365Token string PopOK domain.PopOKConfig + AleaPlay AleaPlayConfig `mapstructure:"alea_play"` + VeliGames VeliGamesConfig `mapstructure:"veli_games"` } func NewConfig() (*Config, error) { @@ -115,6 +148,96 @@ func (c *Config) loadEnv() error { if !ok { 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") + c.CHAPA_BASE_URL = os.Getenv("CHAPA_BASE_URL") + if c.CHAPA_BASE_URL == "" { + c.CHAPA_BASE_URL = "https://api.chapa.co/v1" + } + 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/chapa.go b/internal/domain/chapa.go index 7d0427a..f630a6d 100644 --- a/internal/domain/chapa.go +++ b/internal/domain/chapa.go @@ -1 +1,107 @@ -package domain \ No newline at end of file +package domain + +import "time" + +var ( + ChapaSecret string + ChapaBaseURL string +) + +type InitPaymentRequest struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + TxRef string `json:"tx_ref"` + CallbackURL string `json:"callback_url"` + ReturnURL string `json:"return_url"` +} + +type TransferRequest struct { + AccountNumber string `json:"account_number"` + BankCode string `json:"bank_code"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Reference string `json:"reference"` + Reason string `json:"reason"` + RecipientName string `json:"recipient_name"` +} + +type ChapaSupportedBank struct { + Id int64 `json:"id"` + Slug string `json:"slug"` + Swift string `json:"swift"` + Name string `json:"name"` + AcctLength int `json:"acct_length"` + AcctNumberRegex string `json:"acct_number_regex"` + ExampleValue string `json:"example_value"` + CountryId int `json:"country_id"` + IsMobilemoney *int `json:"is_mobilemoney"` + + IsActive int `json:"is_active"` + IsRtgs *int `json:"is_rtgs"` + Active int `json:"active"` + Is24Hrs *int `json:"is_24hrs"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Currency string `json:"currency"` +} + +type ChapaSupportedBanksResponse struct { + Message string `json:"message"` + Data []ChapaSupportedBank `json:"data"` +} + +type InitPaymentData struct { + TxRef string `json:"tx_ref"` + CheckoutURL string `json:"checkout_url"` +} + +type InitPaymentResponse struct { + Status string `json:"status"` // "success" + Message string `json:"message"` // e.g., "Payment initialized" + Data InitPaymentData `json:"data"` +} + +type WebhookPayload map[string]interface{} + +type TransactionData struct { + TxRef string `json:"tx_ref"` + Status string `json:"status"` + Amount string `json:"amount"` + Currency string `json:"currency"` + CustomerEmail string `json:"email"` +} + +type VerifyTransactionResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data TransactionData `json:"data"` +} + +type TransferData struct { + Reference string `json:"reference"` + Status string `json:"status"` + Amount string `json:"amount"` + Currency string `json:"currency"` +} + +type CreateTransferResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data TransferData `json:"data"` +} + +type TransferVerificationData struct { + Reference string `json:"reference"` + Status string `json:"status"` + BankCode string `json:"bank_code"` + AccountName string `json:"account_name"` +} + +type VerifyTransferResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data TransferVerificationData `json:"data"` +} diff --git a/internal/domain/virtual_game.go b/internal/domain/virtual_game.go index 8b981af..3663aee 100644 --- a/internal/domain/virtual_game.go +++ b/internal/domain/virtual_game.go @@ -14,6 +14,11 @@ type VirtualGameSession struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ExpiresAt time.Time `json:"expires_at"` + + // Alea Play specific fields + ExternalSessionID string `json:"external_session_id"` // Alea's session reference + OperatorID string `json:"operator_id"` // Your operator ID with Alea + GameMode string `json:"game_mode"` // real, demo, tournament } type VirtualGameTransaction struct { @@ -21,15 +26,38 @@ type VirtualGameTransaction struct { 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"` + TransactionType string `json:"transaction_type"` // BET, WIN, REFUND, CASHOUT, etc. + Amount int64 `json:"amount"` // Always in cents 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"` + + // Alea Play specific fields + GameRoundID string `json:"game_round_id"` // Round identifier + 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 { +// 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 @@ -53,3 +81,39 @@ type PopOKCallback struct { Timestamp int64 `json:"timestamp"` Signature string `json:"signature"` // HMAC-SHA256 signature for verification } + +type AleaPlayCallback struct { + EventID string `json:"event_id"` + TransactionID string `json:"transaction_id"` + SessionID string `json:"session_id"` + UserID string `json:"user_id"` + GameID string `json:"game_id"` + Type string `json:"type"` // BET, WIN, CASHOUT, etc. + Amount float64 `json:"amount"` + Currency string `json:"currency"` + RoundID string `json:"round_id"` + Multiplier float64 `json:"multiplier"` + IsFreeRound bool `json:"is_free_round"` + OperatorID string `json:"operator_id"` + Timestamp int64 `json:"timestamp"` + Signature string `json:"signature"` +} + +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/middleware/alea.go b/internal/middleware/alea.go new file mode 100644 index 0000000..36e3f1b --- /dev/null +++ b/internal/middleware/alea.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + + "github.com/gofiber/fiber/v2" +) + +func AleaWebhookMiddleware(secretKey string) fiber.Handler { + return func(c *fiber.Ctx) error { + // Verify IP comes from Alea's allowed IPs + // OR verify a signature header + + // Example signature verification: + receivedSig := c.Get("X-Alea-Signature") + body := c.Body() + + h := hmac.New(sha256.New, []byte(secretKey)) + h.Write(body) + expectedSig := hex.EncodeToString(h.Sum(nil)) + + if receivedSig != expectedSig { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "invalid signature", + }) + } + + return c.Next() + } +} + +// Then update your route: diff --git a/internal/repository/virtual_game.go b/internal/repository/virtual_game.go index cfa6fee..0fa5429 100644 --- a/internal/repository/virtual_game.go +++ b/internal/repository/virtual_game.go @@ -14,6 +14,7 @@ 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 + // 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 diff --git a/internal/services/virtualGame/Alea/port.go b/internal/services/virtualGame/Alea/port.go new file mode 100644 index 0000000..c5d4ac0 --- /dev/null +++ b/internal/services/virtualGame/Alea/port.go @@ -0,0 +1,12 @@ +package alea + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type AleaVirtualGameService interface { + GenerateGameLaunchURL(ctx context.Context, userID int64, gameID, currency, mode string) (string, error) + HandleCallback(ctx context.Context, callback *domain.AleaPlayCallback) error +} diff --git a/internal/services/virtualGame/Alea/service.go b/internal/services/virtualGame/Alea/service.go new file mode 100644 index 0000000..aadd179 --- /dev/null +++ b/internal/services/virtualGame/Alea/service.go @@ -0,0 +1,159 @@ +package alea + +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 AleaPlayService struct { + repo repository.VirtualGameRepository + walletSvc wallet.Service + config *config.AleaPlayConfig + logger *slog.Logger +} + +func NewAleaPlayService( + repo repository.VirtualGameRepository, + walletSvc wallet.Service, + cfg *config.Config, + logger *slog.Logger, +) *AleaPlayService { + return &AleaPlayService{ + repo: repo, + walletSvc: walletSvc, + config: &cfg.AleaPlay, + logger: logger, + } +} + +func (s *AleaPlayService) 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) + } + + params := url.Values{ + "operator_id": []string{s.config.OperatorID}, + "user_id": []string{fmt.Sprintf("%d", userID)}, + "game_id": []string{gameID}, + "currency": []string{currency}, + "session_token": []string{session.SessionToken}, + "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.BaseURL, params.Encode()), nil +} + +func (s *AleaPlayService) HandleCallback(ctx context.Context, callback *domain.AleaPlayCallback) error { + if !s.verifyCallbackSignature(callback) { + return errors.New("invalid callback signature") + } + + if existing, _ := s.repo.GetVirtualGameTransactionByExternalID(ctx, callback.TransactionID); existing != nil { + s.logger.Warn("duplicate transaction detected", "tx_id", callback.TransactionID) + return nil + } + + session, err := s.repo.GetVirtualGameSessionByToken(ctx, callback.SessionID) + if err != nil { + return fmt.Errorf("failed to get game session: %w", err) + } + + tx := &domain.VirtualGameTransaction{ + SessionID: session.ID, + UserID: session.UserID, + TransactionType: callback.Type, + Amount: convertAmount(callback.Amount, callback.Type), + Currency: callback.Currency, + ExternalTransactionID: callback.TransactionID, + Status: "COMPLETED", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := s.processTransaction(ctx, tx, session.UserID); err != nil { + return fmt.Errorf("failed to process transaction: %w", err) + } + + // Update session status using the proper repository method + if callback.Type == "SESSION_END" { + if err := s.repo.UpdateVirtualGameSessionStatus(ctx, session.ID, "COMPLETED"); err != nil { + s.logger.Error("failed to update session status", + "sessionID", session.ID, + "error", err) + } + } + + return nil +} + +func convertAmount(amount float64, txType string) int64 { + cents := int64(amount * 100) + if txType == "BET" { + return -cents + } + return cents +} + +func (s *AleaPlayService) 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) +} + +func (s *AleaPlayService) 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 *AleaPlayService) verifyCallbackSignature(cb *domain.AleaPlayCallback) bool { + signData := fmt.Sprintf("%s%s%s%.2f%s%d", + cb.TransactionID, + cb.SessionID, + cb.Type, + cb.Amount, + cb.Currency, + cb.Timestamp, + ) + expectedSig := s.generateSignature(signData) + return expectedSig == cb.Signature +} + +func generateSessionToken(userID int64) string { + return fmt.Sprintf("alea-%d-%d", userID, time.Now().UnixNano()) +} diff --git a/internal/services/virtualGame/port.go b/internal/services/virtualGame/port.go index d473355..0814a07 100644 --- a/internal/services/virtualGame/port.go +++ b/internal/services/virtualGame/port.go @@ -10,3 +10,4 @@ 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 index 1b5824e..688c8ef 100644 --- a/internal/services/virtualGame/service.go +++ b/internal/services/virtualGame/service.go @@ -32,8 +32,7 @@ func New(repo repository.VirtualGameRepository, walletSvc wallet.Service, store walletSvc: walletSvc, store: store, config: cfg, - logger: logger, - } + logger: logger} } func (s *service) GenerateGameLaunchURL(ctx context.Context, userID int64, gameID, currency, mode string) (string, error) { 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/services/wallet/chapa.go b/internal/services/wallet/chapa.go deleted file mode 100644 index 23a7507..0000000 --- a/internal/services/wallet/chapa.go +++ /dev/null @@ -1 +0,0 @@ -package wallet diff --git a/internal/web_server/app.go b/internal/web_server/app.go index e370f4e..5bbf4ae 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -4,6 +4,7 @@ import ( "fmt" "log/slog" + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" @@ -16,6 +17,8 @@ import ( "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" + 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" @@ -27,26 +30,29 @@ import ( ) type App struct { - fiber *fiber.App - logger *slog.Logger - NotidicationStore *notificationservice.Service - referralSvc referralservice.ReferralStore - port int - authSvc *authentication.Service - userSvc *user.Service - betSvc *bet.Service - virtualGameSvc virtualgameservice.VirtualGameService - walletSvc *wallet.Service - transactionSvc *transaction.Service - ticketSvc *ticket.Service - branchSvc *branch.Service - companySvc *company.Service - validator *customvalidator.CustomValidator - JwtConfig jwtutil.JwtConfig - Logger *slog.Logger - prematchSvc *odds.ServiceImpl - eventSvc event.Service - resultSvc *result.Service + fiber *fiber.App + aleaVirtualGameService alea.AleaVirtualGameService + veliVirtualGameService veli.VeliVirtualGameService + cfg *config.Config + logger *slog.Logger + NotidicationStore *notificationservice.Service + referralSvc referralservice.ReferralStore + port int + authSvc *authentication.Service + userSvc *user.Service + betSvc *bet.Service + virtualGameSvc virtualgameservice.VirtualGameService + walletSvc *wallet.Service + transactionSvc *transaction.Service + ticketSvc *ticket.Service + branchSvc *branch.Service + companySvc *company.Service + validator *customvalidator.CustomValidator + JwtConfig jwtutil.JwtConfig + Logger *slog.Logger + prematchSvc *odds.ServiceImpl + eventSvc event.Service + resultSvc *result.Service } func NewApp( @@ -66,7 +72,10 @@ func NewApp( eventSvc event.Service, referralSvc referralservice.ReferralStore, virtualGameSvc virtualgameservice.VirtualGameService, + aleaVirtualGameService alea.AleaVirtualGameService, + veliVirtualGameService veli.VeliVirtualGameService, resultSvc *result.Service, + cfg *config.Config, ) *App { app := fiber.New(fiber.Config{ CaseSensitive: true, @@ -83,26 +92,29 @@ func NewApp( })) s := &App{ - fiber: app, - port: port, - authSvc: authSvc, - validator: validator, - logger: logger, - JwtConfig: JwtConfig, - userSvc: userSvc, - ticketSvc: ticketSvc, - betSvc: betSvc, - walletSvc: walletSvc, - transactionSvc: transactionSvc, - branchSvc: branchSvc, - companySvc: companySvc, - NotidicationStore: notidicationStore, - referralSvc: referralSvc, - Logger: logger, - prematchSvc: prematchSvc, - eventSvc: eventSvc, - virtualGameSvc: virtualGameSvc, - resultSvc: resultSvc, + fiber: app, + port: port, + authSvc: authSvc, + validator: validator, + logger: logger, + JwtConfig: JwtConfig, + userSvc: userSvc, + ticketSvc: ticketSvc, + betSvc: betSvc, + walletSvc: walletSvc, + transactionSvc: transactionSvc, + branchSvc: branchSvc, + companySvc: companySvc, + NotidicationStore: notidicationStore, + referralSvc: referralSvc, + Logger: logger, + prematchSvc: prematchSvc, + eventSvc: eventSvc, + virtualGameSvc: virtualGameSvc, + aleaVirtualGameService: aleaVirtualGameService, + veliVirtualGameService: veliVirtualGameService, + resultSvc: resultSvc, + cfg: cfg, } s.initAppRoutes() diff --git a/internal/web_server/handlers/alea_games.go b/internal/web_server/handlers/alea_games.go new file mode 100644 index 0000000..0fc8ea3 --- /dev/null +++ b/internal/web_server/handlers/alea_games.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/gofiber/fiber/v2" +) + +// LaunchAleaGame godoc +// @Summary Launch an Alea Play virtual game +// @Description Generates an authenticated launch URL for Alea Play virtual games +// @Tags Alea Virtual Games +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param game_id query string true "Game identifier (e.g., 'aviator', 'plinko')" +// @Param currency query string false "Currency code (ISO 4217)" Enums(USD, EUR, GBP) default(USD) +// @Param mode query string false "Game mode" Enums(real, demo) default(real) +// @Success 200 {object} map[string]string{launch_url=string} "Returns authenticated game launch URL" +// @Router /api/v1/alea-games/launch [get] +func (h *Handler) LaunchAleaGame(c *fiber.Ctx) error { + userID := c.Locals("user_id").(int64) + gameID := c.Query("game_id") + currency := c.Query("currency", "USD") + mode := c.Query("mode", "real") // real or demo + + launchURL, err := h.aleaVirtualGameSvc.GenerateGameLaunchURL(c.Context(), userID, gameID, currency, mode) + if err != nil { + h.logger.Error("failed to generate Alea launch URL", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "failed to launch game", + }) + } + + return c.JSON(fiber.Map{ + "launch_url": launchURL, + "message": "Game launched successfully", + }) +} + +// HandleAleaCallback godoc +// @Summary Process Alea Play game callback +// @Description Handles webhook callbacks from Alea Play virtual games for bet settlement +// @Tags Alea Virtual Games +// @Accept json +// @Produce json +// @Param callback body domain.AleaPlayCallback true "Callback payload" +// @Success 200 {object} map[string]string{status=string} "Callback processed successfully" +// @Router /api/v1/webhooks/alea [post] +func (h *Handler) HandleAleaCallback(c *fiber.Ctx) error { + var cb domain.AleaPlayCallback + if err := c.BodyParser(&cb); err != nil { + h.logger.Error("invalid Alea callback format", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid callback format", + }) + } + + if err := h.aleaVirtualGameSvc.HandleCallback(c.Context(), &cb); err != nil { + h.logger.Error("failed to process Alea callback", + "transactionID", cb.TransactionID, + "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/handlers/chapa.go b/internal/web_server/handlers/chapa.go new file mode 100644 index 0000000..3fc66c0 --- /dev/null +++ b/internal/web_server/handlers/chapa.go @@ -0,0 +1,283 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +// GetBanks godoc +// @Summary Get list of banks +// @Description Fetch all supported banks from Chapa +// @Tags Chapa +// @Accept json +// @Produce json +// @Success 200 {object} domain.ChapaSupportedBanksResponse +// @Router /api/v1/chapa/banks [get] +func (h *Handler) GetBanks(c *fiber.Ctx) error { + httpReq, err := http.NewRequest("GET", h.Cfg.CHAPA_BASE_URL+"/banks", nil) + // log.Printf("\n\nbase url is: %v\n\n", h.Cfg.CHAPA_BASE_URL) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to create request", "details": err.Error()}) + } + httpReq.Header.Set("Authorization", "Bearer "+h.Cfg.CHAPA_SECRET_KEY) + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to fetch banks", "details": err.Error()}) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to read response", "details": err.Error()}) + } + + return c.Status(resp.StatusCode).Type("json").Send(body) +} + +// InitializePayment godoc +// @Summary Initialize a payment transaction +// @Description Initiate a payment through Chapa +// @Tags Chapa +// @Accept json +// @Produce json +// @Param payload body domain.InitPaymentRequest true "Payment initialization request" +// @Success 200 {object} domain.InitPaymentResponse +// @Router /api/v1/chapa/payments/initialize [post] +func (h *Handler) InitializePayment(c *fiber.Ctx) error { + var req InitPaymentRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request body", + "details": err.Error(), + }) + } + + // Generate and assign a unique transaction reference + req.TxRef = uuid.New().String() + + payload, err := json.Marshal(req) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": "Failed to serialize request", + "details": err.Error(), + }) + } + + httpReq, err := http.NewRequest("POST", h.Cfg.CHAPA_BASE_URL+"/transaction/initialize", bytes.NewBuffer(payload)) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": "Failed to create request", + "details": err.Error(), + }) + } + httpReq.Header.Set("Authorization", "Bearer "+h.Cfg.CHAPA_SECRET_KEY) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": "Failed to initialize payment", + "details": err.Error(), + }) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": "Failed to read response", + "details": err.Error(), + }) + } + + return c.Status(resp.StatusCode).Type("json").Send(body) +} + +// VerifyTransaction godoc +// @Summary Verify a payment transaction +// @Description Verify the transaction status from Chapa using tx_ref +// @Tags Chapa +// @Accept json +// @Produce json +// @Param tx_ref path string true "Transaction Reference" +// @Success 200 {object} domain.VerifyTransactionResponse +// @Router /api/v1/chapa/payments/verify/{tx_ref} [get] +func (h *Handler) VerifyTransaction(c *fiber.Ctx) error { + txRef := c.Params("tx_ref") + if txRef == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Missing transaction reference", + }) + } + + url := fmt.Sprintf("%s/transaction/verify/%s", h.Cfg.CHAPA_BASE_URL, txRef) + + httpReq, err := http.NewRequest("GET", url, nil) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": "Failed to create request", + "details": err.Error(), + }) + } + httpReq.Header.Set("Authorization", "Bearer "+h.Cfg.CHAPA_SECRET_KEY) + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": "Failed to verify transaction", + "details": err.Error(), + }) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": "Failed to read response", + "details": err.Error(), + }) + } + + return c.Status(resp.StatusCode).Type("json").Send(body) +} + +// ReceiveWebhook godoc +// @Summary Receive Chapa webhook +// @Description Endpoint to receive webhook payloads from Chapa +// @Tags Chapa +// @Accept json +// @Produce json +// @Param payload body object true "Webhook Payload (dynamic)" +// @Success 200 {string} string "ok" +// @Router /api/v1/chapa/payments/callback [post] +func (h *Handler) ReceiveWebhook(c *fiber.Ctx) error { + var payload map[string]interface{} + if err := c.BodyParser(&payload); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid webhook data", + "details": err.Error(), + }) + } + + h.logger.Info("Chapa webhook received", "payload", payload) + + // Optional: you can verify tx_ref here again if needed + + return c.SendStatus(fiber.StatusOK) +} + +// CreateTransfer godoc +// @Summary Create a money transfer +// @Description Initiate a transfer request via Chapa +// @Tags Chapa +// @Accept json +// @Produce json +// @Param payload body domain.TransferRequest true "Transfer request body" +// @Success 200 {object} domain.CreateTransferResponse +// @Router /api/v1/chapa/transfers [post] +func (h *Handler) CreateTransfer(c *fiber.Ctx) error { + var req TransferRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + "details": err.Error(), + }) + } + + // Inject unique transaction reference + req.Reference = uuid.New().String() + + payload, err := json.Marshal(req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to serialize request", + "details": err.Error(), + }) + } + + httpReq, err := http.NewRequest("POST", h.Cfg.CHAPA_BASE_URL+"/transfers", bytes.NewBuffer(payload)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create HTTP request", + "details": err.Error(), + }) + } + + httpReq.Header.Set("Authorization", "Bearer "+h.Cfg.CHAPA_SECRET_KEY) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Transfer request failed", + "details": err.Error(), + }) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to read response", + "details": err.Error(), + }) + } + + return c.Status(resp.StatusCode).Type("json").Send(body) +} + +// VerifyTransfer godoc +// @Summary Verify a transfer +// @Description Check the status of a money transfer via reference +// @Tags Chapa +// @Accept json +// @Produce json +// @Param transfer_ref path string true "Transfer Reference" +// @Success 200 {object} domain.VerifyTransferResponse +// @Router /api/v1/chapa/transfers/verify/{transfer_ref} [get] +func (h *Handler) VerifyTransfer(c *fiber.Ctx) error { + transferRef := c.Params("transfer_ref") + if transferRef == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Missing transfer reference in URL", + }) + } + + url := fmt.Sprintf("%s/transfers/verify/%s", h.Cfg.CHAPA_BASE_URL, transferRef) + + httpReq, err := http.NewRequest("GET", url, nil) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create HTTP request", + "details": err.Error(), + }) + } + + httpReq.Header.Set("Authorization", "Bearer "+h.Cfg.CHAPA_SECRET_KEY) + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Verification request failed", + "details": err.Error(), + }) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to read response body", + "details": err.Error(), + }) + } + + return c.Status(resp.StatusCode).Type("json").Send(body) +} diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index a72e514..1089821 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -3,6 +3,7 @@ package handlers import ( "log/slog" + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" @@ -15,28 +16,33 @@ import ( "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" + 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" ) type Handler struct { - logger *slog.Logger - notificationSvc *notificationservice.Service - userSvc *user.Service - referralSvc referralservice.ReferralStore - walletSvc *wallet.Service - transactionSvc *transaction.Service - ticketSvc *ticket.Service - betSvc *bet.Service - branchSvc *branch.Service - companySvc *company.Service - prematchSvc *odds.ServiceImpl - eventSvc event.Service - virtualGameSvc virtualgameservice.VirtualGameService - authSvc *authentication.Service - jwtConfig jwtutil.JwtConfig - validator *customvalidator.CustomValidator + logger *slog.Logger + notificationSvc *notificationservice.Service + userSvc *user.Service + referralSvc referralservice.ReferralStore + walletSvc *wallet.Service + transactionSvc *transaction.Service + ticketSvc *ticket.Service + betSvc *bet.Service + branchSvc *branch.Service + companySvc *company.Service + prematchSvc *odds.ServiceImpl + eventSvc event.Service + virtualGameSvc virtualgameservice.VirtualGameService + aleaVirtualGameSvc alea.AleaVirtualGameService + veliVirtualGameSvc veli.VeliVirtualGameService + authSvc *authentication.Service + jwtConfig jwtutil.JwtConfig + validator *customvalidator.CustomValidator + Cfg *config.Config } func New( @@ -46,6 +52,8 @@ func New( walletSvc *wallet.Service, referralSvc referralservice.ReferralStore, virtualGameSvc virtualgameservice.VirtualGameService, + aleaVirtualGameSvc alea.AleaVirtualGameService, + veliVirtualGameSvc veli.VeliVirtualGameService, userSvc *user.Service, transactionSvc *transaction.Service, ticketSvc *ticket.Service, @@ -56,23 +64,27 @@ func New( companySvc *company.Service, prematchSvc *odds.ServiceImpl, eventSvc event.Service, + cfg *config.Config, ) *Handler { return &Handler{ - logger: logger, - notificationSvc: notificationSvc, - walletSvc: walletSvc, - referralSvc: referralSvc, - validator: validator, - userSvc: userSvc, - transactionSvc: transactionSvc, - ticketSvc: ticketSvc, - betSvc: betSvc, - branchSvc: branchSvc, - companySvc: companySvc, - prematchSvc: prematchSvc, - eventSvc: eventSvc, - virtualGameSvc: virtualGameSvc, - authSvc: authSvc, - jwtConfig: jwtConfig, + logger: logger, + notificationSvc: notificationSvc, + walletSvc: walletSvc, + referralSvc: referralSvc, + validator: validator, + userSvc: userSvc, + transactionSvc: transactionSvc, + ticketSvc: ticketSvc, + betSvc: betSvc, + branchSvc: branchSvc, + companySvc: companySvc, + prematchSvc: prematchSvc, + eventSvc: eventSvc, + virtualGameSvc: virtualGameSvc, + aleaVirtualGameSvc: aleaVirtualGameSvc, + veliVirtualGameSvc: veliVirtualGameSvc, + authSvc: authSvc, + jwtConfig: jwtConfig, + Cfg: cfg, } } diff --git a/internal/web_server/handlers/manager.go b/internal/web_server/handlers/manager.go index 6d35089..948ca05 100644 --- a/internal/web_server/handlers/manager.go +++ b/internal/web_server/handlers/manager.go @@ -176,7 +176,7 @@ func (h *Handler) GetAllManagers(c *fiber.Ctx) error { // @Accept json // @Produce json // @Param id path int true "User ID" -// @Success 200 {object} ManagerRes +// @Success 200 {object} ManagersRes // @Failure 400 {object} response.APIResponse // @Failure 401 {object} response.APIResponse // @Failure 500 {object} response.APIResponse diff --git a/internal/web_server/handlers/models.chapa.go b/internal/web_server/handlers/models.chapa.go new file mode 100644 index 0000000..f829b53 --- /dev/null +++ b/internal/web_server/handlers/models.chapa.go @@ -0,0 +1,107 @@ +package handlers + +import "time" + +var ( + ChapaSecret string + ChapaBaseURL string +) + +type InitPaymentRequest struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + TxRef string `json:"tx_ref"` + CallbackURL string `json:"callback_url"` + ReturnURL string `json:"return_url"` +} + +type TransferRequest struct { + AccountNumber string `json:"account_number"` + BankCode string `json:"bank_code"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Reference string `json:"reference"` + Reason string `json:"reason"` + RecipientName string `json:"recipient_name"` +} + +type ChapaSupportedBank struct { + Id int64 `json:"id"` + Slug string `json:"slug"` + Swift string `json:"swift"` + Name string `json:"name"` + AcctLength int `json:"acct_length"` + AcctNumberRegex string `json:"acct_number_regex"` + ExampleValue string `json:"example_value"` + CountryId int `json:"country_id"` + IsMobilemoney *int `json:"is_mobilemoney"` + + IsActive int `json:"is_active"` + IsRtgs *int `json:"is_rtgs"` + Active int `json:"active"` + Is24Hrs *int `json:"is_24hrs"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Currency string `json:"currency"` +} + +type ChapaSupportedBanksResponse struct { + Message string `json:"message"` + Data []ChapaSupportedBank `json:"data"` +} + +type InitPaymentData struct { + TxRef string `json:"tx_ref"` + CheckoutURL string `json:"checkout_url"` +} + +type InitPaymentResponse struct { + Status string `json:"status"` // "success" + Message string `json:"message"` // e.g., "Payment initialized" + Data InitPaymentData `json:"data"` +} + +type WebhookPayload map[string]interface{} + +type TransactionData struct { + TxRef string `json:"tx_ref"` + Status string `json:"status"` + Amount string `json:"amount"` + Currency string `json:"currency"` + CustomerEmail string `json:"email"` +} + +type VerifyTransactionResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data TransactionData `json:"data"` +} + +type TransferData struct { + Reference string `json:"reference"` + Status string `json:"status"` + Amount string `json:"amount"` + Currency string `json:"currency"` +} + +type CreateTransferResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data TransferData `json:"data"` +} + +type TransferVerificationData struct { + Reference string `json:"reference"` + Status string `json:"status"` + BankCode string `json:"bank_code"` + AccountName string `json:"account_name"` +} + +type VerifyTransferResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data TransferVerificationData `json:"data"` +} 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 51a9d1c..b9024ec 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -6,6 +6,7 @@ import ( _ "github.com/SamuelTariku/FortuneBet-Backend/docs" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/handlers" "github.com/gofiber/fiber/v2" @@ -20,6 +21,8 @@ func (a *App) initAppRoutes() { a.walletSvc, a.referralSvc, a.virtualGameSvc, + a.aleaVirtualGameService, + a.veliVirtualGameService, a.userSvc, a.transactionSvc, a.ticketSvc, @@ -30,6 +33,7 @@ func (a *App) initAppRoutes() { a.companySvc, a.prematchSvc, a.eventSvc, + a.cfg, ) a.fiber.Get("/", func(c *fiber.Ctx) error { @@ -172,6 +176,24 @@ func (a *App) initAppRoutes() { a.fiber.Get("/transfer/wallet/:id", a.authMiddleware, h.GetTransfersByWallet) a.fiber.Post("/transfer/refill/:id", a.authMiddleware, h.RefillWallet) + //Chapa Routes + 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 + group.Get("/alea-play/launch", a.authMiddleware, h.LaunchAleaGame) + group.Post("/webhooks/alea-play", a.authMiddleware, h.HandleAleaCallback) + + //Veli Virtual Game Routes + group.Get("/veli-games/launch", a.authMiddleware, h.LaunchVeliGame) + group.Post("/webhooks/veli-games", a.authMiddleware, h.HandleVeliCallback) + // Transactions /transactions a.fiber.Post("/transaction", a.authMiddleware, h.CreateTransaction) a.fiber.Get("/transaction", a.authMiddleware, h.GetAllTransactions) diff --git a/makefile b/makefile index 10f54f7..5c688d8 100644 --- a/makefile +++ b/makefile @@ -29,8 +29,7 @@ stop: air: @echo "Running air locally (not in Docker)" @air -c .air.toml - -.PHONY: migrations/new +.PHONY: migrations/up migrations/new: @echo 'Creating migration files for DB_URL' @migrate create -seq -ext=.sql -dir=./db/migrations $(name)