Chapa webhook + popok fix

This commit is contained in:
Yared Yemane 2025-06-12 09:40:11 +03:00
parent 8f2713b920
commit 6d5bdd8a56
47 changed files with 2108 additions and 2212 deletions

View File

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

View File

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

View File

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

View File

@ -69,3 +69,10 @@ LIMIT $1;
SELECT recipient_id
FROM notifications
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;

View File

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

View File

@ -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"
}
}
}
]
"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",

View File

@ -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"
}
}
}
]
"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",

View File

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

View File

@ -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"`

View File

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

View File

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

7
go.mod
View File

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

4
go.sum
View File

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

View File

@ -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"`
type ChapaWebhookPayload struct {
TxRef string `json:"tx_ref"`
Amount Currency `json:"amount"`
Currency string `json:"currency"`
Status PaymentStatus `json:"status"`
}
// PaymentResponse contains the response from payment initialization
type ChapaDepositResponse struct {
CheckoutURL string
Reference string
}
// PaymentVerification contains payment verification details
type ChapaDepositVerification struct {
Status PaymentStatus
Amount Currency
Currency string
}
type ChapaVerificationResponse struct {
Status string `json:"status"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
TxRef string `json:"tx_ref"`
}
type Bank struct {
ID int `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"`
CountryID int `json:"country_id"`
IsMobileMoney int `json:"is_mobilemoney"` // nullable
IsActive int `json:"is_active"`
IsRtgs *int `json:"is_rtgs"`
IsRTGS int `json:"is_rtgs"`
Active int `json:"active"`
Is24Hrs *int `json:"is_24hrs"`
Is24Hrs int `json:"is_24hrs"` // nullable
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Currency string `json:"currency"`
}
type ChapaSupportedBanksResponse struct {
type BankResponse struct {
Message string `json:"message"`
Data []ChapaSupportedBank `json:"data"`
}
type InitPaymentData struct {
TxRef string `json:"tx_ref"`
CheckoutURL string `json:"checkout_url"`
}
type InitPaymentResponse struct {
Status string `json:"status"` // "success"
Message string `json:"message"` // e.g., "Payment initialized"
Data InitPaymentData `json:"data"`
}
type WebhookPayload map[string]interface{}
type TransactionData struct {
TxRef string `json:"tx_ref"`
Status string `json:"status"`
Amount string `json:"amount"`
Currency string `json:"currency"`
CustomerEmail string `json:"email"`
Data []BankData `json:"data"`
}
type VerifyTransactionResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data TransactionData `json:"data"`
}
type TransferData struct {
Reference string `json:"reference"`
Status string `json:"status"`
Amount string `json:"amount"`
Currency string `json:"currency"`
}
type CreateTransferResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data TransferData `json:"data"`
}
type TransferVerificationData struct {
Reference string `json:"reference"`
Status string `json:"status"`
BankCode string `json:"bank_code"`
AccountName string `json:"account_name"`
}
type VerifyTransferResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data TransferVerificationData `json:"data"`
}
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"`
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"`
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
}

View File

@ -3,8 +3,12 @@ package domain
import (
"fmt"
"time"
"go.uber.org/zap"
)
var MongoDBLogger *zap.Logger
type ValidInt64 struct {
Value int64
Valid bool

View File

@ -29,6 +29,8 @@ const (
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 {

View File

@ -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
@ -13,6 +124,7 @@ type ReportFilter struct {
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"`
@ -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"`
}

View File

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

View File

@ -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"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
transferStore wallet.TransferStore
walletStore wallet.WalletStore
userStore user.UserStore
referralStore referralservice.ReferralStore
branchStore branch.BranchStore
chapaClient ChapaClient
config *config.Config
// logger *slog.Logger
store *repository.Store
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,
transferStore: transferStore,
walletStore: walletStore,
userStore: userStore,
referralStore: referralStore,
branchStore: branchStore,
chapaClient: chapaClient,
store: store,
}
}
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 dont 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)
}
return 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)
}
if txn.Verified {
for _, wallet := range senderWallets {
if wallet.IsWithdraw {
senderWallet = wallet
break
}
}
// 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")
}
txn.Verified = true
if err := s.transactionStore.UpdateTransactionVerified(ctx, txn.ID, txn.Verified, txn.ApprovedBy.Value, txn.ApproverName.Value); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.ChapaWebHookPayment) error {
_, tx, err := s.store.BeginTx(ctx)
// Verify payment with Chapa
verification, err := s.chapaClient.VerifyPayment(ctx, reference)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if req.Status != "success" {
return fmt.Errorf("payment status not successful")
return fmt.Errorf("failed to verify payment: %w", err)
}
// 1. Parse reference ID
referenceID, err := strconv.ParseInt(req.TxRef, 10, 64)
// Update payment status
if err := s.transferStore.UpdateTransferVerification(ctx, payment.ID, true); err != nil {
return fmt.Errorf("failed to update payment status: %w", err)
}
// 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) 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
}
// 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)
// Update our records if payment is successful
if verification.Status == domain.PaymentStatusCompleted {
err = s.transferStore.UpdateTransferVerification(ctx, transfer.ID, true)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("transaction with ID %d not found", referenceID)
}
return err
return nil, fmt.Errorf("failed to update verification status: %w", 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)
// Credit user's wallet
err = s.walletStore.UpdateBalance(ctx, transfer.SenderWalletID, transfer.Amount)
if err != nil {
return err
return nil, fmt.Errorf("failed to update wallet balance: %w", err)
}
}
// 5. Update wallet balance
newBalance := wallet.Balance + txn.Amount
if err := s.walletStore.UpdateBalance(ctx, wallet.ID, newBalance); err != nil {
return err
return &domain.ChapaVerificationResponse{
Status: string(verification.Status),
Amount: float64(verification.Amount),
Currency: verification.Currency,
}, nil
}
// 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))
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)
}
if stats == nil {
if err := s.referralStore.CreateReferral(ctx, wallet.UserID); err != nil {
return err
}
}
return tx.Commit(ctx)
}
func (s *Service) WithdrawUsingChapa(ctx context.Context, userID int64, req domain.ChapaWithdrawRequest) error {
_, tx, err := s.store.BeginTx(ctx)
if err != nil {
return 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)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
)
@ -26,6 +29,9 @@ type Service struct {
transactionStore transaction.TransactionStore
branchStore branch.BranchStore
userStore user.UserStore
companyStore company.CompanyStore
virtulaGamesStore repository.VirtualGameRepository
notificationStore repository.NotificationRepository
logger *slog.Logger
}
@ -35,6 +41,9 @@ 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{
@ -43,36 +52,20 @@ func NewService(
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

View File

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

View File

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

View File

@ -22,6 +22,7 @@ type service struct {
repo repository.VirtualGameRepository
walletSvc wallet.Service
store *repository.Store
// virtualGameStore repository.VirtualGameRepository
config *config.Config
logger *slog.Logger
}
@ -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)
}

View File

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

View File

@ -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,7 +123,7 @@ 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 {
@ -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,

View File

@ -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,
})
}
}
// 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)
}
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 := h.chapaSvc.WithdrawUsingChapa(c.Context(), userID, req); err != nil {
return domain.FiberErrorResponse(c, err)
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Withdrawal requested successfully",
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"
// @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) DepositUsingChapa(c *fiber.Ctx) error {
// Extract user info from token (adjust as per your auth middleware)
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 || userID == 0 {
return c.Status(fiber.StatusUnauthorized).JSON(domain.Response{
Message: "Unauthorized",
Success: false,
StatusCode: fiber.StatusUnauthorized,
if !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: "invalid user ID",
})
}
var req domain.ChapaDepositRequest
var req domain.ChapaDepositRequestPayload
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,
fmt.Sprintln("We first first are here init Chapa payment")
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
})
}
// 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)
}
amount := domain.Currency(req.Amount * 100)
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,
},
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,
})
}
// ReadChapaBanks godoc
// @Summary fetches chapa supported banks
return c.Status(fiber.StatusOK).JSON(domain.ChapaDepositResponse{
CheckoutURL: checkoutURL,
})
}
// WebhookCallback godoc
// @Summary Chapa payment webhook callback (used by Chapa)
// @Description Handles payment notifications from Chapa
// @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 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"`
}
if err := c.BodyParser(&payload); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
})
}
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,
},
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{
StatusCode: 200,
Message: "payment verified successfully",
Data: payload.TxRef,
Success: true,
})
}
// VerifyPayment godoc
// @Summary Verify a payment manually
// @Description Manually verify a payment using Chapa's API
// @Tags Chapa
// @Accept json
// @Produce json
// @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",
})
}
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,
})
}

View File

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

View File

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

View File

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

View File

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