From 6d5bdd8a563582199f0f4665971522023ba40ef6 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Thu, 12 Jun 2025 09:40:11 +0300 Subject: [PATCH] Chapa webhook + popok fix --- cmd/main.go | 22 +- db/migrations/000001_fortune.up.sql | 1 + .../000004_virtual_game_Sessios.up.sql | 16 + db/query/notification.sql | 9 +- db/query/transfer.sql | 7 +- docs/docs.go | 451 ++++++-------- docs/swagger.json | 451 ++++++-------- docs/swagger.yaml | 381 ++++++------ gen/db/models.go | 1 + gen/db/notification.sql.go | 34 ++ gen/db/transfer.sql.go | 42 +- go.mod | 7 +- go.sum | 4 - internal/domain/chapa.go | 263 ++------- internal/domain/common.go | 4 + internal/domain/notification.go | 12 +- internal/domain/report.go | 228 ++++++- internal/domain/transfer.go | 6 +- internal/domain/virtual_game.go | 1 + internal/logger/mongoLogger/init.go | 20 +- internal/repository/bet.go | 96 +-- internal/repository/branch.go | 11 +- internal/repository/company.go | 38 ++ internal/repository/notification.go | 168 +++++- internal/repository/transfer.go | 17 +- internal/repository/user.go | 52 +- internal/repository/virtual_game.go | 39 ++ internal/repository/wallet.go | 32 + internal/services/branch/port.go | 2 +- internal/services/chapa/client.go | 265 ++++++--- internal/services/chapa/port.go | 19 +- internal/services/chapa/service.go | 460 +++++---------- internal/services/company/port.go | 2 + internal/services/notfication/port.go | 2 + internal/services/notfication/service.go | 8 +- internal/services/report/port.go | 13 +- internal/services/report/service.go | 383 ++++++++---- internal/services/user/port.go | 3 +- internal/services/virtualGame/port.go | 3 +- internal/services/virtualGame/service.go | 15 +- internal/services/wallet/port.go | 2 + internal/services/wallet/transfer.go | 21 +- internal/web_server/handlers/chapa.go | 555 ++++-------------- internal/web_server/handlers/handlers.go | 4 +- .../handlers/read_chapa_banks_handler_test.go | 131 ----- .../web_server/handlers/transfer_handler.go | 4 +- internal/web_server/routes.go | 15 +- 47 files changed, 2108 insertions(+), 2212 deletions(-) delete mode 100644 internal/web_server/handlers/read_chapa_banks_handler_test.go diff --git a/cmd/main.go b/cmd/main.go index 4e9eed3..3a03ba0 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,6 +5,7 @@ import ( // "context" "fmt" + "log" "log/slog" "os" "time" @@ -15,6 +16,7 @@ import ( // "github.com/gofiber/fiber/v2" "github.com/SamuelTariku/FortuneBet-Backend/internal/config" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger" @@ -83,8 +85,13 @@ func main() { logger := customlogger.NewLogger(cfg.Env, cfg.LogLevel) - mongoLogger.Init() - mongoDBLogger := zap.L() + domain.MongoDBLogger, err = mongoLogger.InitLogger() + if err != nil { + log.Fatalf("Logger initialization failed: %v", err) + } + defer domain.MongoDBLogger.Sync() + + zap.ReplaceGlobals(domain.MongoDBLogger) // client := mongoLogger.InitDB() // defer func() { @@ -122,6 +129,7 @@ func main() { oddsSvc := odds.New(store, cfg, logger) ticketSvc := ticket.NewService(store) notificationRepo := repository.NewNotificationRepository(store) + virtuaGamesRepo := repository.NewVirtualGameRepository(store) notificationSvc := notificationservice.New(notificationRepo, logger, cfg) @@ -143,7 +151,7 @@ func main() { branchSvc := branch.NewService(store) companySvc := company.NewService(store) leagueSvc := league.New(store) - betSvc := bet.NewService(store, eventSvc, oddsSvc, *walletSvc, *branchSvc, logger, mongoDBLogger) + betSvc := bet.NewService(store, eventSvc, oddsSvc, *walletSvc, *branchSvc, logger, domain.MongoDBLogger) resultSvc := result.NewService(store, cfg, logger, *betSvc) referalRepo := repository.NewReferralRepository(store) vitualGameRepo := repository.NewVirtualGameRepository(store) @@ -167,13 +175,10 @@ func main() { chapaClient := chapa.NewClient(cfg.CHAPA_BASE_URL, cfg.CHAPA_SECRET_KEY) chapaSvc := chapa.NewService( - transaction.TransactionStore(store), + wallet.TransferStore(store), wallet.WalletStore(store), user.UserStore(store), - referalSvc, - branch.BranchStore(store), chapaClient, - store, ) reportSvc := report.NewService( @@ -182,6 +187,9 @@ func main() { transaction.TransactionStore(store), branch.BranchStore(store), user.UserStore(store), + company.CompanyStore(store), + virtuaGamesRepo, + notificationRepo, logger, ) diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index a36f82a..d9cbf8f 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -128,6 +128,7 @@ CREATE TABLE IF NOT EXISTS wallet_transfer ( sender_wallet_id BIGINT, cashier_id BIGINT, verified BOOLEAN NOT NULL DEFAULT false, + reference_number VARCHAR(255) NOT NULL, payment_method VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP diff --git a/db/migrations/000004_virtual_game_Sessios.up.sql b/db/migrations/000004_virtual_game_Sessios.up.sql index 4907d9a..09606ba 100644 --- a/db/migrations/000004_virtual_game_Sessios.up.sql +++ b/db/migrations/000004_virtual_game_Sessios.up.sql @@ -1,3 +1,19 @@ +CREATE TABLE IF NOT EXISTS virtual_games ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + provider VARCHAR(255) NOT NULL, + category VARCHAR(100), + min_bet NUMERIC(10, 2) NOT NULL, + max_bet NUMERIC(10, 2) NOT NULL, + volatility VARCHAR(50), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + rtp NUMERIC(5, 2) CHECK (rtp >= 0 AND rtp <= 100), + is_featured BOOLEAN NOT NULL DEFAULT FALSE, + popularity_score INT DEFAULT 0, + thumbnail_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); CREATE TABLE virtual_game_sessions ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id), diff --git a/db/query/notification.sql b/db/query/notification.sql index 8a1c51f..bcf52d5 100644 --- a/db/query/notification.sql +++ b/db/query/notification.sql @@ -68,4 +68,11 @@ LIMIT $1; -- name: ListRecipientIDsByReceiver :many SELECT recipient_id FROM notifications -WHERE reciever = $1; \ No newline at end of file +WHERE reciever = $1; + +-- name: GetNotificationCounts :many +SELECT + COUNT(*) as total, + COUNT(CASE WHEN is_read = true THEN 1 END) as read, + COUNT(CASE WHEN is_read = false THEN 1 END) as unread +FROM notifications; diff --git a/db/query/transfer.sql b/db/query/transfer.sql index 1272c32..f998b43 100644 --- a/db/query/transfer.sql +++ b/db/query/transfer.sql @@ -6,9 +6,10 @@ INSERT INTO wallet_transfer ( sender_wallet_id, cashier_id, verified, + reference_number, payment_method ) -VALUES ($1, $2, $3, $4, $5, $6, $7) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *; -- name: GetAllTransfers :many SELECT * @@ -22,6 +23,10 @@ WHERE receiver_wallet_id = $1 SELECT * FROM wallet_transfer WHERE id = $1; +-- name: GetTransferByReference :one +SELECT * +FROM wallet_transfer +WHERE reference_number = $1; -- name: UpdateTransferVerification :exec UPDATE wallet_transfer SET verified = $1, diff --git a/docs/docs.go b/docs/docs.go index ea14882..e4bf621 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -304,8 +304,9 @@ const docTemplate = `{ } } }, - "/api/v1/chapa/banks": { - "get": { + "/api/v1/chapa/payments/deposit": { + "post": { + "description": "Starts a new deposit process using Chapa payment gateway", "consumes": [ "application/json" ], @@ -315,50 +316,43 @@ const docTemplate = `{ "tags": [ "Chapa" ], - "summary": "fetches chapa supported banks", + "summary": "Initiate a deposit", + "parameters": [ + { + "description": "Deposit request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.ChapaDepositRequestPayload" + } + } + ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/domain.ChapaSupportedBanksResponseWrapper" + "$ref": "#/definitions/domain.ChapaDepositResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/domain.Response" + "$ref": "#/definitions/domain.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/domain.Response" + "$ref": "#/definitions/domain.ErrorResponse" } } } } }, - "/api/v1/chapa/payments/deposit": { - "post": { - "description": "Deposits money into user wallet from user account using Chapa", + "/api/v1/chapa/payments/manual/verify/{tx_ref}": { + "get": { + "description": "Manually verify a payment using Chapa's API", "consumes": [ "application/json" ], @@ -368,48 +362,41 @@ const docTemplate = `{ "tags": [ "Chapa" ], - "summary": "Deposit money into user wallet using Chapa", + "summary": "Verify a payment manually", "parameters": [ { - "description": "Deposit request payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.ChapaDepositRequest" - } + "type": "string", + "description": "Transaction Reference", + "name": "tx_ref", + "in": "path", + "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/domain.ChapaPaymentUrlResponseWrapper" + "$ref": "#/definitions/domain.ChapaVerificationResponse" } }, "400": { - "description": "Invalid request", + "description": "Bad Request", "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "422": { - "description": "Validation error", - "schema": { - "$ref": "#/definitions/domain.Response" + "$ref": "#/definitions/domain.ErrorResponse" } }, "500": { - "description": "Internal server error", + "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/domain.Response" + "$ref": "#/definitions/domain.ErrorResponse" } } } } }, - "/api/v1/chapa/payments/verify": { + "/api/v1/chapa/payments/webhook/verify": { "post": { + "description": "Handles payment notifications from Chapa", "consumes": [ "application/json" ], @@ -419,93 +406,36 @@ const docTemplate = `{ "tags": [ "Chapa" ], - "summary": "Verifies Chapa webhook transaction", + "summary": "Chapa payment webhook callback (used by Chapa)", "parameters": [ { - "description": "Webhook Payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.ChapaTransactionType" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - } - } - } - }, - "/api/v1/chapa/payments/withdraw": { - "post": { - "description": "Initiates a withdrawal transaction using Chapa for the authenticated user.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chapa" - ], - "summary": "Withdraw using Chapa", - "parameters": [ - { - "description": "Chapa Withdraw Request", + "description": "Webhook payload", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.ChapaWithdrawRequest" + "$ref": "#/definitions/domain.ChapaWebhookPayload" } } ], "responses": { "200": { - "description": "Withdrawal requested successfully", + "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/domain.Response" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] + "type": "object", + "additionalProperties": true } }, "400": { - "description": "Invalid request", + "description": "Bad Request", "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/domain.Response" + "$ref": "#/definitions/domain.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/domain.Response" + "$ref": "#/definitions/domain.ErrorResponse" } } } @@ -914,6 +844,38 @@ const docTemplate = `{ } } }, + "/banks": { + "get": { + "description": "Get list of banks supported by Chapa", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Get supported banks", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Bank" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/bet": { "get": { "description": "Gets all the bets", @@ -4389,6 +4351,55 @@ const docTemplate = `{ } } }, + "domain.Bank": { + "type": "object", + "properties": { + "acct_length": { + "type": "integer" + }, + "active": { + "type": "integer" + }, + "country_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_24hrs": { + "description": "nullable", + "type": "integer" + }, + "is_active": { + "type": "integer" + }, + "is_mobilemoney": { + "description": "nullable", + "type": "integer" + }, + "is_rtgs": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "swift": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "domain.BetOutcome": { "type": "object", "properties": { @@ -4521,152 +4532,62 @@ const docTemplate = `{ } } }, - "domain.ChapaDepositRequest": { + "domain.ChapaDepositRequestPayload": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "number" + } + } + }, + "domain.ChapaDepositResponse": { + "type": "object", + "properties": { + "checkoutURL": { + "type": "string" + }, + "reference": { + "type": "string" + } + } + }, + "domain.ChapaVerificationResponse": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tx_ref": { + "type": "string" + } + } + }, + "domain.ChapaWebhookPayload": { "type": "object", "properties": { "amount": { "type": "integer" }, - "branch_id": { - "type": "integer" - }, "currency": { "type": "string" }, - "phone_number": { + "status": { + "$ref": "#/definitions/domain.PaymentStatus" + }, + "tx_ref": { "type": "string" } } }, - "domain.ChapaPaymentUrlResponse": { - "type": "object", - "properties": { - "payment_url": { - "type": "string" - } - } - }, - "domain.ChapaPaymentUrlResponseWrapper": { - "type": "object", - "properties": { - "data": {}, - "message": { - "type": "string" - }, - "status_code": { - "type": "integer" - }, - "success": { - "type": "boolean" - } - } - }, - "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.ChapaSupportedBanksResponseWrapper": { - "type": "object", - "properties": { - "data": {}, - "message": { - "type": "string" - }, - "status_code": { - "type": "integer" - }, - "success": { - "type": "boolean" - } - } - }, - "domain.ChapaTransactionType": { - "type": "object", - "properties": { - "type": { - "type": "string" - } - } - }, - "domain.ChapaWithdrawRequest": { - "type": "object", - "properties": { - "account_name": { - "type": "string" - }, - "account_number": { - "type": "string" - }, - "amount": { - "type": "integer" - }, - "bank_code": { - "type": "string" - }, - "beneficiary_name": { - "type": "string" - }, - "branch_id": { - "type": "integer" - }, - "currency": { - "type": "string" - }, - "wallet_id": { - "description": "add this", - "type": "integer" - } - } - }, "domain.CreateBetOutcomeReq": { "type": "object", "properties": { @@ -4820,6 +4741,19 @@ const docTemplate = `{ "BANK" ] }, + "domain.PaymentStatus": { + "type": "string", + "enum": [ + "pending", + "completed", + "failed" + ], + "x-enum-varnames": [ + "PaymentStatusPending", + "PaymentStatusCompleted", + "PaymentStatusFailed" + ] + }, "domain.PopOKCallback": { "type": "object", "properties": { @@ -4960,21 +4894,6 @@ const docTemplate = `{ } } }, - "domain.Response": { - "type": "object", - "properties": { - "data": {}, - "message": { - "type": "string" - }, - "status_code": { - "type": "integer" - }, - "success": { - "type": "boolean" - } - } - }, "domain.Role": { "type": "string", "enum": [ @@ -5070,7 +4989,7 @@ const docTemplate = `{ }, "awayTeamID": { "description": "Away team ID (can be empty/null)", - "type": "string" + "type": "integer" }, "homeKitImage": { "description": "Kit or image for home team (optional)", @@ -5082,7 +5001,7 @@ const docTemplate = `{ }, "homeTeamID": { "description": "Home team ID", - "type": "string" + "type": "integer" }, "id": { "description": "Event ID", @@ -5094,7 +5013,7 @@ const docTemplate = `{ }, "leagueID": { "description": "League ID", - "type": "string" + "type": "integer" }, "leagueName": { "description": "League name", @@ -5110,7 +5029,7 @@ const docTemplate = `{ }, "sportID": { "description": "Sport ID", - "type": "string" + "type": "integer" }, "startTime": { "description": "Converted from \"time\" field in UNIX format", diff --git a/docs/swagger.json b/docs/swagger.json index d6e140b..527e4db 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -296,8 +296,9 @@ } } }, - "/api/v1/chapa/banks": { - "get": { + "/api/v1/chapa/payments/deposit": { + "post": { + "description": "Starts a new deposit process using Chapa payment gateway", "consumes": [ "application/json" ], @@ -307,50 +308,43 @@ "tags": [ "Chapa" ], - "summary": "fetches chapa supported banks", + "summary": "Initiate a deposit", + "parameters": [ + { + "description": "Deposit request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.ChapaDepositRequestPayload" + } + } + ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/domain.ChapaSupportedBanksResponseWrapper" + "$ref": "#/definitions/domain.ChapaDepositResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/domain.Response" + "$ref": "#/definitions/domain.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/domain.Response" + "$ref": "#/definitions/domain.ErrorResponse" } } } } }, - "/api/v1/chapa/payments/deposit": { - "post": { - "description": "Deposits money into user wallet from user account using Chapa", + "/api/v1/chapa/payments/manual/verify/{tx_ref}": { + "get": { + "description": "Manually verify a payment using Chapa's API", "consumes": [ "application/json" ], @@ -360,48 +354,41 @@ "tags": [ "Chapa" ], - "summary": "Deposit money into user wallet using Chapa", + "summary": "Verify a payment manually", "parameters": [ { - "description": "Deposit request payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.ChapaDepositRequest" - } + "type": "string", + "description": "Transaction Reference", + "name": "tx_ref", + "in": "path", + "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/domain.ChapaPaymentUrlResponseWrapper" + "$ref": "#/definitions/domain.ChapaVerificationResponse" } }, "400": { - "description": "Invalid request", + "description": "Bad Request", "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "422": { - "description": "Validation error", - "schema": { - "$ref": "#/definitions/domain.Response" + "$ref": "#/definitions/domain.ErrorResponse" } }, "500": { - "description": "Internal server error", + "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/domain.Response" + "$ref": "#/definitions/domain.ErrorResponse" } } } } }, - "/api/v1/chapa/payments/verify": { + "/api/v1/chapa/payments/webhook/verify": { "post": { + "description": "Handles payment notifications from Chapa", "consumes": [ "application/json" ], @@ -411,93 +398,36 @@ "tags": [ "Chapa" ], - "summary": "Verifies Chapa webhook transaction", + "summary": "Chapa payment webhook callback (used by Chapa)", "parameters": [ { - "description": "Webhook Payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.ChapaTransactionType" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - } - } - } - }, - "/api/v1/chapa/payments/withdraw": { - "post": { - "description": "Initiates a withdrawal transaction using Chapa for the authenticated user.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chapa" - ], - "summary": "Withdraw using Chapa", - "parameters": [ - { - "description": "Chapa Withdraw Request", + "description": "Webhook payload", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.ChapaWithdrawRequest" + "$ref": "#/definitions/domain.ChapaWebhookPayload" } } ], "responses": { "200": { - "description": "Withdrawal requested successfully", + "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/domain.Response" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] + "type": "object", + "additionalProperties": true } }, "400": { - "description": "Invalid request", + "description": "Bad Request", "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/domain.Response" + "$ref": "#/definitions/domain.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/domain.Response" + "$ref": "#/definitions/domain.ErrorResponse" } } } @@ -906,6 +836,38 @@ } } }, + "/banks": { + "get": { + "description": "Get list of banks supported by Chapa", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chapa" + ], + "summary": "Get supported banks", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Bank" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/bet": { "get": { "description": "Gets all the bets", @@ -4381,6 +4343,55 @@ } } }, + "domain.Bank": { + "type": "object", + "properties": { + "acct_length": { + "type": "integer" + }, + "active": { + "type": "integer" + }, + "country_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_24hrs": { + "description": "nullable", + "type": "integer" + }, + "is_active": { + "type": "integer" + }, + "is_mobilemoney": { + "description": "nullable", + "type": "integer" + }, + "is_rtgs": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "swift": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "domain.BetOutcome": { "type": "object", "properties": { @@ -4513,152 +4524,62 @@ } } }, - "domain.ChapaDepositRequest": { + "domain.ChapaDepositRequestPayload": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "number" + } + } + }, + "domain.ChapaDepositResponse": { + "type": "object", + "properties": { + "checkoutURL": { + "type": "string" + }, + "reference": { + "type": "string" + } + } + }, + "domain.ChapaVerificationResponse": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tx_ref": { + "type": "string" + } + } + }, + "domain.ChapaWebhookPayload": { "type": "object", "properties": { "amount": { "type": "integer" }, - "branch_id": { - "type": "integer" - }, "currency": { "type": "string" }, - "phone_number": { + "status": { + "$ref": "#/definitions/domain.PaymentStatus" + }, + "tx_ref": { "type": "string" } } }, - "domain.ChapaPaymentUrlResponse": { - "type": "object", - "properties": { - "payment_url": { - "type": "string" - } - } - }, - "domain.ChapaPaymentUrlResponseWrapper": { - "type": "object", - "properties": { - "data": {}, - "message": { - "type": "string" - }, - "status_code": { - "type": "integer" - }, - "success": { - "type": "boolean" - } - } - }, - "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.ChapaSupportedBanksResponseWrapper": { - "type": "object", - "properties": { - "data": {}, - "message": { - "type": "string" - }, - "status_code": { - "type": "integer" - }, - "success": { - "type": "boolean" - } - } - }, - "domain.ChapaTransactionType": { - "type": "object", - "properties": { - "type": { - "type": "string" - } - } - }, - "domain.ChapaWithdrawRequest": { - "type": "object", - "properties": { - "account_name": { - "type": "string" - }, - "account_number": { - "type": "string" - }, - "amount": { - "type": "integer" - }, - "bank_code": { - "type": "string" - }, - "beneficiary_name": { - "type": "string" - }, - "branch_id": { - "type": "integer" - }, - "currency": { - "type": "string" - }, - "wallet_id": { - "description": "add this", - "type": "integer" - } - } - }, "domain.CreateBetOutcomeReq": { "type": "object", "properties": { @@ -4812,6 +4733,19 @@ "BANK" ] }, + "domain.PaymentStatus": { + "type": "string", + "enum": [ + "pending", + "completed", + "failed" + ], + "x-enum-varnames": [ + "PaymentStatusPending", + "PaymentStatusCompleted", + "PaymentStatusFailed" + ] + }, "domain.PopOKCallback": { "type": "object", "properties": { @@ -4952,21 +4886,6 @@ } } }, - "domain.Response": { - "type": "object", - "properties": { - "data": {}, - "message": { - "type": "string" - }, - "status_code": { - "type": "integer" - }, - "success": { - "type": "boolean" - } - } - }, "domain.Role": { "type": "string", "enum": [ @@ -5062,7 +4981,7 @@ }, "awayTeamID": { "description": "Away team ID (can be empty/null)", - "type": "string" + "type": "integer" }, "homeKitImage": { "description": "Kit or image for home team (optional)", @@ -5074,7 +4993,7 @@ }, "homeTeamID": { "description": "Home team ID", - "type": "string" + "type": "integer" }, "id": { "description": "Event ID", @@ -5086,7 +5005,7 @@ }, "leagueID": { "description": "League ID", - "type": "string" + "type": "integer" }, "leagueName": { "description": "League name", @@ -5102,7 +5021,7 @@ }, "sportID": { "description": "Sport ID", - "type": "string" + "type": "integer" }, "startTime": { "description": "Converted from \"time\" field in UNIX format", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 60a0b9f..fb86ed5 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -31,6 +31,39 @@ definitions: user_id: type: string type: object + domain.Bank: + properties: + acct_length: + type: integer + active: + type: integer + country_id: + type: integer + created_at: + type: string + currency: + type: string + id: + type: integer + is_24hrs: + description: nullable + type: integer + is_active: + type: integer + is_mobilemoney: + description: nullable + type: integer + is_rtgs: + type: integer + name: + type: string + slug: + type: string + swift: + type: string + updated_at: + type: string + type: object domain.BetOutcome: properties: away_team_name: @@ -124,102 +157,42 @@ definitions: example: 2 type: integer type: object - domain.ChapaDepositRequest: + domain.ChapaDepositRequestPayload: + properties: + amount: + type: number + required: + - amount + type: object + domain.ChapaDepositResponse: + properties: + checkoutURL: + type: string + reference: + type: string + type: object + domain.ChapaVerificationResponse: + properties: + amount: + type: number + currency: + type: string + status: + type: string + tx_ref: + type: string + type: object + domain.ChapaWebhookPayload: properties: amount: type: integer - branch_id: - type: integer currency: type: string - phone_number: + status: + $ref: '#/definitions/domain.PaymentStatus' + tx_ref: type: string type: object - domain.ChapaPaymentUrlResponse: - properties: - payment_url: - type: string - type: object - domain.ChapaPaymentUrlResponseWrapper: - properties: - data: {} - message: - type: string - status_code: - type: integer - success: - type: boolean - 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.ChapaSupportedBanksResponseWrapper: - properties: - data: {} - message: - type: string - status_code: - type: integer - success: - type: boolean - type: object - domain.ChapaTransactionType: - properties: - type: - type: string - type: object - domain.ChapaWithdrawRequest: - properties: - account_name: - type: string - account_number: - type: string - amount: - type: integer - bank_code: - type: string - beneficiary_name: - type: string - branch_id: - type: integer - currency: - type: string - wallet_id: - description: add this - type: integer - type: object domain.CreateBetOutcomeReq: properties: event_id: @@ -328,6 +301,16 @@ definitions: - TELEBIRR_TRANSACTION - ARIFPAY_TRANSACTION - BANK + domain.PaymentStatus: + enum: + - pending + - completed + - failed + type: string + x-enum-varnames: + - PaymentStatusPending + - PaymentStatusCompleted + - PaymentStatusFailed domain.PopOKCallback: properties: amount: @@ -421,16 +404,6 @@ definitions: totalRewardEarned: type: number type: object - domain.Response: - properties: - data: {} - message: - type: string - status_code: - type: integer - success: - type: boolean - type: object domain.Role: enum: - super_admin @@ -501,7 +474,7 @@ definitions: type: string awayTeamID: description: Away team ID (can be empty/null) - type: string + type: integer homeKitImage: description: Kit or image for home team (optional) type: string @@ -510,7 +483,7 @@ definitions: type: string homeTeamID: description: Home team ID - type: string + type: integer id: description: Event ID type: string @@ -519,7 +492,7 @@ definitions: type: string leagueID: description: League ID - type: string + type: integer leagueName: description: League name type: string @@ -531,7 +504,7 @@ definitions: type: string sportID: description: Sport ID - type: string + type: integer startTime: description: Converted from "time" field in UNIX format type: string @@ -1673,137 +1646,94 @@ paths: summary: Launch an Alea Play virtual game tags: - Alea Virtual Games - /api/v1/chapa/banks: - get: - consumes: - - application/json - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.ChapaSupportedBanksResponseWrapper' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/domain.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/domain.Response' - "422": - description: Unprocessable Entity - schema: - $ref: '#/definitions/domain.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.Response' - summary: fetches chapa supported banks - tags: - - Chapa /api/v1/chapa/payments/deposit: post: consumes: - application/json - description: Deposits money into user wallet from user account using Chapa + description: Starts a new deposit process using Chapa payment gateway parameters: - - description: Deposit request payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/domain.ChapaDepositRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.ChapaPaymentUrlResponseWrapper' - "400": - description: Invalid request - schema: - $ref: '#/definitions/domain.Response' - "422": - description: Validation error - schema: - $ref: '#/definitions/domain.Response' - "500": - description: Internal server error - schema: - $ref: '#/definitions/domain.Response' - summary: Deposit money into user wallet using Chapa - tags: - - Chapa - /api/v1/chapa/payments/verify: - post: - consumes: - - application/json - parameters: - - description: Webhook Payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/domain.ChapaTransactionType' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - summary: Verifies Chapa webhook transaction - tags: - - Chapa - /api/v1/chapa/payments/withdraw: - post: - consumes: - - application/json - description: Initiates a withdrawal transaction using Chapa for the authenticated - user. - parameters: - - description: Chapa Withdraw Request + - description: Deposit request in: body name: request required: true schema: - $ref: '#/definitions/domain.ChapaWithdrawRequest' + $ref: '#/definitions/domain.ChapaDepositRequestPayload' produces: - application/json responses: "200": - description: Withdrawal requested successfully + description: OK schema: - allOf: - - $ref: '#/definitions/domain.Response' - - properties: - data: - type: string - type: object + $ref: '#/definitions/domain.ChapaDepositResponse' "400": - description: Invalid request + description: Bad Request schema: - $ref: '#/definitions/domain.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/domain.Response' - "422": - description: Unprocessable Entity - schema: - $ref: '#/definitions/domain.Response' + $ref: '#/definitions/domain.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/domain.Response' - summary: Withdraw using Chapa + $ref: '#/definitions/domain.ErrorResponse' + summary: Initiate a deposit + tags: + - Chapa + /api/v1/chapa/payments/manual/verify/{tx_ref}: + get: + consumes: + - application/json + description: Manually verify a payment using Chapa's API + parameters: + - description: Transaction Reference + in: path + name: tx_ref + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.ChapaVerificationResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Verify a payment manually + tags: + - Chapa + /api/v1/chapa/payments/webhook/verify: + post: + consumes: + - application/json + description: Handles payment notifications from Chapa + parameters: + - description: Webhook payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/domain.ChapaWebhookPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Chapa payment webhook callback (used by Chapa) tags: - Chapa /api/v1/reports/dashboard: @@ -2067,6 +1997,27 @@ paths: summary: Refresh token tags: - auth + /banks: + get: + consumes: + - application/json + description: Get list of banks supported by Chapa + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/domain.Bank' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get supported banks + tags: + - Chapa /bet: get: consumes: diff --git a/gen/db/models.go b/gen/db/models.go index 87a6909..b7a1be5 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -483,6 +483,7 @@ type WalletTransfer struct { SenderWalletID pgtype.Int8 `json:"sender_wallet_id"` CashierID pgtype.Int8 `json:"cashier_id"` Verified bool `json:"verified"` + ReferenceNumber string `json:"reference_number"` PaymentMethod string `json:"payment_method"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` diff --git a/gen/db/notification.sql.go b/gen/db/notification.sql.go index 8e91798..ba9882b 100644 --- a/gen/db/notification.sql.go +++ b/gen/db/notification.sql.go @@ -187,6 +187,40 @@ func (q *Queries) GetNotification(ctx context.Context, id string) (Notification, return i, err } +const GetNotificationCounts = `-- name: GetNotificationCounts :many +SELECT + COUNT(*) as total, + COUNT(CASE WHEN is_read = true THEN 1 END) as read, + COUNT(CASE WHEN is_read = false THEN 1 END) as unread +FROM notifications +` + +type GetNotificationCountsRow struct { + Total int64 `json:"total"` + Read int64 `json:"read"` + Unread int64 `json:"unread"` +} + +func (q *Queries) GetNotificationCounts(ctx context.Context) ([]GetNotificationCountsRow, error) { + rows, err := q.db.Query(ctx, GetNotificationCounts) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetNotificationCountsRow + for rows.Next() { + var i GetNotificationCountsRow + if err := rows.Scan(&i.Total, &i.Read, &i.Unread); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListFailedNotifications = `-- name: ListFailedNotifications :many SELECT id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata FROM notifications diff --git a/gen/db/transfer.sql.go b/gen/db/transfer.sql.go index b9d2797..540f79b 100644 --- a/gen/db/transfer.sql.go +++ b/gen/db/transfer.sql.go @@ -19,10 +19,11 @@ INSERT INTO wallet_transfer ( sender_wallet_id, cashier_id, verified, + reference_number, payment_method ) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, payment_method, created_at, updated_at +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, payment_method, created_at, updated_at ` type CreateTransferParams struct { @@ -32,6 +33,7 @@ type CreateTransferParams struct { SenderWalletID pgtype.Int8 `json:"sender_wallet_id"` CashierID pgtype.Int8 `json:"cashier_id"` Verified bool `json:"verified"` + ReferenceNumber string `json:"reference_number"` PaymentMethod string `json:"payment_method"` } @@ -43,6 +45,7 @@ func (q *Queries) CreateTransfer(ctx context.Context, arg CreateTransferParams) arg.SenderWalletID, arg.CashierID, arg.Verified, + arg.ReferenceNumber, arg.PaymentMethod, ) var i WalletTransfer @@ -54,6 +57,7 @@ func (q *Queries) CreateTransfer(ctx context.Context, arg CreateTransferParams) &i.SenderWalletID, &i.CashierID, &i.Verified, + &i.ReferenceNumber, &i.PaymentMethod, &i.CreatedAt, &i.UpdatedAt, @@ -62,7 +66,7 @@ func (q *Queries) CreateTransfer(ctx context.Context, arg CreateTransferParams) } const GetAllTransfers = `-- name: GetAllTransfers :many -SELECT id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, payment_method, created_at, updated_at +SELECT id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, payment_method, created_at, updated_at FROM wallet_transfer ` @@ -83,6 +87,7 @@ func (q *Queries) GetAllTransfers(ctx context.Context) ([]WalletTransfer, error) &i.SenderWalletID, &i.CashierID, &i.Verified, + &i.ReferenceNumber, &i.PaymentMethod, &i.CreatedAt, &i.UpdatedAt, @@ -98,7 +103,7 @@ func (q *Queries) GetAllTransfers(ctx context.Context) ([]WalletTransfer, error) } const GetTransferByID = `-- name: GetTransferByID :one -SELECT id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, payment_method, created_at, updated_at +SELECT id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, payment_method, created_at, updated_at FROM wallet_transfer WHERE id = $1 ` @@ -114,6 +119,32 @@ func (q *Queries) GetTransferByID(ctx context.Context, id int64) (WalletTransfer &i.SenderWalletID, &i.CashierID, &i.Verified, + &i.ReferenceNumber, + &i.PaymentMethod, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetTransferByReference = `-- name: GetTransferByReference :one +SELECT id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, payment_method, created_at, updated_at +FROM wallet_transfer +WHERE reference_number = $1 +` + +func (q *Queries) GetTransferByReference(ctx context.Context, referenceNumber string) (WalletTransfer, error) { + row := q.db.QueryRow(ctx, GetTransferByReference, referenceNumber) + var i WalletTransfer + err := row.Scan( + &i.ID, + &i.Amount, + &i.Type, + &i.ReceiverWalletID, + &i.SenderWalletID, + &i.CashierID, + &i.Verified, + &i.ReferenceNumber, &i.PaymentMethod, &i.CreatedAt, &i.UpdatedAt, @@ -122,7 +153,7 @@ func (q *Queries) GetTransferByID(ctx context.Context, id int64) (WalletTransfer } const GetTransfersByWallet = `-- name: GetTransfersByWallet :many -SELECT id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, payment_method, created_at, updated_at +SELECT id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, payment_method, created_at, updated_at FROM wallet_transfer WHERE receiver_wallet_id = $1 OR sender_wallet_id = $1 @@ -145,6 +176,7 @@ func (q *Queries) GetTransfersByWallet(ctx context.Context, receiverWalletID int &i.SenderWalletID, &i.CashierID, &i.Verified, + &i.ReferenceNumber, &i.PaymentMethod, &i.CreatedAt, &i.UpdatedAt, diff --git a/go.mod b/go.mod index 1c0137f..fc1716a 100644 --- a/go.mod +++ b/go.mod @@ -13,8 +13,6 @@ require ( github.com/jackc/pgx/v5 v5.7.4 github.com/joho/godotenv v1.5.1 github.com/robfig/cron/v3 v3.0.1 - github.com/shopspring/decimal v1.4.0 - github.com/stretchr/testify v1.10.0 // github.com/stretchr/testify v1.10.0 github.com/swaggo/fiber-swagger v1.3.0 github.com/swaggo/swag v1.16.4 @@ -30,7 +28,6 @@ require ( // 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/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect @@ -49,16 +46,14 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/stretchr/objx v0.5.2 // indirect github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect go.mongodb.org/mongo-driver v1.17.3 golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/net v0.38.0 // direct + golang.org/x/net v0.38.0 // indirect golang.org/x/sync v0.12.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect diff --git a/go.sum b/go.sum index de410d7..9a3a945 100644 --- a/go.sum +++ b/go.sum @@ -118,16 +118,12 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/domain/chapa.go b/internal/domain/chapa.go index acb82ec..13e7a5f 100644 --- a/internal/domain/chapa.go +++ b/internal/domain/chapa.go @@ -1,16 +1,16 @@ package domain -import ( - "errors" - "time" +import "time" + +type PaymentStatus string + +const ( + PaymentStatusPending PaymentStatus = "pending" + PaymentStatusCompleted PaymentStatus = "completed" + PaymentStatusFailed PaymentStatus = "failed" ) -var ( - ChapaSecret string - ChapaBaseURL string -) - -type InitPaymentRequest struct { +type ChapaDepositRequest struct { Amount Currency `json:"amount"` Currency string `json:"currency"` Email string `json:"email"` @@ -21,208 +21,73 @@ type InitPaymentRequest struct { 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 ChapaDepositRequestPayload struct { + Amount float64 `json:"amount" validate:"required,gt=0"` } -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 ChapaWebhookPayload struct { + TxRef string `json:"tx_ref"` + Amount Currency `json:"amount"` + Currency string `json:"currency"` + Status PaymentStatus `json:"status"` } -type ChapaSupportedBanksResponse struct { - Message string `json:"message"` - Data []ChapaSupportedBank `json:"data"` +// PaymentResponse contains the response from payment initialization +type ChapaDepositResponse struct { + CheckoutURL string + Reference string } -type InitPaymentData struct { - TxRef string `json:"tx_ref"` - CheckoutURL string `json:"checkout_url"` +// PaymentVerification contains payment verification details +type ChapaDepositVerification struct { + Status PaymentStatus + Amount Currency + Currency string } -type InitPaymentResponse struct { - Status string `json:"status"` // "success" - Message string `json:"message"` // e.g., "Payment initialized" - Data InitPaymentData `json:"data"` +type ChapaVerificationResponse struct { + Status string `json:"status"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + TxRef string `json:"tx_ref"` } -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 Bank struct { + ID int `json:"id"` + Slug string `json:"slug"` + Swift string `json:"swift"` + Name string `json:"name"` + AcctLength int `json:"acct_length"` + CountryID int `json:"country_id"` + IsMobileMoney int `json:"is_mobilemoney"` // nullable + IsActive int `json:"is_active"` + IsRTGS int `json:"is_rtgs"` + Active int `json:"active"` + Is24Hrs int `json:"is_24hrs"` // nullable + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Currency string `json:"currency"` } -type VerifyTransactionResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Data TransactionData `json:"data"` +type BankResponse struct { + Message string `json:"message"` + Status string `json:"status"` + Data []BankData `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"` -} - -type ChapaTransactionType struct { - Type string `json:"type"` -} - -type ChapaWebHookTransfer struct { - AccountName string `json:"account_name"` - AccountNumber string `json:"account_number"` - BankId string `json:"bank_id"` - BankName string `json:"bank_name"` - Currency string `json:"currency"` - Amount string `json:"amount"` - Type string `json:"type"` - Status string `json:"status"` - Reference string `json:"reference"` - TxRef string `json:"tx_ref"` - ChapaReference string `json:"chapa_reference"` - CreatedAt time.Time `json:"created_at"` -} - -type ChapaWebHookPayment struct { - Event string `json:"event"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Email string `json:"email"` - Mobile interface{} `json:"mobile"` - Currency string `json:"currency"` - Amount string `json:"amount"` - Charge string `json:"charge"` - Status string `json:"status"` - Mode string `json:"mode"` - Reference string `json:"reference"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Type string `json:"type"` - TxRef string `json:"tx_ref"` - PaymentMethod string `json:"payment_method"` - Customization struct { - Title interface{} `json:"title"` - Description interface{} `json:"description"` - Logo interface{} `json:"logo"` - } `json:"customization"` - Meta string `json:"meta"` -} - -type ChapaWithdrawRequest struct { - WalletID int64 `json:"wallet_id"` // add this - AccountName string `json:"account_name"` - AccountNumber string `json:"account_number"` - Amount int64 `json:"amount"` - Currency string `json:"currency"` - BeneficiaryName string `json:"beneficiary_name"` - BankCode string `json:"bank_code"` - BranchID int64 `json:"branch_id"` -} - -type ChapaTransferPayload struct { - AccountName string - AccountNumber string - Amount string - Currency string - BeneficiaryName string - TxRef string - Reference string - BankCode string -} - -type ChapaDepositRequest struct { - Amount Currency `json:"amount"` - PhoneNumber string `json:"phone_number"` - Currency string `json:"currency"` - BranchID int64 `json:"branch_id"` -} - -func (r ChapaDepositRequest) Validate() error { - if r.Amount <= 0 { - return errors.New("amount must be greater than zero") - } - if r.Currency == "" { - return errors.New("currency is required") - } - if r.PhoneNumber == "" { - return errors.New("phone number is required") - } - // if r.BranchID == 0 { - // return errors.New("branch ID is required") - // } - - return nil -} - -type AcceptChapaPaymentRequest struct { - Amount string `json:"amount"` - Currency string `json:"currency"` - Email string `json:"email"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - PhoneNumber string `json:"phone_number"` - TxRef string `json:"tx_ref"` - CallbackUrl string `json:"callback_url"` - ReturnUrl string `json:"return_url"` - CustomizationTitle string `json:"customization[title]"` - CustomizationDescription string `json:"customization[description]"` -} - -type ChapaPaymentUrlResponse struct { - PaymentURL string `json:"payment_url"` -} - -type ChapaPaymentUrlResponseWrapper struct { - Data ChapaPaymentUrlResponse `json:"data"` - Response -} - -type ChapaSupportedBanksResponseWrapper struct { - Data []ChapaSupportedBank `json:"data"` - Response +type BankData struct { + ID int `json:"id"` + Slug string `json:"slug"` + Swift string `json:"swift"` + Name string `json:"name"` + AcctLength int `json:"acct_length"` + CountryID int `json:"country_id"` + IsMobileMoney int `json:"is_mobilemoney"` // nullable + IsActive int `json:"is_active"` + IsRTGS int `json:"is_rtgs"` + Active int `json:"active"` + Is24Hrs int `json:"is_24hrs"` // nullable + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Currency string `json:"currency"` } diff --git a/internal/domain/common.go b/internal/domain/common.go index 556b98b..a6a408f 100644 --- a/internal/domain/common.go +++ b/internal/domain/common.go @@ -3,8 +3,12 @@ package domain import ( "fmt" "time" + + "go.uber.org/zap" ) +var MongoDBLogger *zap.Logger + type ValidInt64 struct { Value int64 Valid bool diff --git a/internal/domain/notification.go b/internal/domain/notification.go index 9c7a109..bcad707 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -27,8 +27,10 @@ const ( NOTIFICATION_TYPE_ADMIN_ALERT NotificationType = "admin_alert" NOTIFICATION_RECEIVER_ADMIN NotificationRecieverSide = "admin" - NotificationRecieverSideAdmin NotificationRecieverSide = "admin" - NotificationRecieverSideCustomer NotificationRecieverSide = "customer" + NotificationRecieverSideAdmin NotificationRecieverSide = "admin" + NotificationRecieverSideCustomer NotificationRecieverSide = "customer" + NotificationRecieverSideCashier NotificationRecieverSide = "cashier" + NotificationRecieverSideBranchManager NotificationRecieverSide = "branch_manager" NotificationDeliverySchemeBulk NotificationDeliveryScheme = "bulk" NotificationDeliverySchemeSingle NotificationDeliveryScheme = "single" @@ -55,9 +57,9 @@ const ( ) type NotificationPayload struct { - Headline string - Message string - Tags []string + Headline string `json:"headline"` + Message string `json:"message"` + Tags []string `json:"tags"` } type Notification struct { diff --git a/internal/domain/report.go b/internal/domain/report.go index df7921e..afab4d8 100644 --- a/internal/domain/report.go +++ b/internal/domain/report.go @@ -2,6 +2,117 @@ package domain import "time" +type DashboardSummary struct { + TotalStakes Currency `json:"total_stakes"` + TotalBets int64 `json:"total_bets"` + ActiveBets int64 `json:"active_bets"` + WinBalance Currency `json:"win_balance"` + TotalWins int64 `json:"total_wins"` + TotalLosses int64 `json:"total_losses"` + CustomerCount int64 `json:"customer_count"` + Profit Currency `json:"profit"` + WinRate float64 `json:"win_rate"` + AverageStake Currency `json:"average_stake"` + TotalDeposits Currency `json:"total_deposits"` + TotalWithdrawals Currency `json:"total_withdrawals"` + ActiveCustomers int64 `json:"active_customers"` + BranchesCount int64 `json:"branches_count"` + ActiveBranches int64 `json:"active_branches"` + + TotalCashiers int64 `json:"total_cashiers"` + ActiveCashiers int64 `json:"active_cashiers"` + InactiveCashiers int64 `json:"inactive_cashiers"` + + TotalWallets int64 `json:"total_wallets"` + TotalGames int64 `json:"total_games"` + ActiveGames int64 `json:"active_games"` + InactiveGames int64 `json:"inactive_games"` + + TotalManagers int64 `json:"total_managers"` + ActiveManagers int64 `json:"active_managers"` + InactiveManagers int64 `json:"inactive_managers"` + InactiveBranches int64 `json:"inactive_branches"` + + TotalAdmins int64 `json:"total_admins"` + ActiveAdmins int64 `json:"active_admins"` + InactiveAdmins int64 `json:"inactive_admins"` + + TotalCompanies int64 `json:"total_companies"` + ActiveCompanies int64 `json:"active_companies"` + InactiveCompanies int64 `json:"inactive_companies"` + + InactiveCustomers int64 `json:"inactive_customers"` + + TotalNotifications int64 `json:"total_notifications"` + ReadNotifications int64 `json:"read_notifications"` + UnreadNotifications int64 `json:"unread_notifications"` +} + +type CustomerActivity struct { + CustomerID int64 `json:"customer_id"` + CustomerName string `json:"customer_name"` + TotalBets int64 `json:"total_bets"` + TotalStakes Currency `json:"total_stakes"` + TotalWins int64 `json:"total_wins"` + TotalPayouts Currency `json:"total_payouts"` + Profit Currency `json:"profit"` + FirstBetDate time.Time `json:"first_bet_date"` + LastBetDate time.Time `json:"last_bet_date"` + FavoriteSport string `json:"favorite_sport"` + FavoriteMarket string `json:"favorite_market"` + AverageStake Currency `json:"average_stake"` + AverageOdds float64 `json:"average_odds"` + WinRate float64 `json:"win_rate"` + ActivityLevel string `json:"activity_level"` // High, Medium, Low +} + +type BranchPerformance struct { + BranchID int64 `json:"branch_id"` + BranchName string `json:"branch_name"` + Location string `json:"location"` + ManagerName string `json:"manager_name"` + TotalBets int64 `json:"total_bets"` + TotalStakes Currency `json:"total_stakes"` + TotalWins int64 `json:"total_wins"` + TotalPayouts Currency `json:"total_payouts"` + Profit Currency `json:"profit"` + CustomerCount int64 `json:"customer_count"` + Deposits Currency `json:"deposits"` + Withdrawals Currency `json:"withdrawals"` + WinRate float64 `json:"win_rate"` + AverageStake Currency `json:"average_stake"` + PerformanceScore float64 `json:"performance_score"` +} + +type SportPerformance struct { + SportID string `json:"sport_id"` + SportName string `json:"sport_name"` + TotalBets int64 `json:"total_bets"` + TotalStakes Currency `json:"total_stakes"` + TotalWins int64 `json:"total_wins"` + TotalPayouts Currency `json:"total_payouts"` + Profit Currency `json:"profit"` + PopularityRank int `json:"popularity_rank"` + WinRate float64 `json:"win_rate"` + AverageStake Currency `json:"average_stake"` + AverageOdds float64 `json:"average_odds"` + MostPopularMarket string `json:"most_popular_market"` +} + +type BetAnalysis struct { + Date time.Time `json:"date"` + TotalBets int64 `json:"total_bets"` + TotalStakes Currency `json:"total_stakes"` + TotalWins int64 `json:"total_wins"` + TotalPayouts Currency `json:"total_payouts"` + Profit Currency `json:"profit"` + MostPopularSport string `json:"most_popular_sport"` + MostPopularMarket string `json:"most_popular_market"` + HighestStake Currency `json:"highest_stake"` + HighestPayout Currency `json:"highest_payout"` + AverageOdds float64 `json:"average_odds"` +} + type ValidOutcomeStatus struct { Value OutcomeStatus Valid bool // Valid is true if Value is not NULL @@ -9,13 +120,14 @@ type ValidOutcomeStatus struct { // ReportFilter contains filters for report generation type ReportFilter struct { - StartTime ValidTime `json:"start_time"` - EndTime ValidTime `json:"end_time"` - CompanyID ValidInt64 `json:"company_id"` - BranchID ValidInt64 `json:"branch_id"` - UserID ValidInt64 `json:"user_id"` - SportID ValidString `json:"sport_id"` - Status ValidOutcomeStatus `json:"status"` + StartTime ValidTime `json:"start_time"` + EndTime ValidTime `json:"end_time"` + CompanyID ValidInt64 `json:"company_id"` + BranchID ValidInt64 `json:"branch_id"` + RecipientID ValidInt64 `json:"recipient_id"` + UserID ValidInt64 `json:"user_id"` + SportID ValidString `json:"sport_id"` + Status ValidOutcomeStatus `json:"status"` } // BetStat represents aggregated bet statistics @@ -46,7 +158,6 @@ type CustomerBetActivity struct { AverageOdds float64 } - // BranchBetActivity represents branch betting activity type BranchBetActivity struct { BranchID int64 @@ -99,25 +210,92 @@ type CustomerPreferences struct { FavoriteMarket string `json:"favorite_market"` } -type DashboardSummary struct { - TotalStakes Currency `json:"total_stakes"` - TotalBets int64 `json:"total_bets"` - ActiveBets int64 `json:"active_bets"` - WinBalance Currency `json:"win_balance"` - TotalWins int64 `json:"total_wins"` - TotalLosses int64 `json:"total_losses"` - CustomerCount int64 `json:"customer_count"` - Profit Currency `json:"profit"` - WinRate float64 `json:"win_rate"` - AverageStake Currency `json:"average_stake"` - TotalDeposits Currency `json:"total_deposits"` - TotalWithdrawals Currency `json:"total_withdrawals"` - ActiveCustomers int64 `json:"active_customers"` - BranchesCount int64 `json:"branches_count"` - ActiveBranches int64 `json:"active_branches"` -} +// type DashboardSummary struct { +// TotalStakes Currency `json:"total_stakes"` +// TotalBets int64 `json:"total_bets"` +// ActiveBets int64 `json:"active_bets"` +// WinBalance Currency `json:"win_balance"` +// TotalWins int64 `json:"total_wins"` +// TotalLosses int64 `json:"total_losses"` +// CustomerCount int64 `json:"customer_count"` +// Profit Currency `json:"profit"` +// WinRate float64 `json:"win_rate"` +// AverageStake Currency `json:"average_stake"` +// TotalDeposits Currency `json:"total_deposits"` +// TotalWithdrawals Currency `json:"total_withdrawals"` +// ActiveCustomers int64 `json:"active_customers"` +// BranchesCount int64 `json:"branches_count"` +// ActiveBranches int64 `json:"active_branches"` +// } type ErrorResponse struct { Message string `json:"message"` Error string `json:"error,omitempty"` } + +type NotificationReport struct { + CountsByType []NotificationTypeCount `json:"counts_by_type"` + DeliveryStats NotificationDeliveryStats `json:"delivery_stats"` + ActiveRecipients []ActiveNotificationRecipient `json:"active_recipients"` +} + +type NotificationTypeCount struct { + Type string `json:"type"` + Total int64 `json:"total"` + Read int64 `json:"read"` + Unread int64 `json:"unread"` +} + +type NotificationDeliveryStats struct { + TotalSent int64 `json:"total_sent"` + FailedDeliveries int64 `json:"failed_deliveries"` + SuccessRate float64 `json:"success_rate"` + MostUsedChannel string `json:"most_used_channel"` +} + +type ActiveNotificationRecipient struct { + RecipientID int64 `json:"recipient_id"` + RecipientName string `json:"recipient_name"` + NotificationCount int64 `json:"notification_count"` + LastNotificationTime time.Time `json:"last_notification_time"` +} + +type CompanyPerformance struct { + CompanyID int64 `json:"company_id"` + CompanyName string `json:"company_name"` + ContactEmail string `json:"contact_email"` + TotalBets int64 `json:"total_bets"` + TotalStakes Currency `json:"total_stakes"` + TotalWins int64 `json:"total_wins"` + TotalPayouts Currency `json:"total_payouts"` + Profit Currency `json:"profit"` + WinRate float64 `json:"win_rate"` + AverageStake Currency `json:"average_stake"` + TotalBranches int64 `json:"total_branches"` + ActiveBranches int64 `json:"active_branches"` + TotalCashiers int64 `json:"total_cashiers"` + ActiveCashiers int64 `json:"active_cashiers"` + WalletBalance Currency `json:"wallet_balance"` + LastActivity time.Time `json:"last_activity"` +} + +type CashierPerformance struct { + CashierID int64 `json:"cashier_id"` + CashierName string `json:"cashier_name"` + BranchID int64 `json:"branch_id"` + BranchName string `json:"branch_name"` + CompanyID int64 `json:"company_id"` + TotalBets int64 `json:"total_bets"` + TotalStakes Currency `json:"total_stakes"` + TotalWins int64 `json:"total_wins"` + TotalPayouts Currency `json:"total_payouts"` + Profit Currency `json:"profit"` + WinRate float64 `json:"win_rate"` + AverageStake Currency `json:"average_stake"` + Deposits Currency `json:"deposits"` + Withdrawals Currency `json:"withdrawals"` + NetTransactionAmount Currency `json:"net_transaction_amount"` + FirstActivity time.Time `json:"first_activity"` + LastActivity time.Time `json:"last_activity"` + ActiveDays int `json:"active_days"` +} \ No newline at end of file diff --git a/internal/domain/transfer.go b/internal/domain/transfer.go index 845482b..bf968d2 100644 --- a/internal/domain/transfer.go +++ b/internal/domain/transfer.go @@ -31,7 +31,8 @@ type Transfer struct { Type TransferType PaymentMethod PaymentMethod ReceiverWalletID int64 - SenderWalletID ValidInt64 + SenderWalletID int64 + ReferenceNumber string CashierID ValidInt64 CreatedAt time.Time UpdatedAt time.Time @@ -40,8 +41,9 @@ type Transfer struct { type CreateTransfer struct { Amount Currency Verified bool + ReferenceNumber string ReceiverWalletID int64 - SenderWalletID ValidInt64 + SenderWalletID int64 CashierID ValidInt64 Type TransferType PaymentMethod PaymentMethod diff --git a/internal/domain/virtual_game.go b/internal/domain/virtual_game.go index d0e374b..f17bdd9 100644 --- a/internal/domain/virtual_game.go +++ b/internal/domain/virtual_game.go @@ -12,6 +12,7 @@ type VirtualGame struct { MinBet float64 `json:"min_bet"` MaxBet float64 `json:"max_bet"` Volatility string `json:"volatility"` + IsActive bool `json:"is_active"` RTP float64 `json:"rtp"` IsFeatured bool `json:"is_featured"` PopularityScore int `json:"popularity_score"` diff --git a/internal/logger/mongoLogger/init.go b/internal/logger/mongoLogger/init.go index b7b333d..9d4b78b 100644 --- a/internal/logger/mongoLogger/init.go +++ b/internal/logger/mongoLogger/init.go @@ -1,18 +1,22 @@ package mongoLogger import ( - "log" + "fmt" "os" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) -func Init() { - // Replace localhost if inside Docker - mongoCore, err := NewMongoCore("mongodb://root:secret@mongo:27017/?authSource=admin", "logdb", "applogs", zapcore.InfoLevel) +func InitLogger() (*zap.Logger, error) { + mongoCore, err := NewMongoCore( + "mongodb://root:secret@mongo:27017/?authSource=admin", + "logdb", + "applogs", + zapcore.InfoLevel, + ) if err != nil { - log.Fatalf("failed to create MongoDB core: %v", err) + return nil, fmt.Errorf("failed to create MongoDB core: %w", err) } consoleEncoder := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()) @@ -21,10 +25,6 @@ func Init() { combinedCore := zapcore.NewTee(mongoCore, consoleCore) logger := zap.New(combinedCore, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)) - zap.ReplaceGlobals(logger) // Optional but useful if you use zap.L() - defer logger.Sync() - - // logger.Info("Application started", zap.String("module", "main")) - // logger.Error("Something went wrong", zap.String("error_code", "E123")) + return logger, nil } diff --git a/internal/repository/bet.go b/internal/repository/bet.go index 32cd4ac..560eb62 100644 --- a/internal/repository/bet.go +++ b/internal/repository/bet.go @@ -158,7 +158,7 @@ func (s *Store) CreateBetOutcome(ctx context.Context, outcomes []domain.CreateBe rows, err := s.queries.CreateBetOutcome(ctx, dbParams) if err != nil { - mongoLogger.Error("failed to create bet outcomes in DB", + domain.MongoDBLogger.Error("failed to create bet outcomes in DB", zap.Int("outcome_count", len(outcomes)), zap.Any("bet_id", outcomes[0].BetID), // assumes all outcomes have same BetID zap.Error(err), @@ -172,7 +172,7 @@ func (s *Store) CreateBetOutcome(ctx context.Context, outcomes []domain.CreateBe func (s *Store) GetBetByID(ctx context.Context, id int64) (domain.GetBet, error) { bet, err := s.queries.GetBetByID(ctx, id) if err != nil { - mongoLogger.Error("failed to get bet by ID", + domain.MongoDBLogger.Error("failed to get bet by ID", zap.Int64("bet_id", id), zap.Error(err), ) @@ -185,7 +185,7 @@ func (s *Store) GetBetByID(ctx context.Context, id int64) (domain.GetBet, error) func (s *Store) GetBetByCashoutID(ctx context.Context, id string) (domain.GetBet, error) { bet, err := s.queries.GetBetByCashoutID(ctx, id) if err != nil { - mongoLogger.Error("failed to get bet by cashout ID", + domain.MongoDBLogger.Error("failed to get bet by cashout ID", zap.String("cashout_id", id), zap.Error(err), ) @@ -211,7 +211,7 @@ func (s *Store) GetAllBets(ctx context.Context, filter domain.BetFilter) ([]doma }, }) if err != nil { - mongoLogger.Error("failed to get all bets", + domain.MongoDBLogger.Error("failed to get all bets", zap.Any("filter", filter), zap.Error(err), ) @@ -232,7 +232,7 @@ func (s *Store) GetBetByBranchID(ctx context.Context, BranchID int64) ([]domain. Valid: true, }) if err != nil { - mongoLogger.Error("failed to get bets by branch ID", + domain.MongoDBLogger.Error("failed to get bets by branch ID", zap.Int64("branch_id", BranchID), zap.Error(err), ) @@ -271,7 +271,7 @@ func (s *Store) UpdateCashOut(ctx context.Context, id int64, cashedOut bool) err CashedOut: cashedOut, }) if err != nil { - mongoLogger.Error("failed to update cashout", + domain.MongoDBLogger.Error("failed to update cashout", zap.Int64("id", id), zap.Bool("cashed_out", cashedOut), zap.Error(err), @@ -286,7 +286,7 @@ func (s *Store) UpdateStatus(ctx context.Context, id int64, status domain.Outcom Status: int32(status), }) if err != nil { - mongoLogger.Error("failed to update status", + domain.MongoDBLogger.Error("failed to update status", zap.Int64("id", id), zap.Int32("status", int32(status)), zap.Error(err), @@ -298,7 +298,7 @@ func (s *Store) UpdateStatus(ctx context.Context, id int64, status domain.Outcom func (s *Store) GetBetOutcomeByEventID(ctx context.Context, eventID int64) ([]domain.BetOutcome, error) { outcomes, err := s.queries.GetBetOutcomeByEventID(ctx, eventID) if err != nil { - mongoLogger.Error("failed to get bet outcomes by event ID", + domain.MongoDBLogger.Error("failed to get bet outcomes by event ID", zap.Int64("event_id", eventID), zap.Error(err), ) @@ -315,7 +315,7 @@ func (s *Store) GetBetOutcomeByEventID(ctx context.Context, eventID int64) ([]do func (s *Store) GetBetOutcomeByBetID(ctx context.Context, betID int64) ([]domain.BetOutcome, error) { outcomes, err := s.queries.GetBetOutcomeByBetID(ctx, betID) if err != nil { - mongoLogger.Error("failed to get bet outcomes by bet ID", + domain.MongoDBLogger.Error("failed to get bet outcomes by bet ID", zap.Int64("bet_id", betID), zap.Error(err), ) @@ -335,7 +335,7 @@ func (s *Store) UpdateBetOutcomeStatus(ctx context.Context, id int64, status dom ID: id, }) if err != nil { - mongoLogger.Error("failed to update bet outcome status", + domain.MongoDBLogger.Error("failed to update bet outcome status", zap.Int64("id", id), zap.Int32("status", int32(status)), zap.Error(err), @@ -428,7 +428,7 @@ func (s *Store) GetBetSummary(ctx context.Context, filter domain.ReportFilter) ( row := s.conn.QueryRow(ctx, query, args...) err = row.Scan(&totalStakes, &totalBets, &activeBets, &totalWins, &totalLosses, &winBalance) if err != nil { - mongoLogger.Error("failed to get bet summary", + domain.MongoDBLogger.Error("failed to get bet summary", zap.String("query", query), zap.Any("args", args), zap.Error(err), @@ -436,7 +436,7 @@ func (s *Store) GetBetSummary(ctx context.Context, filter domain.ReportFilter) ( return 0, 0, 0, 0, 0, 0, fmt.Errorf("failed to get bet summary: %w", err) } - mongoLogger.Info("GetBetSummary executed successfully", + domain.MongoDBLogger.Info("GetBetSummary executed successfully", zap.String("query", query), zap.Any("args", args), zap.Float64("totalStakes", float64(totalStakes)), // convert if needed @@ -519,7 +519,7 @@ func (s *Store) GetBetStats(ctx context.Context, filter domain.ReportFilter) ([] rows, err := s.conn.Query(ctx, query, args...) if err != nil { - mongoLogger.Error("failed to query bet stats", + domain.MongoDBLogger.Error("failed to query bet stats", zap.String("query", query), zap.Any("args", args), zap.Error(err), @@ -539,7 +539,7 @@ func (s *Store) GetBetStats(ctx context.Context, filter domain.ReportFilter) ([] &stat.TotalPayouts, &stat.AverageOdds, ); err != nil { - mongoLogger.Error("failed to scan bet stat", + domain.MongoDBLogger.Error("failed to scan bet stat", zap.Error(err), ) return nil, fmt.Errorf("failed to scan bet stat: %w", err) @@ -548,13 +548,13 @@ func (s *Store) GetBetStats(ctx context.Context, filter domain.ReportFilter) ([] } if err = rows.Err(); err != nil { - mongoLogger.Error("rows error after iteration", + domain.MongoDBLogger.Error("rows error after iteration", zap.Error(err), ) return nil, fmt.Errorf("rows error: %w", err) } - mongoLogger.Info("GetBetStats executed successfully", + domain.MongoDBLogger.Info("GetBetStats executed successfully", zap.Int("result_count", len(stats)), zap.String("query", query), zap.Any("args", args), @@ -615,7 +615,7 @@ func (s *Store) GetSportPopularity(ctx context.Context, filter domain.ReportFilt rows, err := s.conn.Query(ctx, query, args...) if err != nil { - mongoLogger.Error("failed to query sport popularity", + domain.MongoDBLogger.Error("failed to query sport popularity", zap.String("query", query), zap.Any("args", args), zap.Error(err), @@ -629,7 +629,7 @@ func (s *Store) GetSportPopularity(ctx context.Context, filter domain.ReportFilt var date time.Time var sportID string if err := rows.Scan(&date, &sportID); err != nil { - mongoLogger.Error("failed to scan sport popularity", + domain.MongoDBLogger.Error("failed to scan sport popularity", zap.Error(err), ) return nil, fmt.Errorf("failed to scan sport popularity: %w", err) @@ -638,13 +638,13 @@ func (s *Store) GetSportPopularity(ctx context.Context, filter domain.ReportFilt } if err = rows.Err(); err != nil { - mongoLogger.Error("rows error after iteration", + domain.MongoDBLogger.Error("rows error after iteration", zap.Error(err), ) return nil, fmt.Errorf("rows error: %w", err) } - mongoLogger.Info("GetSportPopularity executed successfully", + domain.MongoDBLogger.Info("GetSportPopularity executed successfully", zap.Int("result_count", len(popularity)), zap.String("query", query), zap.Any("args", args), @@ -705,7 +705,7 @@ func (s *Store) GetMarketPopularity(ctx context.Context, filter domain.ReportFil rows, err := s.conn.Query(ctx, query, args...) if err != nil { - mongoLogger.Error("failed to query market popularity", + domain.MongoDBLogger.Error("failed to query market popularity", zap.String("query", query), zap.Any("args", args), zap.Error(err), @@ -719,7 +719,7 @@ func (s *Store) GetMarketPopularity(ctx context.Context, filter domain.ReportFil var date time.Time var marketName string if err := rows.Scan(&date, &marketName); err != nil { - mongoLogger.Error("failed to scan market popularity", + domain.MongoDBLogger.Error("failed to scan market popularity", zap.Error(err), ) return nil, fmt.Errorf("failed to scan market popularity: %w", err) @@ -728,13 +728,13 @@ func (s *Store) GetMarketPopularity(ctx context.Context, filter domain.ReportFil } if err = rows.Err(); err != nil { - mongoLogger.Error("rows error after iteration", + domain.MongoDBLogger.Error("rows error after iteration", zap.Error(err), ) return nil, fmt.Errorf("rows error: %w", err) } - mongoLogger.Info("GetMarketPopularity executed successfully", + domain.MongoDBLogger.Info("GetMarketPopularity executed successfully", zap.Int("result_count", len(popularity)), zap.String("query", query), zap.Any("args", args), @@ -809,7 +809,7 @@ func (s *Store) GetExtremeValues(ctx context.Context, filter domain.ReportFilter rows, err := s.conn.Query(ctx, query, args...) if err != nil { - mongoLogger.Error("failed to query extreme values", + domain.MongoDBLogger.Error("failed to query extreme values", zap.String("query", query), zap.Any("args", args), zap.Error(err), @@ -823,7 +823,7 @@ func (s *Store) GetExtremeValues(ctx context.Context, filter domain.ReportFilter var date time.Time var extreme domain.ExtremeValues if err := rows.Scan(&date, &extreme.HighestStake, &extreme.HighestPayout); err != nil { - mongoLogger.Error("failed to scan extreme values", + domain.MongoDBLogger.Error("failed to scan extreme values", zap.Error(err), ) return nil, fmt.Errorf("failed to scan extreme values: %w", err) @@ -832,13 +832,13 @@ func (s *Store) GetExtremeValues(ctx context.Context, filter domain.ReportFilter } if err = rows.Err(); err != nil { - mongoLogger.Error("rows error after iteration", + domain.MongoDBLogger.Error("rows error after iteration", zap.Error(err), ) return nil, fmt.Errorf("rows error: %w", err) } - mongoLogger.Info("GetExtremeValues executed successfully", + domain.MongoDBLogger.Info("GetExtremeValues executed successfully", zap.Int("result_count", len(extremes)), zap.String("query", query), zap.Any("args", args), @@ -899,7 +899,7 @@ func (s *Store) GetCustomerBetActivity(ctx context.Context, filter domain.Report rows, err := s.conn.Query(ctx, query, args...) if err != nil { - mongoLogger.Error("failed to query customer bet activity", + domain.MongoDBLogger.Error("failed to query customer bet activity", zap.String("query", query), zap.Any("args", args), zap.Error(err), @@ -921,7 +921,7 @@ func (s *Store) GetCustomerBetActivity(ctx context.Context, filter domain.Report &activity.LastBetDate, &activity.AverageOdds, ); err != nil { - mongoLogger.Error("failed to scan customer bet activity", + domain.MongoDBLogger.Error("failed to scan customer bet activity", zap.Error(err), ) return nil, fmt.Errorf("failed to scan customer bet activity: %w", err) @@ -930,13 +930,13 @@ func (s *Store) GetCustomerBetActivity(ctx context.Context, filter domain.Report } if err = rows.Err(); err != nil { - mongoLogger.Error("rows error after iteration", + domain.MongoDBLogger.Error("rows error after iteration", zap.Error(err), ) return nil, fmt.Errorf("rows error: %w", err) } - mongoLogger.Info("GetCustomerBetActivity executed successfully", + domain.MongoDBLogger.Info("GetCustomerBetActivity executed successfully", zap.Int("result_count", len(activities)), zap.String("query", query), zap.Any("args", args), @@ -989,7 +989,7 @@ func (s *Store) GetBranchBetActivity(ctx context.Context, filter domain.ReportFi rows, err := s.conn.Query(ctx, query, args...) if err != nil { - mongoLogger.Error("failed to query branch bet activity", + domain.MongoDBLogger.Error("failed to query branch bet activity", zap.String("query", query), zap.Any("args", args), zap.Error(err), @@ -1008,18 +1008,18 @@ func (s *Store) GetBranchBetActivity(ctx context.Context, filter domain.ReportFi &activity.TotalWins, &activity.TotalPayouts, ); err != nil { - mongoLogger.Error("failed to scan branch bet activity", zap.Error(err)) + domain.MongoDBLogger.Error("failed to scan branch bet activity", zap.Error(err)) return nil, fmt.Errorf("failed to scan branch bet activity: %w", err) } activities = append(activities, activity) } if err = rows.Err(); err != nil { - mongoLogger.Error("rows error after iteration", zap.Error(err)) + domain.MongoDBLogger.Error("rows error after iteration", zap.Error(err)) return nil, fmt.Errorf("rows error: %w", err) } - mongoLogger.Info("GetBranchBetActivity executed successfully", + domain.MongoDBLogger.Info("GetBranchBetActivity executed successfully", zap.Int("result_count", len(activities)), zap.String("query", query), zap.Any("args", args), @@ -1078,7 +1078,7 @@ func (s *Store) GetSportBetActivity(ctx context.Context, filter domain.ReportFil rows, err := s.conn.Query(ctx, query, args...) if err != nil { - mongoLogger.Error("failed to query sport bet activity", + domain.MongoDBLogger.Error("failed to query sport bet activity", zap.String("query", query), zap.Any("args", args), zap.Error(err), @@ -1098,18 +1098,18 @@ func (s *Store) GetSportBetActivity(ctx context.Context, filter domain.ReportFil &activity.TotalPayouts, &activity.AverageOdds, ); err != nil { - mongoLogger.Error("failed to scan sport bet activity", zap.Error(err)) + domain.MongoDBLogger.Error("failed to scan sport bet activity", zap.Error(err)) return nil, fmt.Errorf("failed to scan sport bet activity: %w", err) } activities = append(activities, activity) } if err = rows.Err(); err != nil { - mongoLogger.Error("rows error after iteration", zap.Error(err)) + domain.MongoDBLogger.Error("rows error after iteration", zap.Error(err)) return nil, fmt.Errorf("rows error: %w", err) } - mongoLogger.Info("GetSportBetActivity executed successfully", + domain.MongoDBLogger.Info("GetSportBetActivity executed successfully", zap.Int("result_count", len(activities)), zap.String("query", query), zap.Any("args", args), @@ -1156,7 +1156,7 @@ func (s *Store) GetSportDetails(ctx context.Context, filter domain.ReportFilter) rows, err := s.conn.Query(ctx, query, args...) if err != nil { - mongoLogger.Error("failed to query sport details", + domain.MongoDBLogger.Error("failed to query sport details", zap.String("query", query), zap.Any("args", args), zap.Error(err), @@ -1169,18 +1169,18 @@ func (s *Store) GetSportDetails(ctx context.Context, filter domain.ReportFilter) for rows.Next() { var sportID, matchName string if err := rows.Scan(&sportID, &matchName); err != nil { - mongoLogger.Error("failed to scan sport detail", zap.Error(err)) + domain.MongoDBLogger.Error("failed to scan sport detail", zap.Error(err)) return nil, fmt.Errorf("failed to scan sport detail: %w", err) } details[sportID] = matchName } if err = rows.Err(); err != nil { - mongoLogger.Error("rows error after iteration", zap.Error(err)) + domain.MongoDBLogger.Error("rows error after iteration", zap.Error(err)) return nil, fmt.Errorf("rows error: %w", err) } - mongoLogger.Info("GetSportDetails executed successfully", + domain.MongoDBLogger.Info("GetSportDetails executed successfully", zap.Int("result_count", len(details)), zap.String("query", query), zap.Any("args", args), @@ -1241,7 +1241,7 @@ func (s *Store) GetSportMarketPopularity(ctx context.Context, filter domain.Repo rows, err := s.conn.Query(ctx, query, args...) if err != nil { - mongoLogger.Error("failed to query sport market popularity", + domain.MongoDBLogger.Error("failed to query sport market popularity", zap.String("query", query), zap.Any("args", args), zap.Error(err), @@ -1254,18 +1254,18 @@ func (s *Store) GetSportMarketPopularity(ctx context.Context, filter domain.Repo for rows.Next() { var sportID, marketName string if err := rows.Scan(&sportID, &marketName); err != nil { - mongoLogger.Error("failed to scan sport market popularity", zap.Error(err)) + domain.MongoDBLogger.Error("failed to scan sport market popularity", zap.Error(err)) return nil, fmt.Errorf("failed to scan sport market popularity: %w", err) } popularity[sportID] = marketName } if err = rows.Err(); err != nil { - mongoLogger.Error("rows error after iteration", zap.Error(err)) + domain.MongoDBLogger.Error("rows error after iteration", zap.Error(err)) return nil, fmt.Errorf("rows error: %w", err) } - mongoLogger.Info("GetSportMarketPopularity executed successfully", + domain.MongoDBLogger.Info("GetSportMarketPopularity executed successfully", zap.Int("result_count", len(popularity)), zap.String("query", query), zap.Any("args", args), diff --git a/internal/repository/branch.go b/internal/repository/branch.go index 300ef7e..0f5c5b5 100644 --- a/internal/repository/branch.go +++ b/internal/repository/branch.go @@ -259,10 +259,11 @@ func (s *Store) DeleteBranchCashier(ctx context.Context, userID int64) error { } // GetBranchCounts returns total and active branch counts -func (s *Store) GetBranchCounts(ctx context.Context, filter domain.ReportFilter) (total, active int64, err error) { +func (s *Store) GetBranchCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error) { query := `SELECT COUNT(*) as total, - COUNT(CASE WHEN is_active = true THEN 1 END) as active + COUNT(CASE WHEN is_active = true THEN 1 END) as active, + COUNT(CASE WHEN is_active = false THEN 1 END) as inactive FROM branches` args := []interface{}{} @@ -291,12 +292,12 @@ func (s *Store) GetBranchCounts(ctx context.Context, filter domain.ReportFilter) } row := s.conn.QueryRow(ctx, query, args...) - err = row.Scan(&total, &active) + err = row.Scan(&total, &active, &inactive) if err != nil { - return 0, 0, fmt.Errorf("failed to get branch counts: %w", err) + return 0, 0, 0, fmt.Errorf("failed to get branch counts: %w", err) } - return total, active, nil + return total, active, inactive, nil } // GetBranchDetails returns branch details map diff --git a/internal/repository/company.go b/internal/repository/company.go index d9b8e06..8fd8432 100644 --- a/internal/repository/company.go +++ b/internal/repository/company.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" @@ -122,3 +123,40 @@ func (s *Store) UpdateCompany(ctx context.Context, company domain.UpdateCompany) func (s *Store) DeleteCompany(ctx context.Context, id int64) error { return s.queries.DeleteCompany(ctx, id) } + +func (s *Store) GetCompanyCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error) { + query := `SELECT + COUNT(*) as total, + COUNT(CASE WHEN w.is_active = true THEN 1 END) as active, + COUNT(CASE WHEN w.is_active = false THEN 1 END) as inactive + FROM companies c + JOIN wallets w ON c.wallet_id = w.id` + + args := []interface{}{} + argPos := 1 + + // Add filters if provided + if filter.StartTime.Valid { + query += fmt.Sprintf(" WHERE %screated_at >= $%d", func() string { + if len(args) == 0 { + return "" + } + return " AND " + }(), argPos) + args = append(args, filter.StartTime.Value) + argPos++ + } + if filter.EndTime.Valid { + query += fmt.Sprintf(" AND created_at <= $%d", argPos) + args = append(args, filter.EndTime.Value) + argPos++ + } + + row := s.conn.QueryRow(ctx, query, args...) + err = row.Scan(&total, &active, &inactive) + if err != nil { + return 0, 0, 0, fmt.Errorf("failed to get company counts: %w", err) + } + + return total, active, inactive, nil +} diff --git a/internal/repository/notification.go b/internal/repository/notification.go index c2150c7..21ace2b 100644 --- a/internal/repository/notification.go +++ b/internal/repository/notification.go @@ -3,11 +3,12 @@ package repository import ( "context" "encoding/json" + "fmt" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/gorilla/websocket" "github.com/jackc/pgx/v5/pgtype" - "golang.org/x/net/websocket" ) type NotificationRepository interface { @@ -18,6 +19,7 @@ type NotificationRepository interface { ListRecipientIDs(ctx context.Context, receiver domain.NotificationRecieverSide) ([]int64, error) CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) + GetNotificationCounts(ctx context.Context, filter domain.ReportFilter) (total, read, unread int64, err error) } type Repository struct { @@ -28,10 +30,13 @@ func NewNotificationRepository(store *Store) NotificationRepository { return &Repository{store: store} } -func (r *Repository) ConnectWebSocket(ctx context.Context, recipientID int64, c *websocket.Conn) error { +func (s *Store) ConnectWebSocket(ctx context.Context, recipientID int64, c *websocket.Conn) error { return nil } +func (s *Store) DisconnectWebSocket(recipientID int64) { +} + func (r *Repository) CreateNotification(ctx context.Context, notification *domain.Notification) (*domain.Notification, error) { var errorSeverity pgtype.Text if notification.ErrorSeverity != nil { @@ -206,3 +211,162 @@ func unmarshalPayload(data []byte) (domain.NotificationPayload, error) { func (r *Repository) CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) { return r.store.queries.CountUnreadNotifications(ctx, recipient_id) } + +func (r *Repository) GetNotificationCounts(ctx context.Context, filter domain.ReportFilter) (total, read, unread int64, err error) { + rows, err := r.store.queries.GetNotificationCounts(ctx) + if err != nil { + return 0, 0, 0, fmt.Errorf("failed to get notification counts: %w", err) + } + + // var total, read, unread int64 + for _, row := range rows { + total += row.Total + read += row.Read + unread += row.Unread + } + + return total, read, unread, nil +} + +func (s *Store) GetMostActiveNotificationRecipients(ctx context.Context, filter domain.ReportFilter, limit int) ([]domain.ActiveNotificationRecipient, error) { + query := `SELECT + n.recipient_id, + u.first_name || ' ' || u.last_name as recipient_name, + COUNT(*) as notification_count, + MAX(n.timestamp) as last_notification_time + FROM notifications n + JOIN users u ON n.recipient_id = u.id + WHERE n.timestamp BETWEEN $1 AND $2 + GROUP BY n.recipient_id, u.first_name, u.last_name + ORDER BY notification_count DESC + LIMIT $3` + + var recipients []domain.ActiveNotificationRecipient + rows, err := s.conn.Query(ctx, query, filter.StartTime.Value, filter.EndTime.Value, limit) + if err != nil { + return nil, fmt.Errorf("failed to get active notification recipients: %w", err) + } + defer rows.Close() + + for rows.Next() { + var r domain.ActiveNotificationRecipient + if err := rows.Scan(&r.RecipientID, &r.RecipientName, &r.NotificationCount, &r.LastNotificationTime); err != nil { + return nil, err + } + recipients = append(recipients, r) + } + + return recipients, nil +} + +// GetNotificationDeliveryStats +func (s *Store) GetNotificationDeliveryStats(ctx context.Context, filter domain.ReportFilter) (domain.NotificationDeliveryStats, error) { + query := `SELECT + COUNT(*) as total_sent, + COUNT(CASE WHEN delivery_status = 'failed' THEN 1 END) as failed_deliveries, + (COUNT(CASE WHEN delivery_status = 'sent' THEN 1 END) * 100.0 / NULLIF(COUNT(*), 0)) as success_rate, + MODE() WITHIN GROUP (ORDER BY delivery_channel) as most_used_channel + FROM notifications + WHERE timestamp BETWEEN $1 AND $2` + + var stats domain.NotificationDeliveryStats + row := s.conn.QueryRow(ctx, query, filter.StartTime.Value, filter.EndTime.Value) + err := row.Scan(&stats.TotalSent, &stats.FailedDeliveries, &stats.SuccessRate, &stats.MostUsedChannel) + if err != nil { + return domain.NotificationDeliveryStats{}, fmt.Errorf("failed to get notification delivery stats: %w", err) + } + + return stats, nil +} + +// GetNotificationCountsByType +func (s *Store) GetNotificationCountsByType(ctx context.Context, filter domain.ReportFilter) (map[string]domain.NotificationTypeCount, error) { + query := `SELECT + type, + COUNT(*) as total, + COUNT(CASE WHEN is_read = true THEN 1 END) as read, + COUNT(CASE WHEN is_read = false THEN 1 END) as unread + FROM notifications + WHERE timestamp BETWEEN $1 AND $2 + GROUP BY type` + + counts := make(map[string]domain.NotificationTypeCount) + rows, err := s.conn.Query(ctx, query, filter.StartTime.Value, filter.EndTime.Value) + if err != nil { + return nil, fmt.Errorf("failed to get notification counts by type: %w", err) + } + defer rows.Close() + + for rows.Next() { + var nt domain.NotificationTypeCount + var typ string + if err := rows.Scan(&typ, &nt.Total, &nt.Read, &nt.Unread); err != nil { + return nil, err + } + counts[typ] = nt + } + + return counts, nil +} + +func (s *Store) CountUnreadNotifications(ctx context.Context, userID int64) (int64, error) { + count, err := s.queries.CountUnreadNotifications(ctx, userID) + if err != nil { + return 0, err + } + return count, nil +} + +// func (s *Store) GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) { +// dbNotifications, err := s.queries.GetAllNotifications(ctx, dbgen.GetAllNotificationsParams{ +// Limit: int32(limit), +// Offset: int32(offset), +// }) +// if err != nil { +// return nil, err +// } + +// result := make([]domain.Notification, 0, len(dbNotifications)) +// for _, dbNotif := range dbNotifications { +// // You may want to move this mapping logic to a shared function if not already present +// var errorSeverity *domain.NotificationErrorSeverity +// if dbNotif.ErrorSeverity.Valid { +// s := domain.NotificationErrorSeverity(dbNotif.ErrorSeverity.String) +// errorSeverity = &s +// } + +// var deliveryChannel domain.DeliveryChannel +// if dbNotif.DeliveryChannel.Valid { +// deliveryChannel = domain.DeliveryChannel(dbNotif.DeliveryChannel.String) +// } else { +// deliveryChannel = "" +// } + +// var priority int +// if dbNotif.Priority.Valid { +// priority = int(dbNotif.Priority.Int32) +// } + +// payload, err := unmarshalPayload(dbNotif.Payload) +// if err != nil { +// payload = domain.NotificationPayload{} +// } + +// result = append(result, domain.Notification{ +// ID: dbNotif.ID, +// RecipientID: dbNotif.RecipientID, +// Type: domain.NotificationType(dbNotif.Type), +// Level: domain.NotificationLevel(dbNotif.Level), +// ErrorSeverity: errorSeverity, +// Reciever: domain.NotificationRecieverSide(dbNotif.Reciever), +// IsRead: dbNotif.IsRead, +// DeliveryStatus: domain.NotificationDeliveryStatus(dbNotif.DeliveryStatus), +// DeliveryChannel: deliveryChannel, +// Payload: payload, +// Priority: priority, +// Timestamp: dbNotif.Timestamp.Time, +// Metadata: dbNotif.Metadata, +// }) +// } +// return result, nil +// } diff --git a/internal/repository/transfer.go b/internal/repository/transfer.go index 7ee876e..58d3b05 100644 --- a/internal/repository/transfer.go +++ b/internal/repository/transfer.go @@ -15,10 +15,7 @@ func convertDBTransfer(transfer dbgen.WalletTransfer) domain.Transfer { Type: domain.TransferType(transfer.Type), Verified: transfer.Verified, ReceiverWalletID: transfer.ReceiverWalletID, - SenderWalletID: domain.ValidInt64{ - Value: transfer.SenderWalletID.Int64, - Valid: transfer.SenderWalletID.Valid, - }, + SenderWalletID: transfer.SenderWalletID.Int64, CashierID: domain.ValidInt64{ Value: transfer.CashierID.Int64, Valid: transfer.CashierID.Valid, @@ -33,8 +30,8 @@ func convertCreateTransfer(transfer domain.CreateTransfer) dbgen.CreateTransferP Type: string(transfer.Type), ReceiverWalletID: transfer.ReceiverWalletID, SenderWalletID: pgtype.Int8{ - Int64: transfer.SenderWalletID.Value, - Valid: transfer.SenderWalletID.Valid, + Int64: transfer.SenderWalletID, + Valid: true, }, CashierID: pgtype.Int8{ Int64: transfer.CashierID.Value, @@ -78,6 +75,14 @@ func (s *Store) GetTransfersByWallet(ctx context.Context, walletID int64) ([]dom return result, nil } +func (s *Store) GetTransferByReference(ctx context.Context, reference string) (domain.Transfer, error) { + transfer, err := s.queries.GetTransferByReference(ctx, reference) + if err != nil { + return domain.Transfer{}, nil + } + return convertDBTransfer(transfer), nil +} + func (s *Store) GetTransferByID(ctx context.Context, id int64) (domain.Transfer, error) { transfer, err := s.queries.GetTransferByID(ctx, id) if err != nil { diff --git a/internal/repository/user.go b/internal/repository/user.go index 9b05b5b..1f41c35 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -448,10 +448,11 @@ func (s *Store) CreateUserWithoutOtp(ctx context.Context, user domain.User, is_c } // GetCustomerCounts returns total and active customer counts -func (s *Store) GetCustomerCounts(ctx context.Context, filter domain.ReportFilter) (total, active int64, err error) { +func (s *Store) GetCustomerCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error) { query := `SELECT COUNT(*) as total, - SUM(CASE WHEN suspended = false THEN 1 ELSE 0 END) as active + SUM(CASE WHEN suspended = false THEN 1 ELSE 0 END) as active, + SUM(CASE WHEN suspended = true THEN 1 ELSE 0 END) as inactive FROM users WHERE role = 'customer'` args := []interface{}{} @@ -480,12 +481,12 @@ func (s *Store) GetCustomerCounts(ctx context.Context, filter domain.ReportFilte } row := s.conn.QueryRow(ctx, query, args...) - err = row.Scan(&total, &active) + err = row.Scan(&total, &active, &inactive) if err != nil { - return 0, 0, fmt.Errorf("failed to get customer counts: %w", err) + return 0, 0, 0, fmt.Errorf("failed to get customer counts: %w", err) } - return total, active, nil + return total, active, inactive, nil } // GetCustomerDetails returns customer details map @@ -693,3 +694,44 @@ func (s *Store) GetCustomerPreferences(ctx context.Context, filter domain.Report return preferences, nil } + +func (s *Store) GetRoleCounts(ctx context.Context, role string, filter domain.ReportFilter) (total, active, inactive int64, err error) { + query := `SELECT + COUNT(*) as total, + COUNT(CASE WHEN suspended = false THEN 1 END) as active, + COUNT(CASE WHEN suspended = true THEN 1 END) as inactive + FROM users WHERE role = $1` + + args := []interface{}{role} + argPos := 2 + + // Add filters if provided + if filter.CompanyID.Valid { + query += fmt.Sprintf(" AND company_id = $%d", argPos) + args = append(args, filter.CompanyID.Value) + argPos++ + } + if filter.StartTime.Valid { + query += fmt.Sprintf(" AND %screated_at >= $%d", func() string { + if len(args) == 1 { // Only role parameter so far + return " " + } + return " AND " + }(), argPos) + args = append(args, filter.StartTime.Value) + argPos++ + } + if filter.EndTime.Valid { + query += fmt.Sprintf(" AND created_at <= $%d", argPos) + args = append(args, filter.EndTime.Value) + argPos++ + } + + row := s.conn.QueryRow(ctx, query, args...) + err = row.Scan(&total, &active, &inactive) + if err != nil { + return 0, 0, 0, fmt.Errorf("failed to get %s counts: %w", role, err) + } + + return total, active, inactive, nil +} diff --git a/internal/repository/virtual_game.go b/internal/repository/virtual_game.go index cfa6fee..f736390 100644 --- a/internal/repository/virtual_game.go +++ b/internal/repository/virtual_game.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" @@ -17,12 +18,19 @@ type VirtualGameRepository interface { 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 + + GetGameCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error) } type VirtualGameRepo struct { store *Store } +// GetGameCounts implements VirtualGameRepository. +// func (r *VirtualGameRepo) GetGameCounts(ctx context.Context, filter domain.ReportFilter) (total int64, active int64, inactive int64, err error) { +// panic("unimplemented") +// } + func NewVirtualGameRepository(store *Store) VirtualGameRepository { return &VirtualGameRepo{store: store} } @@ -112,3 +120,34 @@ func (r *VirtualGameRepo) UpdateVirtualGameTransactionStatus(ctx context.Context Status: status, }) } + +func (r *VirtualGameRepo) GetGameCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error) { + query := `SELECT + COUNT(*) as total, + COUNT(CASE WHEN is_active = true THEN 1 END) as active, + COUNT(CASE WHEN is_active = false THEN 1 END) as inactive + FROM virtual_games` + + args := []interface{}{} + argPos := 1 + + // Add filters if provided + if filter.StartTime.Valid { + query += fmt.Sprintf(" WHERE created_at >= $%d", argPos) + args = append(args, filter.StartTime.Value) + argPos++ + } + if filter.EndTime.Valid { + query += fmt.Sprintf(" AND created_at <= $%d", argPos) + args = append(args, filter.EndTime.Value) + argPos++ + } + + row := r.store.conn.QueryRow(ctx, query, args...) + err = row.Scan(&total, &active, &inactive) + if err != nil { + return 0, 0, 0, fmt.Errorf("failed to get game counts: %w", err) + } + + return total, active, inactive, nil +} diff --git a/internal/repository/wallet.go b/internal/repository/wallet.go index 2223cbf..3271b54 100644 --- a/internal/repository/wallet.go +++ b/internal/repository/wallet.go @@ -225,3 +225,35 @@ func (s *Store) GetBalanceSummary(ctx context.Context, filter domain.ReportFilte return summary, nil } + +func (s *Store) GetTotalWallets(ctx context.Context, filter domain.ReportFilter) (int64, error) { + query := `SELECT COUNT(*) FROM wallets WHERE is_active = true` + args := []interface{}{} + argPos := 1 + + // Add filters if provided + if filter.StartTime.Valid { + query += fmt.Sprintf(" AND %screated_at >= $%d", func() string { + if len(args) == 0 { + return " WHERE " + } + return " AND " + }(), argPos) + args = append(args, filter.StartTime.Value) + argPos++ + } + if filter.EndTime.Valid { + query += fmt.Sprintf(" AND created_at <= $%d", argPos) + args = append(args, filter.EndTime.Value) + argPos++ + } + + var total int64 + row := s.conn.QueryRow(ctx, query, args...) + err := row.Scan(&total) + if err != nil { + return 0, fmt.Errorf("failed to get wallet counts: %w", err) + } + + return total, nil +} diff --git a/internal/services/branch/port.go b/internal/services/branch/port.go index 95b5f76..a128d59 100644 --- a/internal/services/branch/port.go +++ b/internal/services/branch/port.go @@ -24,7 +24,7 @@ type BranchStore interface { GetBranchByCashier(ctx context.Context, userID int64) (domain.Branch, error) DeleteBranchCashier(ctx context.Context, userID int64) error - GetBranchCounts(ctx context.Context, filter domain.ReportFilter) (total, active int64, err error) + GetBranchCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error) GetBranchDetails(ctx context.Context, filter domain.ReportFilter) (map[int64]domain.BranchDetail, error) GetAllCompaniesBranch(ctx context.Context) ([]domain.Company, error) diff --git a/internal/services/chapa/client.go b/internal/services/chapa/client.go index be88ebd..dbf12b3 100644 --- a/internal/services/chapa/client.go +++ b/internal/services/chapa/client.go @@ -5,129 +5,230 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" ) -type ChapaClient interface { - IssuePayment(ctx context.Context, payload domain.ChapaTransferPayload) (bool, error) - InitPayment(ctx context.Context, req domain.InitPaymentRequest) (string, error) - FetchBanks() ([]domain.ChapaSupportedBank, error) -} - type Client struct { - BaseURL string - SecretKey string - HTTPClient *http.Client - UserAgent string + baseURL string + secretKey string + httpClient *http.Client } func NewClient(baseURL, secretKey string) *Client { return &Client{ - BaseURL: baseURL, - SecretKey: secretKey, - HTTPClient: http.DefaultClient, - UserAgent: "FortuneBet/1.0", + baseURL: baseURL, + secretKey: secretKey, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, } } -func (c *Client) IssuePayment(ctx context.Context, payload domain.ChapaTransferPayload) (bool, error) { +func (c *Client) InitializePayment(ctx context.Context, req domain.ChapaDepositRequest) (domain.ChapaDepositResponse, error) { + payload := map[string]interface{}{ + "amount": req.Amount, + "currency": req.Currency, + "email": req.Email, + "first_name": req.FirstName, + "last_name": req.LastName, + "tx_ref": req.TxRef, + "callback_url": req.CallbackURL, + "return_url": req.ReturnURL, + } + payloadBytes, err := json.Marshal(payload) if err != nil { - return false, fmt.Errorf("failed to serialize payload: %w", err) + return domain.ChapaDepositResponse{}, fmt.Errorf("failed to marshal payload: %w", err) } - req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/transfers", bytes.NewBuffer(payloadBytes)) + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/transaction/initialize", bytes.NewBuffer(payloadBytes)) if err != nil { - return false, fmt.Errorf("failed to create HTTP request: %w", err) + return domain.ChapaDepositResponse{}, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Authorization", "Bearer "+c.SecretKey) - req.Header.Set("Content-Type", "application/json") - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return false, fmt.Errorf("chapa HTTP request failed: %w", err) - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return true, nil - } - - return false, fmt.Errorf("chapa error: status %d, body: %s", resp.StatusCode, string(body)) -} - -// service/chapa_service.go -func (c *Client) InitPayment(ctx context.Context, req domain.InitPaymentRequest) (string, error) { - fmt.Println("\n\nInit payment request: ", req) - payloadBytes, err := json.Marshal(req) - if err != nil { - fmt.Println("\n\nWe are here") - return "", fmt.Errorf("failed to serialize payload: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/transaction/initialize", bytes.NewBuffer(payloadBytes)) - if err != nil { - fmt.Println("\n\nWe are here 2") - return "", fmt.Errorf("failed to create HTTP request: %w", err) - } - - httpReq.Header.Set("Authorization", "Bearer "+c.SecretKey) + httpReq.Header.Set("Authorization", "Bearer "+c.secretKey) httpReq.Header.Set("Content-Type", "application/json") - resp, err := c.HTTPClient.Do(httpReq) + resp, err := c.httpClient.Do(httpReq) if err != nil { - fmt.Println("\n\nWe are here 3") - return "", fmt.Errorf("chapa HTTP request failed: %w", err) + return domain.ChapaDepositResponse{}, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - fmt.Println("\n\nWe are here 4") - return "", fmt.Errorf("chapa error: status %d, body: %s", resp.StatusCode, string(body)) + if resp.StatusCode != http.StatusOK { + return domain.ChapaDepositResponse{}, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } var response struct { - Data struct { + Message string `json:"message"` + Status string `json:"status"` + Data struct { CheckoutURL string `json:"checkout_url"` } `json:"data"` } - fmt.Printf("\n\nInit payment response body: %v\n\n", response) - - if err := json.Unmarshal(body, &response); err != nil { - return "", fmt.Errorf("failed to parse chapa response: %w", err) + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return domain.ChapaDepositResponse{}, fmt.Errorf("failed to decode response: %w", err) } - return response.Data.CheckoutURL, nil + return domain.ChapaDepositResponse{ + CheckoutURL: response.Data.CheckoutURL, + // Reference: req.TxRef, + }, nil } -func (c *Client) FetchBanks() ([]domain.ChapaSupportedBank, error) { - req, _ := http.NewRequest("GET", c.BaseURL+"/banks", nil) - req.Header.Set("Authorization", "Bearer "+c.SecretKey) - fmt.Printf("\n\nbase URL is: %s\n\n", c.BaseURL) - - res, err := c.HTTPClient.Do(req) +func (c *Client) VerifyPayment(ctx context.Context, reference string) (domain.ChapaDepositVerification, error) { + httpReq, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/transaction/verify/"+reference, nil) if err != nil { - return nil, err - } - defer res.Body.Close() - - var resp struct { - Message string `json:"message"` - Data []domain.ChapaSupportedBank `json:"data"` + return domain.ChapaDepositVerification{}, fmt.Errorf("failed to create request: %w", err) } - if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { - return nil, err + httpReq.Header.Set("Authorization", "Bearer "+c.secretKey) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return domain.ChapaDepositVerification{}, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return domain.ChapaDepositVerification{}, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } - fmt.Printf("\n\nclient fetched banks: %+v\n\n", resp.Data) + var verification domain.ChapaDepositVerification - return resp.Data, nil + if err := json.NewDecoder(resp.Body).Decode(&verification); err != nil { + return domain.ChapaDepositVerification{}, fmt.Errorf("failed to decode response: %w", err) + } + + var status domain.PaymentStatus + switch verification.Status { + case "success": + status = domain.PaymentStatusCompleted + default: + status = domain.PaymentStatusFailed + } + + return domain.ChapaDepositVerification{ + Status: status, + Amount: verification.Amount, + Currency: verification.Currency, + }, nil } + +func (c *Client) ManualVerifyPayment(ctx context.Context, txRef string) (*domain.ChapaVerificationResponse, error) { + url := fmt.Sprintf("%s/transaction/verify/%s", c.baseURL, txRef) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.secretKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var response struct { + Status string `json:"status"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + } + + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + var status domain.PaymentStatus + switch response.Status { + case "success": + status = domain.PaymentStatusCompleted + default: + status = domain.PaymentStatusFailed + } + + return &domain.ChapaVerificationResponse{ + Status: string(status), + Amount: response.Amount, + Currency: response.Currency, + }, nil +} + +func (c *Client) FetchSupportedBanks(ctx context.Context) ([]domain.Bank, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/banks", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.secretKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var bankResponse domain.BankResponse + if err := json.NewDecoder(resp.Body).Decode(&bankResponse); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + var banks []domain.Bank + for _, bankData := range bankResponse.Data { + bank := domain.Bank{ + ID: bankData.ID, + Slug: bankData.Slug, + Swift: bankData.Swift, + Name: bankData.Name, + AcctLength: bankData.AcctLength, + CountryID: bankData.CountryID, + IsMobileMoney: bankData.IsMobileMoney, + IsActive: bankData.IsActive, + IsRTGS: bankData.IsRTGS, + Active: bankData.Active, + Is24Hrs: bankData.Is24Hrs, + CreatedAt: bankData.CreatedAt, + UpdatedAt: bankData.UpdatedAt, + Currency: bankData.Currency, + } + banks = append(banks, bank) + } + + return banks, nil +} + +// Helper method to generate account regex based on bank type +// func GetAccountRegex(bank domain.Bank) string { +// if bank.IsMobileMoney != nil && bank.IsMobileMoney == 1 { +// return `^09[0-9]{8}$` // Ethiopian mobile money pattern +// } +// return fmt.Sprintf(`^[0-9]{%d}$`, bank.AcctLength) +// } + +// // Helper method to generate example account number +// func GetExampleAccount(bank domain.Bank) string { +// if bank.IsMobileMoney != nil && *bank.IsMobileMoney { +// return "0912345678" // Ethiopian mobile number example +// } + +// // Generate example based on length +// example := "1" +// for i := 1; i < bank.AcctLength; i++ { +// example += fmt.Sprintf("%d", i%10) +// } +// return example +// } diff --git a/internal/services/chapa/port.go b/internal/services/chapa/port.go index 0cdb213..581b53f 100644 --- a/internal/services/chapa/port.go +++ b/internal/services/chapa/port.go @@ -6,10 +6,17 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" ) -type ChapaPort interface { - HandleChapaTransferWebhook(ctx context.Context, req domain.ChapaWebHookTransfer) error - HandleChapaPaymentWebhook(ctx context.Context, req domain.ChapaWebHookPayment) error - WithdrawUsingChapa(ctx context.Context, userID int64, req domain.ChapaWithdrawRequest) error - DepositUsingChapa(ctx context.Context, userID int64, req domain.ChapaDepositRequest) (string, error) - GetSupportedBanks() ([]domain.ChapaSupportedBank, error) +// type ChapaPort interface { +// HandleChapaTransferWebhook(ctx context.Context, req domain.ChapaWebHookTransfer) error +// HandleChapaPaymentWebhook(ctx context.Context, req domain.ChapaWebHookPayment) error +// WithdrawUsingChapa(ctx context.Context, userID int64, req domain.ChapaWithdrawRequest) error +// DepositUsingChapa(ctx context.Context, userID int64, req domain.ChapaDepositRequest) (string, error) +// GetSupportedBanks() ([]domain.ChapaSupportedBank, error) +// } + +type ChapaStore interface { + InitializePayment(request domain.ChapaDepositRequest) (domain.ChapaDepositResponse, error) + VerifyPayment(reference string) (domain.ChapaDepositVerification, error) + ManualVerifyPayment(ctx context.Context, txRef string) (*domain.ChapaVerificationResponse, error) + FetchSupportedBanks(ctx context.Context) ([]domain.Bank, error) } diff --git a/internal/services/chapa/service.go b/internal/services/chapa/service.go index 08a573b..cb9281a 100644 --- a/internal/services/chapa/service.go +++ b/internal/services/chapa/service.go @@ -2,377 +2,191 @@ package chapa import ( "context" - "database/sql" "errors" "fmt" - "time" - - // "log/slog" - "strconv" "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/branch" - referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" - "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/google/uuid" - "github.com/shopspring/decimal" +) + +var ( + ErrPaymentNotFound = errors.New("payment not found") + ErrPaymentAlreadyExists = errors.New("payment with this reference already exists") + ErrInvalidPaymentAmount = errors.New("invalid payment amount") ) type Service struct { - transactionStore transaction.TransactionStore - walletStore wallet.WalletStore - userStore user.UserStore - referralStore referralservice.ReferralStore - branchStore branch.BranchStore - chapaClient ChapaClient - config *config.Config - // logger *slog.Logger - store *repository.Store + transferStore wallet.TransferStore + walletStore wallet.WalletStore + userStore user.UserStore + cfg *config.Config + chapaClient *Client } func NewService( - txStore transaction.TransactionStore, + transferStore wallet.TransferStore, walletStore wallet.WalletStore, userStore user.UserStore, - referralStore referralservice.ReferralStore, - branchStore branch.BranchStore, - chapaClient ChapaClient, - store *repository.Store, + chapaClient *Client, + ) *Service { return &Service{ - transactionStore: txStore, - walletStore: walletStore, - userStore: userStore, - referralStore: referralStore, - branchStore: branchStore, - chapaClient: chapaClient, - store: store, + transferStore: transferStore, + walletStore: walletStore, + userStore: userStore, + chapaClient: chapaClient, } } -func (s *Service) HandleChapaTransferWebhook(ctx context.Context, req domain.ChapaWebHookTransfer) error { - _, tx, err := s.store.BeginTx(ctx) - if err != nil { - return err - } - defer tx.Rollback(ctx) - - // Use your services normally (they don’t use the transaction, unless you wire `q`) - referenceID, err := strconv.ParseInt(req.Reference, 10, 64) - if err != nil { - return fmt.Errorf("invalid reference ID: %w", err) +// InitiateDeposit starts a new deposit process +func (s *Service) InitiateDeposit(ctx context.Context, userID int64, amount domain.Currency) (string, error) { + // Validate amount + if amount <= 0 { + return "", ErrInvalidPaymentAmount } - txn, err := s.transactionStore.GetTransactionByID(ctx, referenceID) + // Get user details + user, err := s.userStore.GetUserByID(ctx, userID) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("transaction with ID %d not found", referenceID) + return "", fmt.Errorf("failed to get user: %w", err) + } + + var senderWallet domain.Wallet + + // Generate unique reference + reference := uuid.New().String() + senderWallets, err := s.walletStore.GetWalletsByUser(ctx, userID) + if err != nil { + return "", fmt.Errorf("failed to get sender wallets: %w", err) + } + for _, wallet := range senderWallets { + if wallet.IsWithdraw { + senderWallet = wallet + break } - return err } - if txn.Verified { + + // Check if payment with this reference already exists + // if transfer, err := s.transferStore.GetTransferByReference(ctx, reference); err == nil { + + // return fmt.Sprintf("%v", transfer), ErrPaymentAlreadyExists + // } + + // Create payment record + transfer := domain.CreateTransfer{ + Amount: amount, + Type: domain.DEPOSIT, + PaymentMethod: domain.TRANSFER_CHAPA, + ReferenceNumber: reference, + // ReceiverWalletID: 1, + SenderWalletID: senderWallet.ID, + Verified: false, + } + + if _, err := s.transferStore.CreateTransfer(ctx, transfer); err != nil { + return "", fmt.Errorf("failed to save payment: %w", err) + } + + // Initialize payment with Chapa + response, err := s.chapaClient.InitializePayment(ctx, domain.ChapaDepositRequest{ + Amount: amount, + Currency: "ETB", + Email: user.Email, + FirstName: user.FirstName, + LastName: user.LastName, + TxRef: reference, + CallbackURL: "https://fortunebet.com/api/v1/payments/callback", + ReturnURL: "https://fortunebet.com/api/v1/payment-success", + }) + + if err != nil { + // Update payment status to failed + // _ = s.transferStore.(payment.ID, domain.PaymentStatusFailed) + return "", fmt.Errorf("failed to initialize payment: %w", err) + } + + return response.CheckoutURL, nil +} + +// VerifyDeposit handles payment verification from webhook +func (s *Service) VerifyDeposit(ctx context.Context, reference string) error { + // Find payment by reference + payment, err := s.transferStore.GetTransferByReference(ctx, reference) + if err != nil { + return ErrPaymentNotFound + } + + // Skip if already completed + if payment.Verified { return nil } - webhookAmount, _ := decimal.NewFromString(req.Amount) - storedAmount, _ := decimal.NewFromString(txn.Amount.String()) - if !webhookAmount.Equal(storedAmount) { - return fmt.Errorf("amount mismatch") + // Verify payment with Chapa + verification, err := s.chapaClient.VerifyPayment(ctx, reference) + if err != nil { + return fmt.Errorf("failed to verify payment: %w", err) } - txn.Verified = true - if err := s.transactionStore.UpdateTransactionVerified(ctx, txn.ID, txn.Verified, txn.ApprovedBy.Value, txn.ApproverName.Value); err != nil { - return err + // Update payment status + if err := s.transferStore.UpdateTransferVerification(ctx, payment.ID, true); err != nil { + return fmt.Errorf("failed to update payment status: %w", err) } - return tx.Commit(ctx) + // If payment is completed, credit user's wallet + if verification.Status == domain.PaymentStatusCompleted { + if err := s.walletStore.UpdateBalance(ctx, payment.SenderWalletID, payment.Amount); err != nil { + return fmt.Errorf("failed to credit user wallet: %w", err) + } + } + + return nil } -func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.ChapaWebHookPayment) error { - _, tx, err := s.store.BeginTx(ctx) - if err != nil { - return err - } - defer tx.Rollback(ctx) - - if req.Status != "success" { - return fmt.Errorf("payment status not successful") +func (s *Service) ManualVerifyPayment(ctx context.Context, txRef string) (*domain.ChapaVerificationResponse, error) { + // First check if we already have a verified record + transfer, err := s.transferStore.GetTransferByReference(ctx, txRef) + if err == nil && transfer.Verified { + return &domain.ChapaVerificationResponse{ + Status: string(domain.PaymentStatusCompleted), + Amount: float64(transfer.Amount) / 100, // Convert from cents/kobo + Currency: "ETB", + }, nil } - // 1. Parse reference ID - referenceID, err := strconv.ParseInt(req.TxRef, 10, 64) + // If not verified or not found, verify with Chapa + verification, err := s.chapaClient.VerifyPayment(ctx, txRef) if err != nil { - return fmt.Errorf("invalid tx_ref: %w", err) + return nil, fmt.Errorf("failed to verify payment: %w", err) } - // 2. Fetch transaction - txn, err := s.transactionStore.GetTransactionByID(ctx, referenceID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("transaction with ID %d not found", referenceID) + // Update our records if payment is successful + if verification.Status == domain.PaymentStatusCompleted { + err = s.transferStore.UpdateTransferVerification(ctx, transfer.ID, true) + if err != nil { + return nil, fmt.Errorf("failed to update verification status: %w", err) } - return err - } - if txn.Verified { - return nil // already processed - } - - webhookAmount, _ := strconv.ParseFloat(req.Amount, 32) - if webhookAmount < float64(txn.Amount) { - return fmt.Errorf("webhook amount is less than expected") - } - - // 4. Fetch wallet - wallet, err := s.walletStore.GetWalletByID(ctx, txn.ID) - if err != nil { - return err - } - - // 5. Update wallet balance - newBalance := wallet.Balance + txn.Amount - if err := s.walletStore.UpdateBalance(ctx, wallet.ID, newBalance); err != nil { - return err - } - - // 6. Mark transaction as verified - if err := s.transactionStore.UpdateTransactionVerified(ctx, txn.ID, true, txn.ApprovedBy.Value, txn.ApproverName.Value); err != nil { - return err - } - - // 7. Check & Create Referral - stats, err := s.referralStore.GetReferralStats(ctx, strconv.FormatInt(wallet.UserID, 10)) - if err != nil { - return err - } - - if stats == nil { - if err := s.referralStore.CreateReferral(ctx, wallet.UserID); err != nil { - return err + // Credit user's wallet + err = s.walletStore.UpdateBalance(ctx, transfer.SenderWalletID, transfer.Amount) + if err != nil { + return nil, fmt.Errorf("failed to update wallet balance: %w", err) } } - return tx.Commit(ctx) + return &domain.ChapaVerificationResponse{ + Status: string(verification.Status), + Amount: float64(verification.Amount), + Currency: verification.Currency, + }, nil } -func (s *Service) WithdrawUsingChapa(ctx context.Context, userID int64, req domain.ChapaWithdrawRequest) error { - _, tx, err := s.store.BeginTx(ctx) +func (s *Service) GetSupportedBanks(ctx context.Context) ([]domain.Bank, error) { + banks, err := s.chapaClient.FetchSupportedBanks(ctx) if err != nil { - return err + return nil, fmt.Errorf("failed to fetch banks: %w", err) } - defer tx.Rollback(ctx) - - // Get the requesting user - user, err := s.userStore.GetUserByID(ctx, userID) - if err != nil { - return fmt.Errorf("user not found: %w", err) - } - - banks, err := s.GetSupportedBanks() - validBank := false - for _, bank := range banks { - if strconv.FormatInt(bank.Id, 10) == req.BankCode { - validBank = true - break - } - } - if !validBank { - return fmt.Errorf("invalid bank code") - } - - // branch, err := s.branchStore.GetBranchByID(ctx, req.BranchID) - // if err != nil { - // return err - // } - - var targetWallet domain.Wallet - targetWallet, err = s.walletStore.GetWalletByID(ctx, req.WalletID) - if err != nil { - return err - } - - // for _, w := range wallets { - // if w.ID == req.WalletID { - // targetWallet = &w - // break - // } - // } - - // if targetWallet == nil { - // return fmt.Errorf("no wallet found with the specified ID") - // } - - if !targetWallet.IsTransferable || !targetWallet.IsActive { - return fmt.Errorf("wallet not eligible for withdrawal") - } - - if targetWallet.Balance < domain.Currency(req.Amount) { - return fmt.Errorf("insufficient balance") - } - - txID := uuid.New().String() - - payload := domain.ChapaTransferPayload{ - AccountName: req.AccountName, - AccountNumber: req.AccountNumber, - Amount: strconv.FormatInt(req.Amount, 10), - Currency: req.Currency, - BeneficiaryName: req.BeneficiaryName, - TxRef: txID, - Reference: txID, - BankCode: req.BankCode, - } - - ok, err := s.chapaClient.IssuePayment(ctx, payload) - if err != nil || !ok { - return fmt.Errorf("chapa transfer failed: %v", err) - } - - // Create transaction using user and wallet info - _, err = s.transactionStore.CreateTransaction(ctx, domain.CreateTransaction{ - Amount: domain.Currency(req.Amount), - Type: domain.TransactionType(domain.TRANSACTION_CASHOUT), - ReferenceNumber: txID, - AccountName: req.AccountName, - AccountNumber: req.AccountNumber, - BankCode: req.BankCode, - BeneficiaryName: req.BeneficiaryName, - PaymentOption: domain.PaymentOption(domain.BANK), - BranchID: req.BranchID, - // BranchName: branch.Name, - // BranchLocation: branch.Location, - // CashierID: user.ID, - // CashierName: user.FullName, - FullName: user.FirstName + " " + user.LastName, - PhoneNumber: user.PhoneNumber, - // CompanyID: branch.CompanyID, - }) - if err != nil { - return fmt.Errorf("failed to create transaction: %w", err) - } - - newBalance := domain.Currency(req.Amount) - err = s.walletStore.UpdateBalance(ctx, targetWallet.ID, newBalance) - if err != nil { - return fmt.Errorf("failed to update wallet balance: %w", err) - } - - return tx.Commit(ctx) -} - -func (s *Service) DepositUsingChapa(ctx context.Context, userID int64, req domain.ChapaDepositRequest) (string, error) { - _, tx, err := s.store.BeginTx(ctx) - if err != nil { - return "", err - } - defer tx.Rollback(ctx) - - if req.Amount <= 0 { - return "", fmt.Errorf("amount must be positive") - } - - user, err := s.userStore.GetUserByID(ctx, userID) - if err != nil { - return "", err - } - - branch, err := s.branchStore.GetBranchByID(ctx, req.BranchID) - if err != nil { - return "", err - } - - txID := uuid.New().String() - - fmt.Printf("\n\nChapa deposit transaction created: %v%v\n\n", branch, user) - - // _, err = s.transactionStore.CreateTransaction(ctx, domain.CreateTransaction{ - // Amount: req.Amount, - // Type: domain.TransactionType(domain.TRANSACTION_DEPOSIT), - // ReferenceNumber: txID, - // BranchID: req.BranchID, - // BranchName: branch.Name, - // BranchLocation: branch.Location, - // FullName: user.FirstName + " " + user.LastName, - // PhoneNumber: user.PhoneNumber, - // // CompanyID: branch.CompanyID, - // }) - // if err != nil { - // return "", err - // } - - // Fetch user details for Chapa payment - userInfo, err := s.userStore.GetUserByID(ctx, userID) - if err != nil { - return "", err - } - - // fmt.Printf("\n\nCallbackURL is:%v\n\n", s.config.CHAPA_CALLBACK_URL) - - // Build Chapa InitPaymentRequest (matches Chapa API) - paymentReq := domain.InitPaymentRequest{ - Amount: req.Amount, - Currency: req.Currency, - Email: userInfo.Email, - FirstName: userInfo.FirstName, - LastName: userInfo.LastName, - TxRef: txID, - CallbackURL: "https://fortunebet.com/api/v1/payments/callback", - ReturnURL: "https://fortunebet.com/api/v1/payment-success", - } - - // Call Chapa to initialize payment - var paymentURL string - maxRetries := 3 - for range maxRetries { - paymentURL, err = s.chapaClient.InitPayment(ctx, paymentReq) - if err == nil { - break - } - time.Sleep(1 * time.Second) // Backoff - } - - // Commit DB transaction - if err := tx.Commit(ctx); err != nil { - return "", err - } - - return paymentURL, nil -} - -func (s *Service) GetSupportedBanks() ([]domain.ChapaSupportedBank, error) { - banks, err := s.chapaClient.FetchBanks() - fmt.Printf("\n\nfetched banks: %+v\n\n", banks) - if err != nil { - return nil, err - } - - // Add formatting logic (same as in original controller) - for i := range banks { - if banks[i].IsMobilemoney != nil && *(banks[i].IsMobilemoney) == 1 { - banks[i].AcctNumberRegex = "/^09[0-9]{8}$/" - banks[i].ExampleValue = "0952097177" - } else { - switch banks[i].AcctLength { - case 8: - banks[i].ExampleValue = "16967608" - case 13: - banks[i].ExampleValue = "1000222215735" - case 14: - banks[i].ExampleValue = "01320089280800" - case 16: - banks[i].ExampleValue = "1000222215735123" - } - banks[i].AcctNumberRegex = formatRegex(banks[i].AcctLength) - } - } - return banks, nil } - -func formatRegex(length int) string { - return fmt.Sprintf("/^[0-9]{%d}$/", length) -} diff --git a/internal/services/company/port.go b/internal/services/company/port.go index 7081899..a6d14da 100644 --- a/internal/services/company/port.go +++ b/internal/services/company/port.go @@ -13,4 +13,6 @@ type CompanyStore interface { GetCompanyByID(ctx context.Context, id int64) (domain.GetCompany, error) UpdateCompany(ctx context.Context, company domain.UpdateCompany) (domain.Company, error) DeleteCompany(ctx context.Context, id int64) error + + GetCompanyCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error) } diff --git a/internal/services/notfication/port.go b/internal/services/notfication/port.go index ec82c03..2d03f80 100644 --- a/internal/services/notfication/port.go +++ b/internal/services/notfication/port.go @@ -18,4 +18,6 @@ type NotificationStore interface { ListRecipientIDs(ctx context.Context, receiver domain.NotificationRecieverSide) ([]int64, error) // New method CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) + + GetNotificationCounts(ctx context.Context, filter domain.ReportFilter) (total, read, unread int64, err error) } diff --git a/internal/services/notfication/service.go b/internal/services/notfication/service.go index 5d5760c..2e92e19 100644 --- a/internal/services/notfication/service.go +++ b/internal/services/notfication/service.go @@ -45,7 +45,7 @@ func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *confi return svc } -func (s *Service) addConnection(ctx context.Context, recipientID int64, c *websocket.Conn) { +func (s *Service) addConnection(recipientID int64, c *websocket.Conn) { if c == nil { s.logger.Warn("[NotificationSvc.AddConnection] Attempted to add nil WebSocket connection", "recipientID", recipientID) return @@ -134,7 +134,7 @@ func (s *Service) GetAllNotifications(ctx context.Context, limit, offset int) ([ } func (s *Service) ConnectWebSocket(ctx context.Context, recipientID int64, c *websocket.Conn) error { - s.addConnection(ctx, recipientID, c) + s.addConnection(recipientID, c) s.logger.Info("[NotificationSvc.ConnectWebSocket] WebSocket connection established", "recipientID", recipientID) return nil } @@ -283,3 +283,7 @@ func (s *Service) retryFailedNotifications() { func (s *Service) CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) { return s.repo.CountUnreadNotifications(ctx, recipient_id) } + +// func (s *Service) GetNotificationCounts(ctx context.Context, filter domain.ReportFilter) (total, read, unread int64, err error){ +// return s.repo.Get(ctx, filter) +// } diff --git a/internal/services/report/port.go b/internal/services/report/port.go index 7f592e8..33d6050 100644 --- a/internal/services/report/port.go +++ b/internal/services/report/port.go @@ -7,9 +7,12 @@ import ( ) type ReportStore interface { - GetDashboardSummary(ctx context.Context, filter domain.ReportFilter) (DashboardSummary, error) - GetBetAnalysis(ctx context.Context, filter domain.ReportFilter) ([]BetAnalysis, error) - GetCustomerActivity(ctx context.Context, filter domain.ReportFilter) ([]CustomerActivity, error) - GetBranchPerformance(ctx context.Context, filter domain.ReportFilter) ([]BranchPerformance, error) - GetSportPerformance(ctx context.Context, filter domain.ReportFilter) ([]SportPerformance, error) + GetDashboardSummary(ctx context.Context, filter domain.ReportFilter) (domain.DashboardSummary, error) + GetBetAnalysis(ctx context.Context, filter domain.ReportFilter) ([]domain.BetAnalysis, error) + GetCustomerActivity(ctx context.Context, filter domain.ReportFilter) ([]domain.CustomerActivity, error) + GetBranchPerformance(ctx context.Context, filter domain.ReportFilter) ([]domain.BranchPerformance, error) + GetSportPerformance(ctx context.Context, filter domain.ReportFilter) ([]domain.SportPerformance, error) + // GetNotificationReport(ctx context.Context, filter domain.ReportFilter) (domain.NotificationReport, error) + // GetCashierPerformance(ctx context.Context, filter domain.ReportFilter) ([]domain.CashierPerformance, error) + // GetCompanyPerformance(ctx context.Context, filter domain.ReportFilter) ([]domain.CompanyPerformance, error) } diff --git a/internal/services/report/service.go b/internal/services/report/service.go index 854ef32..d102d18 100644 --- a/internal/services/report/service.go +++ b/internal/services/report/service.go @@ -5,13 +5,16 @@ import ( "errors" "log/slog" "sort" - "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/company" + // notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" + // virtualgameservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" ) @@ -21,12 +24,15 @@ var ( ) type Service struct { - betStore bet.BetStore - walletStore wallet.WalletStore - transactionStore transaction.TransactionStore - branchStore branch.BranchStore - userStore user.UserStore - logger *slog.Logger + betStore bet.BetStore + walletStore wallet.WalletStore + transactionStore transaction.TransactionStore + branchStore branch.BranchStore + userStore user.UserStore + companyStore company.CompanyStore + virtulaGamesStore repository.VirtualGameRepository + notificationStore repository.NotificationRepository + logger *slog.Logger } func NewService( @@ -35,44 +41,31 @@ func NewService( transactionStore transaction.TransactionStore, branchStore branch.BranchStore, userStore user.UserStore, + companyStore company.CompanyStore, + virtulaGamesStore repository.VirtualGameRepository, + notificationStore repository.NotificationRepository, logger *slog.Logger, ) *Service { return &Service{ - betStore: betStore, - walletStore: walletStore, - transactionStore: transactionStore, - branchStore: branchStore, - userStore: userStore, - logger: logger, + betStore: betStore, + walletStore: walletStore, + transactionStore: transactionStore, + branchStore: branchStore, + userStore: userStore, + companyStore: companyStore, + virtulaGamesStore: virtulaGamesStore, + notificationStore: notificationStore, + logger: logger, } } -// DashboardSummary represents comprehensive dashboard metrics -type DashboardSummary struct { - TotalStakes domain.Currency `json:"total_stakes"` - TotalBets int64 `json:"total_bets"` - ActiveBets int64 `json:"active_bets"` - WinBalance domain.Currency `json:"win_balance"` - TotalWins int64 `json:"total_wins"` - TotalLosses int64 `json:"total_losses"` - CustomerCount int64 `json:"customer_count"` - Profit domain.Currency `json:"profit"` - WinRate float64 `json:"win_rate"` - AverageStake domain.Currency `json:"average_stake"` - TotalDeposits domain.Currency `json:"total_deposits"` - TotalWithdrawals domain.Currency `json:"total_withdrawals"` - ActiveCustomers int64 `json:"active_customers"` - BranchesCount int64 `json:"branches_count"` - ActiveBranches int64 `json:"active_branches"` -} - // GetDashboardSummary returns comprehensive dashboard metrics -func (s *Service) GetDashboardSummary(ctx context.Context, filter domain.ReportFilter) (DashboardSummary, error) { +func (s *Service) GetDashboardSummary(ctx context.Context, filter domain.ReportFilter) (domain.DashboardSummary, error) { if err := validateTimeRange(filter); err != nil { - return DashboardSummary{}, err + return domain.DashboardSummary{}, err } - var summary DashboardSummary + var summary domain.DashboardSummary var err error // Get bets summary @@ -80,28 +73,75 @@ func (s *Service) GetDashboardSummary(ctx context.Context, filter domain.ReportF s.betStore.GetBetSummary(ctx, filter) if err != nil { s.logger.Error("failed to get bet summary", "error", err) - return DashboardSummary{}, err + return domain.DashboardSummary{}, err } // Get customer metrics - summary.CustomerCount, summary.ActiveCustomers, err = s.userStore.GetCustomerCounts(ctx, filter) + summary.CustomerCount, summary.ActiveCustomers, summary.InactiveCustomers, err = s.userStore.GetCustomerCounts(ctx, filter) if err != nil { s.logger.Error("failed to get customer counts", "error", err) - return DashboardSummary{}, err + return domain.DashboardSummary{}, err } // Get branch metrics - summary.BranchesCount, summary.ActiveBranches, err = s.branchStore.GetBranchCounts(ctx, filter) + summary.BranchesCount, summary.ActiveBranches, summary.InactiveBranches, err = s.branchStore.GetBranchCounts(ctx, filter) if err != nil { s.logger.Error("failed to get branch counts", "error", err) - return DashboardSummary{}, err + return domain.DashboardSummary{}, err } // Get transaction metrics summary.TotalDeposits, summary.TotalWithdrawals, err = s.transactionStore.GetTransactionTotals(ctx, filter) if err != nil { s.logger.Error("failed to get transaction totals", "error", err) - return DashboardSummary{}, err + return domain.DashboardSummary{}, err + } + + // Get user role metrics + summary.TotalCashiers, summary.ActiveCashiers, summary.InactiveCashiers, err = s.userStore.GetRoleCounts(ctx, string(domain.RoleCashier), filter) + if err != nil { + s.logger.Error("failed to get cashier counts", "error", err) + return domain.DashboardSummary{}, err + } + + summary.TotalManagers, summary.ActiveManagers, summary.InactiveManagers, err = s.userStore.GetRoleCounts(ctx, string(domain.RoleBranchManager), filter) + if err != nil { + s.logger.Error("failed to get manager counts", "error", err) + return domain.DashboardSummary{}, err + } + + summary.TotalAdmins, summary.ActiveAdmins, summary.InactiveAdmins, err = s.userStore.GetRoleCounts(ctx, string(domain.RoleAdmin), filter) + if err != nil { + s.logger.Error("failed to get admin counts", "error", err) + return domain.DashboardSummary{}, err + } + + // Get wallet metrics + summary.TotalWallets, err = s.walletStore.GetTotalWallets(ctx, filter) + if err != nil { + s.logger.Error("failed to get wallet counts", "error", err) + return domain.DashboardSummary{}, err + } + + // Get sport/game metrics + summary.TotalGames, summary.ActiveGames, summary.InactiveGames, err = s.virtulaGamesStore.GetGameCounts(ctx, filter) + if err != nil { + s.logger.Error("failed to get game counts", "error", err) + return domain.DashboardSummary{}, err + } + + // Get company metrics + summary.TotalCompanies, summary.ActiveCompanies, summary.InactiveCompanies, err = s.companyStore.GetCompanyCounts(ctx, filter) + if err != nil { + s.logger.Error("failed to get company counts", "error", err) + return domain.DashboardSummary{}, err + } + + // Get notification metrics + summary.TotalNotifications, summary.ReadNotifications, summary.UnreadNotifications, err = s.notificationStore.GetNotificationCounts(ctx, filter) + if err != nil { + s.logger.Error("failed to get notification counts", "error", err) + return domain.DashboardSummary{}, err } // Calculate derived metrics @@ -114,23 +154,8 @@ func (s *Service) GetDashboardSummary(ctx context.Context, filter domain.ReportF return summary, nil } -// BetAnalysis represents detailed bet analysis -type BetAnalysis struct { - Date time.Time `json:"date"` - TotalBets int64 `json:"total_bets"` - TotalStakes domain.Currency `json:"total_stakes"` - TotalWins int64 `json:"total_wins"` - TotalPayouts domain.Currency `json:"total_payouts"` - Profit domain.Currency `json:"profit"` - MostPopularSport string `json:"most_popular_sport"` - MostPopularMarket string `json:"most_popular_market"` - HighestStake domain.Currency `json:"highest_stake"` - HighestPayout domain.Currency `json:"highest_payout"` - AverageOdds float64 `json:"average_odds"` -} - -// GetBetAnalysis returns detailed bet analysis -func (s *Service) GetBetAnalysis(ctx context.Context, filter domain.ReportFilter) ([]BetAnalysis, error) { +// Getdomain.BetAnalysis returns detailed bet analysis +func (s *Service) GetBetAnalysis(ctx context.Context, filter domain.ReportFilter) ([]domain.BetAnalysis, error) { if err := validateTimeRange(filter); err != nil { return nil, err } @@ -164,9 +189,9 @@ func (s *Service) GetBetAnalysis(ctx context.Context, filter domain.ReportFilter } // Combine data into analysis - var analysis []BetAnalysis + var analysis []domain.BetAnalysis for _, stat := range betStats { - a := BetAnalysis{ + a := domain.BetAnalysis{ Date: stat.Date, TotalBets: stat.TotalBets, TotalStakes: stat.TotalStakes, @@ -203,27 +228,8 @@ func (s *Service) GetBetAnalysis(ctx context.Context, filter domain.ReportFilter return analysis, nil } -// CustomerActivity represents customer activity metrics -type CustomerActivity struct { - CustomerID int64 `json:"customer_id"` - CustomerName string `json:"customer_name"` - TotalBets int64 `json:"total_bets"` - TotalStakes domain.Currency `json:"total_stakes"` - TotalWins int64 `json:"total_wins"` - TotalPayouts domain.Currency `json:"total_payouts"` - Profit domain.Currency `json:"profit"` - FirstBetDate time.Time `json:"first_bet_date"` - LastBetDate time.Time `json:"last_bet_date"` - FavoriteSport string `json:"favorite_sport"` - FavoriteMarket string `json:"favorite_market"` - AverageStake domain.Currency `json:"average_stake"` - AverageOdds float64 `json:"average_odds"` - WinRate float64 `json:"win_rate"` - ActivityLevel string `json:"activity_level"` // High, Medium, Low -} - -// GetCustomerActivity returns customer activity report -func (s *Service) GetCustomerActivity(ctx context.Context, filter domain.ReportFilter) ([]CustomerActivity, error) { +// Getdomain.CustomerActivity returns customer activity report +func (s *Service) GetCustomerActivity(ctx context.Context, filter domain.ReportFilter) ([]domain.CustomerActivity, error) { if err := validateTimeRange(filter); err != nil { return nil, err } @@ -250,9 +256,9 @@ func (s *Service) GetCustomerActivity(ctx context.Context, filter domain.ReportF } // Combine data into activity report - var activities []CustomerActivity + var activities []domain.CustomerActivity for _, bet := range customerBets { - activity := CustomerActivity{ + activity := domain.CustomerActivity{ CustomerID: bet.CustomerID, TotalBets: bet.TotalBets, TotalStakes: bet.TotalStakes, @@ -295,27 +301,8 @@ func (s *Service) GetCustomerActivity(ctx context.Context, filter domain.ReportF return activities, nil } -// BranchPerformance represents branch performance metrics -type BranchPerformance struct { - BranchID int64 `json:"branch_id"` - BranchName string `json:"branch_name"` - Location string `json:"location"` - ManagerName string `json:"manager_name"` - TotalBets int64 `json:"total_bets"` - TotalStakes domain.Currency `json:"total_stakes"` - TotalWins int64 `json:"total_wins"` - TotalPayouts domain.Currency `json:"total_payouts"` - Profit domain.Currency `json:"profit"` - CustomerCount int64 `json:"customer_count"` - Deposits domain.Currency `json:"deposits"` - Withdrawals domain.Currency `json:"withdrawals"` - WinRate float64 `json:"win_rate"` - AverageStake domain.Currency `json:"average_stake"` - PerformanceScore float64 `json:"performance_score"` -} - -// GetBranchPerformance returns branch performance report -func (s *Service) GetBranchPerformance(ctx context.Context, filter domain.ReportFilter) ([]BranchPerformance, error) { +// Getdomain.BranchPerformance returns branch performance report +func (s *Service) GetBranchPerformance(ctx context.Context, filter domain.ReportFilter) ([]domain.BranchPerformance, error) { // Get branch bet activity branchBets, err := s.betStore.GetBranchBetActivity(ctx, filter) if err != nil { @@ -345,9 +332,9 @@ func (s *Service) GetBranchPerformance(ctx context.Context, filter domain.Report } // Combine data into performance report - var performances []BranchPerformance + var performances []domain.BranchPerformance for _, bet := range branchBets { - performance := BranchPerformance{ + performance := domain.BranchPerformance{ BranchID: bet.BranchID, TotalBets: bet.TotalBets, TotalStakes: bet.TotalStakes, @@ -394,24 +381,8 @@ func (s *Service) GetBranchPerformance(ctx context.Context, filter domain.Report return performances, nil } -// SportPerformance represents sport performance metrics -type SportPerformance struct { - SportID string `json:"sport_id"` - SportName string `json:"sport_name"` - TotalBets int64 `json:"total_bets"` - TotalStakes domain.Currency `json:"total_stakes"` - TotalWins int64 `json:"total_wins"` - TotalPayouts domain.Currency `json:"total_payouts"` - Profit domain.Currency `json:"profit"` - PopularityRank int `json:"popularity_rank"` - WinRate float64 `json:"win_rate"` - AverageStake domain.Currency `json:"average_stake"` - AverageOdds float64 `json:"average_odds"` - MostPopularMarket string `json:"most_popular_market"` -} - -// GetSportPerformance returns sport performance report -func (s *Service) GetSportPerformance(ctx context.Context, filter domain.ReportFilter) ([]SportPerformance, error) { +// Getdomain.SportPerformance returns sport performance report +func (s *Service) GetSportPerformance(ctx context.Context, filter domain.ReportFilter) ([]domain.SportPerformance, error) { // Get sport bet activity sportBets, err := s.betStore.GetSportBetActivity(ctx, filter) if err != nil { @@ -434,9 +405,9 @@ func (s *Service) GetSportPerformance(ctx context.Context, filter domain.ReportF } // Combine data into performance report - var performances []SportPerformance + var performances []domain.SportPerformance for _, bet := range sportBets { - performance := SportPerformance{ + performance := domain.SportPerformance{ SportID: bet.SportID, TotalBets: bet.TotalBets, TotalStakes: bet.TotalStakes, @@ -477,6 +448,164 @@ func (s *Service) GetSportPerformance(ctx context.Context, filter domain.ReportF return performances, nil } +// func (s *Service) GetCompanyPerformance(ctx context.Context, filter domain.ReportFilter) ([]domain.CompanyPerformance, error) { +// // Get company bet activity +// companyBets, err := s.betStore.GetCompanyBetActivity(ctx, filter) +// if err != nil { +// s.logger.Error("failed to get company bet activity", "error", err) +// return nil, err +// } + +// // Get company details +// companyDetails, err := s.branchStore.GetCompanyDetails(ctx, filter) +// if err != nil { +// s.logger.Error("failed to get company details", "error", err) +// return nil, err +// } + +// // Get company branches +// companyBranches, err := s.branchStore.GetCompanyBranchCounts(ctx, filter) +// if err != nil { +// s.logger.Error("failed to get company branch counts", "error", err) +// return nil, err +// } + +// // Combine data into performance report +// var performances []domain.CompanyPerformance +// for _, bet := range companyBets { +// performance := domain.CompanyPerformance{ +// CompanyID: bet.CompanyID, +// TotalBets: bet.TotalBets, +// TotalStakes: bet.TotalStakes, +// TotalWins: bet.TotalWins, +// TotalPayouts: bet.TotalPayouts, +// Profit: bet.TotalStakes - bet.TotalPayouts, +// } + +// // Add company details +// if details, ok := companyDetails[bet.CompanyID]; ok { +// performance.CompanyName = details.Name +// performance.ContactEmail = details.ContactEmail +// } + +// // Add branch counts +// if branches, ok := companyBranches[bet.CompanyID]; ok { +// performance.TotalBranches = branches.Total +// performance.ActiveBranches = branches.Active +// } + +// // Calculate metrics +// if bet.TotalBets > 0 { +// performance.WinRate = float64(bet.TotalWins) / float64(bet.TotalBets) * 100 +// performance.AverageStake = bet.TotalStakes / domain.Currency(bet.TotalBets) +// } + +// performances = append(performances, performance) +// } + +// // Sort by profit (descending) +// sort.Slice(performances, func(i, j int) bool { +// return performances[i].Profit > performances[j].Profit +// }) + +// return performances, nil +// } + +// GetCashierPerformance returns cashier performance report +// func (s *Service) GetCashierPerformance(ctx context.Context, filter domain.ReportFilter) ([]domain.CashierPerformance, error) { +// // Get cashier bet activity +// cashierBets, err := s.betStore.GetCashierBetActivity(ctx, filter) +// if err != nil { +// s.logger.Error("failed to get cashier bet activity", "error", err) +// return nil, err +// } + +// // Get cashier details +// cashierDetails, err := s.userStore.GetCashierDetails(ctx, filter) +// if err != nil { +// s.logger.Error("failed to get cashier details", "error", err) +// return nil, err +// } + +// // Get cashier transactions +// cashierTransactions, err := s.transactionStore.GetCashierTransactionTotals(ctx, filter) +// if err != nil { +// s.logger.Error("failed to get cashier transactions", "error", err) +// return nil, err +// } + +// // Combine data into performance report +// var performances []domain.CashierPerformance +// for _, bet := range cashierBets { +// performance := domain.CashierPerformance{ +// CashierID: bet.CashierID, +// TotalBets: bet.TotalBets, +// TotalStakes: bet.TotalStakes, +// TotalWins: bet.TotalWins, +// TotalPayouts: bet.TotalPayouts, +// Profit: bet.TotalStakes - bet.TotalPayouts, +// } + +// // Add cashier details +// if details, ok := cashierDetails[bet.CashierID]; ok { +// performance.CashierName = details.Name +// performance.BranchID = details.BranchID +// performance.BranchName = details.BranchName +// } + +// // Add transactions +// if transactions, ok := cashierTransactions[bet.CashierID]; ok { +// performance.Deposits = transactions.Deposits +// performance.Withdrawals = transactions.Withdrawals +// } + +// // Calculate metrics +// if bet.TotalBets > 0 { +// performance.WinRate = float64(bet.TotalWins) / float64(bet.TotalBets) * 100 +// performance.AverageStake = bet.TotalStakes / domain.Currency(bet.TotalBets) +// } + +// performances = append(performances, performance) +// } + +// // Sort by total stakes (descending) +// sort.Slice(performances, func(i, j int) bool { +// return performances[i].TotalStakes > performances[j].TotalStakes +// }) + +// return performances, nil +// } + +// GetNotificationReport returns notification statistics report +// func (s *Service) GetNotificationReport(ctx context.Context, filter domain.ReportFilter) (domain.NotificationReport, error) { +// // Get notification counts by type +// countsByType, err := s.notificationStore.GetNotificationCountsByType(ctx, filter) +// if err != nil { +// s.logger.Error("failed to get notification counts by type", "error", err) +// return domain.NotificationReport{}, err +// } + +// // Get notification delivery stats +// deliveryStats, err := s.notificationStore.GetNotificationDeliveryStats(ctx, filter) +// if err != nil { +// s.logger.Error("failed to get notification delivery stats", "error", err) +// return domain.NotificationReport{}, err +// } + +// // Get most active notification recipients +// activeRecipients, err := s.notificationStore.GetMostActiveNotificationRecipients(ctx, filter) +// if err != nil { +// s.logger.Error("failed to get active notification recipients", "error", err) +// return domain.NotificationReport{}, err +// } + +// return domain.NotificationReport{ +// CountsByType: countsByType, +// DeliveryStats: deliveryStats, +// ActiveRecipients: activeRecipients, +// }, nil +// } + // Helper functions func validateTimeRange(filter domain.ReportFilter) error { if filter.StartTime.Valid && filter.EndTime.Valid { @@ -498,7 +627,7 @@ func calculateActivityLevel(totalBets int64, totalStakes domain.Currency) string } } -func calculatePerformanceScore(perf BranchPerformance) float64 { +func calculatePerformanceScore(perf domain.BranchPerformance) float64 { // Simple scoring algorithm - can be enhanced based on business rules profitScore := float64(perf.Profit) / 1000 customerScore := float64(perf.CustomerCount) * 0.1 diff --git a/internal/services/user/port.go b/internal/services/user/port.go index 9a1317c..c8149df 100644 --- a/internal/services/user/port.go +++ b/internal/services/user/port.go @@ -24,9 +24,10 @@ type UserStore interface { SearchUserByNameOrPhone(ctx context.Context, searchString string, role *domain.Role, companyID domain.ValidInt64) ([]domain.User, error) UpdatePassword(ctx context.Context, identifier string, password []byte, usedOtpId int64) error // identifier verified email or phone - GetCustomerCounts(ctx context.Context, filter domain.ReportFilter) (total, active int64, err error) + GetCustomerCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error) GetCustomerDetails(ctx context.Context, filter domain.ReportFilter) (map[int64]domain.CustomerDetail, error) GetBranchCustomerCounts(ctx context.Context, filter domain.ReportFilter) (map[int64]int64, error) + GetRoleCounts(ctx context.Context, role string, filter domain.ReportFilter) (total, active, inactive int64, err error) } type SmsGateway interface { SendSMSOTP(ctx context.Context, phoneNumber, otp string) error diff --git a/internal/services/virtualGame/port.go b/internal/services/virtualGame/port.go index 0814a07..4daa771 100644 --- a/internal/services/virtualGame/port.go +++ b/internal/services/virtualGame/port.go @@ -9,5 +9,6 @@ import ( type VirtualGameService interface { GenerateGameLaunchURL(ctx context.Context, userID int64, gameID, currency, mode string) (string, error) HandleCallback(ctx context.Context, callback *domain.PopOKCallback) error -} + GetGameCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error) +} diff --git a/internal/services/virtualGame/service.go b/internal/services/virtualGame/service.go index 688c8ef..9fcbb75 100644 --- a/internal/services/virtualGame/service.go +++ b/internal/services/virtualGame/service.go @@ -19,11 +19,12 @@ import ( ) type service struct { - repo repository.VirtualGameRepository - walletSvc wallet.Service - store *repository.Store - config *config.Config - logger *slog.Logger + repo repository.VirtualGameRepository + walletSvc wallet.Service + store *repository.Store + // virtualGameStore repository.VirtualGameRepository + config *config.Config + logger *slog.Logger } func New(repo repository.VirtualGameRepository, walletSvc wallet.Service, store *repository.Store, cfg *config.Config, logger *slog.Logger) VirtualGameService { @@ -166,3 +167,7 @@ func (s *service) verifySignature(callback *domain.PopOKCallback) bool { expected := hex.EncodeToString(h.Sum(nil)) return expected == callback.Signature } + +func (s *service) GetGameCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error) { + return s.repo.GetGameCounts(ctx, filter) +} diff --git a/internal/services/wallet/port.go b/internal/services/wallet/port.go index c6ad52e..66eabef 100644 --- a/internal/services/wallet/port.go +++ b/internal/services/wallet/port.go @@ -18,12 +18,14 @@ type WalletStore interface { UpdateWalletActive(ctx context.Context, id int64, isActive bool) error GetBalanceSummary(ctx context.Context, filter domain.ReportFilter) (domain.BalanceSummary, error) + GetTotalWallets(ctx context.Context, filter domain.ReportFilter) (int64, error) } type TransferStore interface { CreateTransfer(ctx context.Context, transfer domain.CreateTransfer) (domain.Transfer, error) GetAllTransfers(ctx context.Context) ([]domain.Transfer, error) GetTransfersByWallet(ctx context.Context, walletID int64) ([]domain.Transfer, error) + GetTransferByReference(ctx context.Context, reference string) (domain.Transfer, error) GetTransferByID(ctx context.Context, id int64) (domain.Transfer, error) UpdateTransferVerification(ctx context.Context, id int64, verified bool) error } diff --git a/internal/services/wallet/transfer.go b/internal/services/wallet/transfer.go index 927120f..7f71c4a 100644 --- a/internal/services/wallet/transfer.go +++ b/internal/services/wallet/transfer.go @@ -14,7 +14,7 @@ var ( ) func (s *Service) CreateTransfer(ctx context.Context, transfer domain.CreateTransfer) (domain.Transfer, error) { - senderWallet, err := s.walletStore.GetWalletByID(ctx, transfer.SenderWalletID.Value) + senderWallet, err := s.walletStore.GetWalletByID(ctx, transfer.SenderWalletID) receiverWallet, err := s.walletStore.GetWalletByID(ctx, transfer.ReceiverWalletID) if err != nil { return domain.Transfer{}, fmt.Errorf("failed to get sender wallet: %w", err) @@ -39,7 +39,7 @@ func (s *Service) CreateTransfer(ctx context.Context, transfer domain.CreateTran "current_balance": %d, "wallet_id": %d, "notification_type": "customer_facing" - }`, transfer.Amount, senderWallet.Balance, transfer.SenderWalletID.Value)), + }`, transfer.Amount, senderWallet.Balance, transfer.SenderWalletID)), } // Send notification to admin team @@ -53,7 +53,7 @@ func (s *Service) CreateTransfer(ctx context.Context, transfer domain.CreateTran Headline: "CREDIT WARNING: System Running Out of Funds", Message: fmt.Sprintf( "Wallet ID %d has insufficient balance for transfer. Current balance: %.2f, Attempted transfer: %.2f", - transfer.SenderWalletID.Value, + transfer.SenderWalletID, float64(senderWallet.Balance)/100, float64(transfer.Amount)/100, ), @@ -64,7 +64,7 @@ func (s *Service) CreateTransfer(ctx context.Context, transfer domain.CreateTran "balance": %d, "required_amount": %d, "notification_type": "admin_alert" - }`, transfer.SenderWalletID.Value, senderWallet.Balance, transfer.Amount), + }`, transfer.SenderWalletID, senderWallet.Balance, transfer.Amount), } // Send both notifications @@ -100,6 +100,10 @@ func (s *Service) GetAllTransfers(ctx context.Context) ([]domain.Transfer, error return s.transferStore.GetAllTransfers(ctx) } +func (s *Service) GetTransferByReference(ctx context.Context, reference string) (domain.Transfer, error) { + return s.transferStore.GetTransferByReference(ctx, reference) +} + func (s *Service) GetTransferByID(ctx context.Context, id int64) (domain.Transfer, error) { return s.transferStore.GetTransferByID(ctx, id) } @@ -119,13 +123,13 @@ func (s *Service) RefillWallet(ctx context.Context, transfer domain.CreateTransf } // Add to receiver - senderWallet, err := s.GetWalletByID(ctx, transfer.SenderWalletID.Value) + senderWallet, err := s.GetWalletByID(ctx, transfer.SenderWalletID) if err != nil { return domain.Transfer{}, err } else if senderWallet.Balance < transfer.Amount { return domain.Transfer{}, ErrInsufficientBalance } - + err = s.walletStore.UpdateBalance(ctx, receiverWallet.ID, receiverWallet.Balance+transfer.Amount) if err != nil { return domain.Transfer{}, err @@ -188,10 +192,7 @@ func (s *Service) TransferToWallet(ctx context.Context, senderID int64, receiver // Log the transfer so that if there is a mistake, it can be reverted transfer, err := s.transferStore.CreateTransfer(ctx, domain.CreateTransfer{ - SenderWalletID: domain.ValidInt64{ - Value: senderID, - Valid: true, - }, + SenderWalletID: senderID, CashierID: cashierID, ReceiverWalletID: receiverID, Amount: amount, diff --git a/internal/web_server/handlers/chapa.go b/internal/web_server/handlers/chapa.go index 7c03183..16c8675 100644 --- a/internal/web_server/handlers/chapa.go +++ b/internal/web_server/handlers/chapa.go @@ -1,464 +1,163 @@ package handlers import ( - // "bytes" - // "encoding/json" - // "fmt" - // "io" - // "net/http" - "fmt" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/gofiber/fiber/v2" ) -// // 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) -// } - -// VerifyChapaPayment godoc -// @Summary Verifies Chapa webhook transaction +// InitiateDeposit godoc +// @Summary Initiate a deposit +// @Description Starts a new deposit process using Chapa payment gateway // @Tags Chapa // @Accept json // @Produce json -// @Param payload body domain.ChapaTransactionType true "Webhook Payload" -// @Success 200 {object} domain.Response -// @Router /api/v1/chapa/payments/verify [post] -func (h *Handler) VerifyChapaPayment(c *fiber.Ctx) error { - var txType domain.ChapaTransactionType - if err := c.BodyParser(&txType); err != nil { - return domain.UnProcessableEntityResponse(c) - } - - switch txType.Type { - case "Payout": - var payload domain.ChapaWebHookTransfer - if err := c.BodyParser(&payload); err != nil { - return domain.UnProcessableEntityResponse(c) - } - - if err := h.chapaSvc.HandleChapaTransferWebhook(c.Context(), payload); err != nil { - return domain.FiberErrorResponse(c, err) - } - - return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "Chapa transfer verified successfully", - Success: true, - StatusCode: fiber.StatusOK, - }) - - case "API": - var payload domain.ChapaWebHookPayment - if err := c.BodyParser(&payload); err != nil { - return domain.UnProcessableEntityResponse(c) - } - - if err := h.chapaSvc.HandleChapaPaymentWebhook(c.Context(), payload); err != nil { - return domain.FiberErrorResponse(c, err) - } - - return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "Chapa payment verified successfully", - Success: true, - StatusCode: fiber.StatusOK, - }) - - default: - return c.Status(fiber.StatusBadRequest).JSON(domain.Response{ - Message: "Invalid Chapa transaction type", - Success: false, - StatusCode: fiber.StatusBadRequest, +// @Param request body domain.ChapaDepositRequestPayload true "Deposit request" +// @Success 200 {object} domain.ChapaDepositResponse +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/chapa/payments/deposit [post] +func (h *Handler) InitiateDeposit(c *fiber.Ctx) error { + // Get user ID from context (set by your auth middleware) + userID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Error: "invalid user ID", }) } + + var req domain.ChapaDepositRequestPayload + + if err := c.BodyParser(&req); err != nil { + fmt.Sprintln("We first first are here init Chapa payment") + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Error: err.Error(), + }) + } + + amount := domain.Currency(req.Amount * 100) + + fmt.Sprintln("We are here init Chapa payment") + + checkoutURL, err := h.chapaSvc.InitiateDeposit(c.Context(), userID, amount) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Error: err.Error(), + Message: checkoutURL, + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.ChapaDepositResponse{ + CheckoutURL: checkoutURL, + }) } -// WithdrawUsingChapa godoc -// @Summary Withdraw using Chapa -// @Description Initiates a withdrawal transaction using Chapa for the authenticated user. -// @Tags Chapa -// @Accept json -// @Produce json -// @Param request body domain.ChapaWithdrawRequest true "Chapa Withdraw Request" -// @Success 200 {object} domain.Response{data=string} "Withdrawal requested successfully" -// @Failure 400 {object} domain.Response "Invalid request" -// @Failure 401 {object} domain.Response "Unauthorized" -// @Failure 422 {object} domain.Response "Unprocessable Entity" -// @Failure 500 {object} domain.Response "Internal Server Error" -// @Router /api/v1/chapa/payments/withdraw [post] -func (h *Handler) WithdrawUsingChapa(c *fiber.Ctx) error { - var req domain.ChapaWithdrawRequest - if err := c.BodyParser(&req); err != nil { - return domain.UnProcessableEntityResponse(c) +// WebhookCallback godoc +// @Summary Chapa payment webhook callback (used by Chapa) +// @Description Handles payment notifications from Chapa +// @Tags Chapa +// @Accept json +// @Produce json +// @Param request body domain.ChapaWebhookPayload true "Webhook payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/chapa/payments/webhook/verify [post] +func (h *Handler) WebhookCallback(c *fiber.Ctx) error { + // Verify webhook signature first + // signature := c.Get("Chapa-Signature") + // if !verifySignature(signature, c.Body()) { + // return c.Status(fiber.StatusUnauthorized).JSON(ErrorResponse{ + // Error: "invalid signature", + // }) + // } + + var payload struct { + TxRef string `json:"tx_ref"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` } - userID, ok := c.Locals("user_id").(int64) - if !ok || userID == 0 { - return c.Status(fiber.StatusUnauthorized).JSON(domain.Response{ - Message: "Unauthorized", - Success: false, - StatusCode: fiber.StatusUnauthorized, + if err := c.BodyParser(&payload); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Error: err.Error(), }) } - if err := h.chapaSvc.WithdrawUsingChapa(c.Context(), userID, req); err != nil { - return domain.FiberErrorResponse(c, err) + if err := h.chapaSvc.VerifyDeposit(c.Context(), payload.TxRef); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Error: err.Error(), + }) } return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "Withdrawal requested successfully", + StatusCode: 200, + Message: "payment verified successfully", + Data: payload.TxRef, Success: true, - StatusCode: fiber.StatusOK, }) } -// DepositUsingChapa godoc -// @Summary Deposit money into user wallet using Chapa -// @Description Deposits money into user wallet from user account using Chapa -// @Tags Chapa -// @Accept json -// @Produce json -// @Param payload body domain.ChapaDepositRequest true "Deposit request payload" -// @Success 200 {object} domain.ChapaPaymentUrlResponseWrapper -// @Failure 400 {object} domain.Response "Invalid request" -// @Failure 422 {object} domain.Response "Validation error" -// @Failure 500 {object} domain.Response "Internal server error" -// @Router /api/v1/chapa/payments/deposit [post] -func (h *Handler) DepositUsingChapa(c *fiber.Ctx) error { - // Extract user info from token (adjust as per your auth middleware) - userID, ok := c.Locals("user_id").(int64) - if !ok || userID == 0 { - return c.Status(fiber.StatusUnauthorized).JSON(domain.Response{ - Message: "Unauthorized", - Success: false, - StatusCode: fiber.StatusUnauthorized, - }) - } - - var req domain.ChapaDepositRequest - if err := c.BodyParser(&req); err != nil { - return domain.UnProcessableEntityResponse(c) - } - - // Validate input in domain/domain (you may have a Validate method) - if err := req.Validate(); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.Response{ - Message: err.Error(), - Success: false, - StatusCode: fiber.StatusBadRequest, - }) - } - - // Call service to handle the deposit logic and get payment URL - paymentUrl, svcErr := h.chapaSvc.DepositUsingChapa(c.Context(), userID, req) - if svcErr != nil { - return domain.FiberErrorResponse(c, svcErr) - } - - return c.Status(fiber.StatusOK).JSON(domain.ResponseWDataFactory[domain.ChapaPaymentUrlResponse]{ - Data: domain.ChapaPaymentUrlResponse{ - PaymentURL: paymentUrl, - }, - Response: domain.Response{ - Message: "Deposit process started on wallet, fulfill payment using the URL provided", - Success: true, - StatusCode: fiber.StatusOK, - }, - }) -} - -// ReadChapaBanks godoc -// @Summary fetches chapa supported banks +// VerifyPayment godoc +// @Summary Verify a payment manually +// @Description Manually verify a payment using Chapa's API // @Tags Chapa // @Accept json // @Produce json -// @Success 200 {object} domain.ChapaSupportedBanksResponseWrapper -// @Failure 400,401,404,422,500 {object} domain.Response -// @Router /api/v1/chapa/banks [get] -func (h *Handler) ReadChapaBanks(c *fiber.Ctx) error { - banks, err := h.chapaSvc.GetSupportedBanks() - fmt.Printf("\n\nhandler fetched banks: %+v\n\n", banks) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.Response{ - Message: "Internal server error", - Success: false, - StatusCode: fiber.StatusInternalServerError, +// @Param tx_ref path string true "Transaction Reference" +// @Success 200 {object} domain.ChapaVerificationResponse +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/chapa/payments/manual/verify/{tx_ref} [get] +func (h *Handler) ManualVerifyPayment(c *fiber.Ctx) error { + txRef := c.Params("tx_ref") + if txRef == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to verify Chapa transaction", + Error: "Transaction reference is required", }) } - return c.Status(fiber.StatusOK).JSON(domain.ResponseWDataFactory[[]domain.ChapaSupportedBank]{ - Data: banks, - Response: domain.Response{ - Message: "read successful on chapa supported banks", - Success: true, - StatusCode: fiber.StatusOK, - }, + verification, err := h.chapaSvc.ManualVerifyPayment(c.Context(), txRef) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to verify Chapa transaction", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.ChapaVerificationResponse{ + Status: string(verification.Status), + Amount: verification.Amount, + Currency: verification.Currency, + TxRef: txRef, + }) +} + +// GetSupportedBanks godoc +// @Summary Get supported banks +// @Description Get list of banks supported by Chapa +// @Tags Chapa +// @Accept json +// @Produce json +// @Success 200 {array} domain.Bank +// @Failure 500 {object} domain.ErrorResponse +// @Router /banks [get] +func (h *Handler) GetSupportedBanks(c *fiber.Ctx) error { + banks, err := h.chapaSvc.GetSupportedBanks(c.Context()) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Error: err.Error(), + Message: "Failed to fetch banks", + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Banks fetched successfully", + StatusCode: 200, + Success: true, + Data: banks, }) } diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 7969167..22e1b82 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -33,7 +33,7 @@ type Handler struct { userSvc *user.Service referralSvc referralservice.ReferralStore reportSvc report.ReportStore - chapaSvc chapa.ChapaPort + chapaSvc *chapa.Service walletSvc *wallet.Service transactionSvc *transaction.Service ticketSvc *ticket.Service @@ -58,7 +58,7 @@ func New( notificationSvc *notificationservice.Service, validator *customvalidator.CustomValidator, reportSvc report.ReportStore, - chapaSvc chapa.ChapaPort, + chapaSvc *chapa.Service, walletSvc *wallet.Service, referralSvc referralservice.ReferralStore, virtualGameSvc virtualgameservice.VirtualGameService, diff --git a/internal/web_server/handlers/read_chapa_banks_handler_test.go b/internal/web_server/handlers/read_chapa_banks_handler_test.go deleted file mode 100644 index 73e785c..0000000 --- a/internal/web_server/handlers/read_chapa_banks_handler_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package handlers - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "net/http" - "testing" - "time" - - "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" - "github.com/gofiber/fiber/v2" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" -) - -// --- Mock service --- - -type MockChapaService struct { - mock.Mock -} - -func (m *MockChapaService) GetSupportedBanks() ([]domain.ChapaSupportedBank, error) { - args := m.Called() - return args.Get(0).([]domain.ChapaSupportedBank), args.Error(1) -} - -// --- Tests --- - -func (h *Handler) TestReadChapaBanks_Success(t *testing.T) { - app := fiber.New() - - mockService := new(MockChapaService) - - now := time.Now() - isMobile := 1 - isRtgs := 1 - is24hrs := 1 - - mockBanks := []domain.ChapaSupportedBank{ - { - Id: 101, - Slug: "bank-a", - Swift: "BKAETHAA", - Name: "Bank A", - AcctLength: 13, - AcctNumberRegex: "^[0-9]{13}$", - ExampleValue: "1000222215735", - CountryId: 1, - IsMobilemoney: &isMobile, - IsActive: 1, - IsRtgs: &isRtgs, - Active: 1, - Is24Hrs: &is24hrs, - CreatedAt: now, - UpdatedAt: now, - Currency: "ETB", - }, - } - - mockService.On("GetSupportedBanks").Return(mockBanks, nil) - - // handler := handlers.NewChapaHandler(mockService) - app.Post("/chapa/banks", h.ReadChapaBanks) - - req := createTestRequest(t, "POST", "/chapa/banks", nil) - resp, err := app.Test(req) - require.NoError(t, err) - - assert.Equal(t, fiber.StatusOK, resp.StatusCode) - - var body domain.ResponseWDataFactory[[]domain.ChapaSupportedBank] - err = parseJSONBody(resp, &body) - require.NoError(t, err) - - assert.True(t, body.Success) - assert.Equal(t, "read successful on chapa supported banks", body.Message) - require.Len(t, body.Data, 1) - assert.Equal(t, mockBanks[0].Name, body.Data[0].Name) - assert.Equal(t, mockBanks[0].AcctNumberRegex, body.Data[0].AcctNumberRegex) - - mockService.AssertExpectations(t) -} - -func (h *Handler) TestReadChapaBanks_Failure(t *testing.T) { - app := fiber.New() - - mockService := new(MockChapaService) - mockService.On("GetSupportedBanks").Return(nil, errors.New("chapa service unavailable")) - - // handler := handlers.NewChapaHandler(mockService) - app.Post("/chapa/banks", h.ReadChapaBanks) - - req := createTestRequest(t, "POST", "/chapa/banks", nil) - resp, err := app.Test(req) - require.NoError(t, err) - - assert.Equal(t, fiber.StatusInternalServerError, resp.StatusCode) - - var body domain.Response - err = parseJSONBody(resp, &body) - require.NoError(t, err) - - assert.False(t, body.Success) - assert.Equal(t, "Internal server error", body.Message) - mockService.AssertExpectations(t) -} - -func createTestRequest(t *testing.T, method, url string, body interface{}) *http.Request { - var buf io.Reader - if body != nil { - b, err := json.Marshal(body) - if err != nil { - t.Fatal(err) - } - buf = bytes.NewBuffer(b) - } - - req, err := http.NewRequest(method, url, buf) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Content-Type", "application/json") - return req -} - -func parseJSONBody(resp *http.Response, target interface{}) error { - return json.NewDecoder(resp.Body).Decode(target) -} diff --git a/internal/web_server/handlers/transfer_handler.go b/internal/web_server/handlers/transfer_handler.go index df8e206..b272a39 100644 --- a/internal/web_server/handlers/transfer_handler.go +++ b/internal/web_server/handlers/transfer_handler.go @@ -36,9 +36,7 @@ type RefillRes struct { func convertTransfer(transfer domain.Transfer) TransferWalletRes { var senderWalletID *int64 - if transfer.SenderWalletID.Valid { - senderWalletID = &transfer.SenderWalletID.Value - } + senderWalletID = &transfer.SenderWalletID var cashierID *int64 if transfer.CashierID.Valid { diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 57d2184..29f725f 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -7,6 +7,7 @@ import ( _ "github.com/SamuelTariku/FortuneBet-Backend/docs" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + // "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger" // "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet/monitor" @@ -192,13 +193,13 @@ func (a *App) initAppRoutes() { a.fiber.Post("/transfer/refill/:id", a.authMiddleware, h.RefillWallet) //Chapa Routes - group.Post("/chapa/payments/verify", a.authMiddleware, h.VerifyChapaPayment) - group.Post("/chapa/payments/withdraw", a.authMiddleware, h.WithdrawUsingChapa) - group.Post("/chapa/payments/deposit", a.authMiddleware, h.DepositUsingChapa) - group.Get("/chapa/banks", a.authMiddleware, h.ReadChapaBanks) + group.Post("/chapa/payments/webhook/verify", h.WebhookCallback) + group.Get("/chapa/payments/manual/verify/:tx_ref", h.ManualVerifyPayment) + group.Post("/chapa/payments/deposit", a.authMiddleware, h.InitiateDeposit) + group.Get("/chapa/banks", h.GetSupportedBanks) //Report Routes - group.Get("/reports/dashboard", a.authMiddleware, h.GetDashboardReport) + group.Get("/reports/dashboard", h.GetDashboardReport) //Wallet Monitor Service // group.Get("/debug/wallet-monitor/status", func(c *fiber.Ctx) error { @@ -230,7 +231,7 @@ func (a *App) initAppRoutes() { //mongoDB logs ctx := context.Background() - group.Get("/logs", handlers.GetLogsHandler(ctx)) + group.Get("/logs", a.authMiddleware, a.SuperAdminOnly, handlers.GetLogsHandler(ctx)) // Recommendation Routes group.Get("/virtual-games/recommendations/:userID", h.GetRecommendations) @@ -247,7 +248,7 @@ func (a *App) initAppRoutes() { a.fiber.Get("/notifications/all", a.authMiddleware, h.GetAllNotifications) a.fiber.Post("/notifications/mark-as-read", a.authMiddleware, h.MarkNotificationAsRead) a.fiber.Get("/notifications/unread", a.authMiddleware, h.CountUnreadNotifications) - a.fiber.Post("/notifications/create", h.CreateAndSendNotification) + a.fiber.Post("/notifications/create", a.authMiddleware, h.CreateAndSendNotification) // Virtual Game Routes a.fiber.Post("/virtual-game/launch", a.authMiddleware, h.LaunchVirtualGame)