Merge branch 'main' into ticket-bet

This commit is contained in:
Samuel Tariku 2025-06-12 14:10:13 +03:00
commit 35a03e1959
59 changed files with 5263 additions and 1314 deletions

View File

@ -3,17 +3,25 @@ package main
import ( import (
// "context" // "context"
// "context"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"time"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"go.uber.org/zap"
// "github.com/gofiber/fiber/v2" // "github.com/gofiber/fiber/v2"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/config"
customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger"
mockemail "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_email" "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger"
mocksms "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_sms"
// mongologger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger"
// "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger"
"github.com/SamuelTariku/FortuneBet-Backend/internal/repository" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository"
// "github.com/SamuelTariku/FortuneBet-Backend/internal/router" // "github.com/SamuelTariku/FortuneBet-Backend/internal/router"
@ -28,6 +36,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation"
referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/report"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/result" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/result"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction"
@ -36,6 +45,7 @@ import (
alea "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame/Alea" alea "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame/Alea"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame/veli" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame/veli"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet/monitor"
// "github.com/SamuelTariku/FortuneBet-Backend/internal/utils" // "github.com/SamuelTariku/FortuneBet-Backend/internal/utils"
httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server" httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server"
@ -71,31 +81,71 @@ func main() {
} }
logger := customlogger.NewLogger(cfg.Env, cfg.LogLevel) logger := customlogger.NewLogger(cfg.Env, cfg.LogLevel)
mongoLogger.Init()
mongoDBLogger := zap.L()
// client := mongoLogger.InitDB()
// defer func() {
// if err := client.Disconnect(context.Background()); err != nil {
// slog.Error("Failed to disconnect MongoDB", "error", err)
// }
// }()
// // 2. Create MongoDB logger handler
// handler, err := mongoLogger.NewMongoHandler("logs", "app_logs", slog.LevelDebug)
// if err != nil {
// slog.Error("Failed to create MongoDB logger", "error", err)
// os.Exit(1)
// }
// // 3. Set as default logger
// tempLogger := slog.New(handler)
// slog.SetDefault(tempLogger)
// // 4. Log examples
// tempLogger.Info("Application started", "version", "1.0.0")
// slog.Warn("Low disk space", "available_gb", 12.5)
// slog.Error("Payment failed", "transaction_id", "tx123", "error", "insufficient funds")
store := repository.NewStore(db) store := repository.NewStore(db)
v := customvalidator.NewCustomValidator(validator.New()) v := customvalidator.NewCustomValidator(validator.New())
authSvc := authentication.NewService(store, store, cfg.RefreshExpiry) authSvc := authentication.NewService(store, store, cfg.RefreshExpiry)
mockSms := mocksms.NewMockSMS()
mockEmail := mockemail.NewMockEmail()
userSvc := user.NewService(store, store, mockSms, mockEmail) userSvc := user.NewService(store, store, cfg)
eventSvc := event.New(cfg.Bet365Token, store) eventSvc := event.New(cfg.Bet365Token, store)
oddsSvc := odds.New(store, cfg, logger) oddsSvc := odds.New(store, cfg, logger)
ticketSvc := ticket.NewService(store) ticketSvc := ticket.NewService(store)
walletSvc := wallet.NewService(store, store) notificationRepo := repository.NewNotificationRepository(store)
notificationSvc := notificationservice.New(notificationRepo, logger, cfg)
// var betStore bet.BetStore
// var walletStore wallet.WalletStore
// var transactionStore transaction.TransactionStore
// var branchStore branch.BranchStore
// var userStore user.UserStore
var notificationStore notificationservice.NotificationStore
walletSvc := wallet.NewService(
wallet.WalletStore(store),
wallet.TransferStore(store),
notificationStore,
logger,
)
transactionSvc := transaction.NewService(store) transactionSvc := transaction.NewService(store)
branchSvc := branch.NewService(store) branchSvc := branch.NewService(store)
companySvc := company.NewService(store) companySvc := company.NewService(store)
leagueSvc := league.New(store) leagueSvc := league.New(store)
betSvc := bet.NewService(store, eventSvc, *oddsSvc, *walletSvc, *branchSvc, logger) betSvc := bet.NewService(store, eventSvc, *oddsSvc, *walletSvc, *branchSvc, logger, mongoDBLogger)
resultSvc := result.NewService(store, cfg, logger, *betSvc, *oddsSvc, eventSvc, leagueSvc) resultSvc := result.NewService(store, cfg, logger, *betSvc, *oddsSvc, eventSvc, leagueSvc)
notificationRepo := repository.NewNotificationRepository(store)
referalRepo := repository.NewReferralRepository(store) referalRepo := repository.NewReferralRepository(store)
vitualGameRepo := repository.NewVirtualGameRepository(store) vitualGameRepo := repository.NewVirtualGameRepository(store)
recommendationRepo := repository.NewRecommendationRepository(store) recommendationRepo := repository.NewRecommendationRepository(store)
notificationSvc := notificationservice.New(notificationRepo, logger, cfg)
referalSvc := referralservice.New(referalRepo, *walletSvc, store, cfg, logger) referalSvc := referralservice.New(referalRepo, *walletSvc, store, cfg, logger)
virtualGameSvc := virtualgameservice.New(vitualGameRepo, *walletSvc, store, cfg, logger) virtualGameSvc := virtualgameservice.New(vitualGameRepo, *walletSvc, store, cfg, logger)
aleaService := alea.NewAleaPlayService( aleaService := alea.NewAleaPlayService(
@ -123,6 +173,33 @@ func main() {
store, store,
) )
reportSvc := report.NewService(
bet.BetStore(store), // Must implement BetStore
wallet.WalletStore(store), // Must implement WalletStore
transaction.TransactionStore(store),
branch.BranchStore(store),
user.UserStore(store),
logger,
)
// reportSvc := report.NewService(
// betStore,
// walletStore,
// transactionStore,
// branchStore,
// userStore,
// logger,
// )
walletMonitorSvc := monitor.NewService(
*walletSvc,
*branchSvc,
notificationSvc,
logger,
5*time.Minute,
)
walletMonitorSvc.Start()
httpserver.StartDataFetchingCrons(eventSvc, *oddsSvc, resultSvc) httpserver.StartDataFetchingCrons(eventSvc, *oddsSvc, resultSvc)
httpserver.StartTicketCrons(*ticketSvc) httpserver.StartTicketCrons(*ticketSvc)
@ -130,7 +207,7 @@ func main() {
JwtAccessKey: cfg.JwtKey, JwtAccessKey: cfg.JwtKey,
JwtAccessExpiry: cfg.AccessExpiry, JwtAccessExpiry: cfg.AccessExpiry,
}, userSvc, }, userSvc,
ticketSvc, betSvc, chapaSvc, walletSvc, transactionSvc, branchSvc, companySvc, notificationSvc, oddsSvc, eventSvc, leagueSvc, referalSvc, virtualGameSvc, aleaService, veliService, recommendationSvc, resultSvc, cfg) ticketSvc, betSvc, reportSvc, chapaSvc, walletSvc, transactionSvc, branchSvc, companySvc, notificationSvc, oddsSvc, eventSvc, leagueSvc, referalSvc, virtualGameSvc, aleaService, veliService, recommendationSvc, resultSvc, cfg)
logger.Info("Starting server", "port", cfg.Port) logger.Info("Starting server", "port", cfg.Port)
if err := app.Run(); err != nil { if err := app.Run(); err != nil {

View File

@ -137,25 +137,25 @@ CREATE TABLE IF NOT EXISTS transactions (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
amount BIGINT NOT NULL, amount BIGINT NOT NULL,
branch_id BIGINT NOT NULL, branch_id BIGINT NOT NULL,
company_id BIGINT NOT NULL, company_id BIGINT,
cashier_id BIGINT NOT NULL, cashier_id BIGINT,
cashier_name VARCHAR(255) NOT NULL, cashier_name VARCHAR(255),
bet_id BIGINT NOT NULL, bet_id BIGINT,
number_of_outcomes BIGINT NOT NULL, number_of_outcomes BIGINT,
type BIGINT NOT NULL, type BIGINT,
payment_option BIGINT NOT NULL, payment_option BIGINT,
full_name VARCHAR(255) NOT NULL, full_name VARCHAR(255),
phone_number VARCHAR(255) NOT NULL, phone_number VARCHAR(255),
bank_code VARCHAR(255) NOT NULL, bank_code VARCHAR(255),
beneficiary_name VARCHAR(255) NOT NULL, beneficiary_name VARCHAR(255),
account_name VARCHAR(255) NOT NULL, account_name VARCHAR(255),
account_number VARCHAR(255) NOT NULL, account_number VARCHAR(255),
reference_number VARCHAR(255) NOT NULL, reference_number VARCHAR(255),
verified BOOLEAN NOT NULL DEFAULT false, verified BOOLEAN NOT NULL DEFAULT false,
approved_by BIGINT, approved_by BIGINT,
approver_name VARCHAR(255), approver_name VARCHAR(255),
branch_location VARCHAR(255) NOT NULL, branch_location VARCHAR(255),
branch_name VARCHAR(255) NOT NULL, branch_name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
@ -163,6 +163,7 @@ CREATE TABLE IF NOT EXISTS branches (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
location VARCHAR(255) NOT NULL, location VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT false,
wallet_id BIGINT NOT NULL, wallet_id BIGINT NOT NULL,
branch_manager_id BIGINT NOT NULL, branch_manager_id BIGINT NOT NULL,
company_id BIGINT NOT NULL, company_id BIGINT NOT NULL,
@ -261,6 +262,7 @@ FROM companies
JOIN wallets ON wallets.id = companies.wallet_id JOIN wallets ON wallets.id = companies.wallet_id
JOIN users ON users.id = companies.admin_id; JOIN users ON users.id = companies.admin_id;
; ;
CREATE VIEW branch_details AS CREATE VIEW branch_details AS
SELECT branches.*, SELECT branches.*,
CONCAT(users.first_name, ' ', users.last_name) AS manager_name, CONCAT(users.first_name, ' ', users.last_name) AS manager_name,
@ -287,6 +289,10 @@ FROM tickets
LEFT JOIN ticket_outcomes ON tickets.id = ticket_outcomes.ticket_id LEFT JOIN ticket_outcomes ON tickets.id = ticket_outcomes.ticket_id
GROUP BY tickets.id; GROUP BY tickets.id;
-- Foreign Keys -- Foreign Keys
ALTER TABLE users
ADD CONSTRAINT unique_email UNIQUE (email),
ADD CONSTRAINT unique_phone_number UNIQUE (phone_number);
ALTER TABLE refresh_tokens ALTER TABLE refresh_tokens
ADD CONSTRAINT fk_refresh_tokens_users FOREIGN KEY (user_id) REFERENCES users(id); ADD CONSTRAINT fk_refresh_tokens_users FOREIGN KEY (user_id) REFERENCES users(id);
ALTER TABLE bets ALTER TABLE bets

View File

@ -1 +1,2 @@
DROP TABLE notifications; DROP TABLE notifications;
DROP TABLE wallet_threshold_notifications;

View File

@ -30,8 +30,18 @@ CREATE TABLE IF NOT EXISTS notifications (
metadata JSONB metadata JSONB
); );
CREATE TABLE IF NOT EXISTS wallet_threshold_notifications (
company_id BIGINT NOT NULL,
threshold FLOAT NOT NULL,
notified_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (company_id, threshold)
);
CREATE INDEX idx_wallet_threshold_notifications_company ON wallet_threshold_notifications(company_id);
CREATE INDEX idx_notifications_recipient_id ON notifications (recipient_id); CREATE INDEX idx_notifications_recipient_id ON notifications (recipient_id);
CREATE INDEX idx_notifications_timestamp ON notifications (timestamp); CREATE INDEX idx_notifications_timestamp ON notifications (timestamp);
CREATE INDEX idx_notifications_type ON notifications (type); CREATE INDEX idx_notifications_type ON notifications (type);

24
db/query/monitor.sql Normal file
View File

@ -0,0 +1,24 @@
-- name: GetAllCompaniesBranch :many
SELECT id, name, wallet_id, admin_id
FROM companies;
-- name: GetBranchesByCompanyID :many
SELECT
id,
name,
location,
wallet_id,
branch_manager_id,
company_id,
is_self_owned
FROM branches
WHERE company_id = $1;
-- name: CountThresholdNotifications :one
SELECT COUNT(*)
FROM wallet_threshold_notifications
WHERE company_id = $1 AND threshold = $2;
-- name: CreateThresholdNotification :exec
INSERT INTO wallet_threshold_notifications (company_id, threshold)
VALUES ($1, $2);

View File

@ -14,18 +14,8 @@ INSERT INTO users (
company_id company_id
) )
VALUES ( VALUES (
$1, $1, $2, $3, $4, $5, $6,
$2, $7, $8, $9, $10, $11, $12
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10,
$11,
$12
) )
RETURNING id, RETURNING id,
first_name, first_name,
@ -39,6 +29,7 @@ RETURNING id,
updated_at, updated_at,
suspended, suspended,
company_id; company_id;
-- name: GetUserByID :one -- name: GetUserByID :one
SELECT * SELECT *
FROM users FROM users

View File

@ -17,6 +17,25 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
mongo:
container_name: fortunebet-mongo
image: mongo:7.0
restart: always
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: secret
volumes:
- mongo_data:/data/db
networks:
- app
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
migrate: migrate:
image: migrate/migrate image: migrate/migrate
volumes: volumes:
@ -44,14 +63,16 @@ services:
- ${PORT}:8080 - ${PORT}:8080
environment: environment:
- DB_URL=postgresql://root:secret@postgres:5432/gh?sslmode=disable - DB_URL=postgresql://root:secret@postgres:5432/gh?sslmode=disable
- MONGO_URI=mongodb://root:secret@mongo:27017
depends_on: depends_on:
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
mongo:
condition: service_healthy
networks: networks:
- app - app
command: ["/app/bin/web"] command: ["/app/bin/web"]
test: test:
build: build:
context: . context: .
@ -69,3 +90,4 @@ networks:
volumes: volumes:
postgres_data: postgres_data:
mongo_data:

View File

@ -511,6 +511,96 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/reports/dashboard": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Returns a comprehensive dashboard report with key metrics",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Reports"
],
"summary": "Get dashboard report",
"parameters": [
{
"type": "integer",
"description": "Company ID filter",
"name": "company_id",
"in": "query"
},
{
"type": "integer",
"description": "Branch ID filter",
"name": "branch_id",
"in": "query"
},
{
"type": "integer",
"description": "User ID filter",
"name": "user_id",
"in": "query"
},
{
"type": "string",
"description": "Start time filter (RFC3339 format)",
"name": "start_time",
"in": "query"
},
{
"type": "string",
"description": "End time filter (RFC3339 format)",
"name": "end_time",
"in": "query"
},
{
"type": "string",
"description": "Sport ID filter",
"name": "sport_id",
"in": "query"
},
{
"type": "integer",
"description": "Status filter (0=Pending, 1=Win, 2=Loss, 3=Half, 4=Void, 5=Error)",
"name": "status",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/report.DashboardSummary"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/virtual-games/recommendations/{userID}": { "/api/v1/virtual-games/recommendations/{userID}": {
"get": { "get": {
"description": "Returns a list of recommended virtual games for a specific user", "description": "Returns a list of recommended virtual games for a specific user",
@ -4676,6 +4766,17 @@ const docTemplate = `{
} }
} }
}, },
"domain.ErrorResponse": {
"type": "object",
"properties": {
"error": {
"type": "string"
},
"message": {
"type": "string"
}
}
},
"domain.Odd": { "domain.Odd": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -6332,6 +6433,56 @@ const docTemplate = `{
} }
} }
}, },
"report.DashboardSummary": {
"type": "object",
"properties": {
"active_bets": {
"type": "integer"
},
"active_branches": {
"type": "integer"
},
"active_customers": {
"type": "integer"
},
"average_stake": {
"type": "integer"
},
"branches_count": {
"type": "integer"
},
"customer_count": {
"type": "integer"
},
"profit": {
"type": "integer"
},
"total_bets": {
"type": "integer"
},
"total_deposits": {
"type": "integer"
},
"total_losses": {
"type": "integer"
},
"total_stakes": {
"type": "integer"
},
"total_wins": {
"type": "integer"
},
"total_withdrawals": {
"type": "integer"
},
"win_balance": {
"type": "integer"
},
"win_rate": {
"type": "number"
}
}
},
"response.APIResponse": { "response.APIResponse": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -503,6 +503,96 @@
} }
} }
}, },
"/api/v1/reports/dashboard": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Returns a comprehensive dashboard report with key metrics",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Reports"
],
"summary": "Get dashboard report",
"parameters": [
{
"type": "integer",
"description": "Company ID filter",
"name": "company_id",
"in": "query"
},
{
"type": "integer",
"description": "Branch ID filter",
"name": "branch_id",
"in": "query"
},
{
"type": "integer",
"description": "User ID filter",
"name": "user_id",
"in": "query"
},
{
"type": "string",
"description": "Start time filter (RFC3339 format)",
"name": "start_time",
"in": "query"
},
{
"type": "string",
"description": "End time filter (RFC3339 format)",
"name": "end_time",
"in": "query"
},
{
"type": "string",
"description": "Sport ID filter",
"name": "sport_id",
"in": "query"
},
{
"type": "integer",
"description": "Status filter (0=Pending, 1=Win, 2=Loss, 3=Half, 4=Void, 5=Error)",
"name": "status",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/report.DashboardSummary"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/virtual-games/recommendations/{userID}": { "/api/v1/virtual-games/recommendations/{userID}": {
"get": { "get": {
"description": "Returns a list of recommended virtual games for a specific user", "description": "Returns a list of recommended virtual games for a specific user",
@ -4668,6 +4758,17 @@
} }
} }
}, },
"domain.ErrorResponse": {
"type": "object",
"properties": {
"error": {
"type": "string"
},
"message": {
"type": "string"
}
}
},
"domain.Odd": { "domain.Odd": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -6324,6 +6425,56 @@
} }
} }
}, },
"report.DashboardSummary": {
"type": "object",
"properties": {
"active_bets": {
"type": "integer"
},
"active_branches": {
"type": "integer"
},
"active_customers": {
"type": "integer"
},
"average_stake": {
"type": "integer"
},
"branches_count": {
"type": "integer"
},
"customer_count": {
"type": "integer"
},
"profit": {
"type": "integer"
},
"total_bets": {
"type": "integer"
},
"total_deposits": {
"type": "integer"
},
"total_losses": {
"type": "integer"
},
"total_stakes": {
"type": "integer"
},
"total_wins": {
"type": "integer"
},
"total_withdrawals": {
"type": "integer"
},
"win_balance": {
"type": "integer"
},
"win_rate": {
"type": "number"
}
}
},
"response.APIResponse": { "response.APIResponse": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -255,6 +255,13 @@ definitions:
- $ref: '#/definitions/domain.OutcomeStatus' - $ref: '#/definitions/domain.OutcomeStatus'
example: 1 example: 1
type: object type: object
domain.ErrorResponse:
properties:
error:
type: string
message:
type: string
type: object
domain.Odd: domain.Odd:
properties: properties:
category: category:
@ -1415,6 +1422,39 @@ definitions:
example: false example: false
type: boolean type: boolean
type: object type: object
report.DashboardSummary:
properties:
active_bets:
type: integer
active_branches:
type: integer
active_customers:
type: integer
average_stake:
type: integer
branches_count:
type: integer
customer_count:
type: integer
profit:
type: integer
total_bets:
type: integer
total_deposits:
type: integer
total_losses:
type: integer
total_stakes:
type: integer
total_wins:
type: integer
total_withdrawals:
type: integer
win_balance:
type: integer
win_rate:
type: number
type: object
response.APIResponse: response.APIResponse:
properties: properties:
data: {} data: {}
@ -1766,6 +1806,64 @@ paths:
summary: Withdraw using Chapa summary: Withdraw using Chapa
tags: tags:
- Chapa - Chapa
/api/v1/reports/dashboard:
get:
consumes:
- application/json
description: Returns a comprehensive dashboard report with key metrics
parameters:
- description: Company ID filter
in: query
name: company_id
type: integer
- description: Branch ID filter
in: query
name: branch_id
type: integer
- description: User ID filter
in: query
name: user_id
type: integer
- description: Start time filter (RFC3339 format)
in: query
name: start_time
type: string
- description: End time filter (RFC3339 format)
in: query
name: end_time
type: string
- description: Sport ID filter
in: query
name: sport_id
type: string
- description: Status filter (0=Pending, 1=Win, 2=Loss, 3=Half, 4=Void, 5=Error)
in: query
name: status
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/report.DashboardSummary'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
security:
- ApiKeyAuth: []
summary: Get dashboard report
tags:
- Reports
/api/v1/virtual-games/recommendations/{userID}: /api/v1/virtual-games/recommendations/{userID}:
get: get:
consumes: consumes:

View File

@ -21,7 +21,7 @@ INSERT INTO branches (
is_self_owned is_self_owned
) )
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, location, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at RETURNING id, name, location, is_active, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at
` `
type CreateBranchParams struct { type CreateBranchParams struct {
@ -47,6 +47,7 @@ func (q *Queries) CreateBranch(ctx context.Context, arg CreateBranchParams) (Bra
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.Location, &i.Location,
&i.IsActive,
&i.WalletID, &i.WalletID,
&i.BranchManagerID, &i.BranchManagerID,
&i.CompanyID, &i.CompanyID,
@ -154,7 +155,7 @@ func (q *Queries) DeleteBranchOperation(ctx context.Context, arg DeleteBranchOpe
} }
const GetAllBranches = `-- name: GetAllBranches :many const GetAllBranches = `-- name: GetAllBranches :many
SELECT id, name, location, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at, manager_name, manager_phone_number, balance SELECT id, name, location, is_active, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at, manager_name, manager_phone_number, balance
FROM branch_details FROM branch_details
` `
@ -171,6 +172,7 @@ func (q *Queries) GetAllBranches(ctx context.Context) ([]BranchDetail, error) {
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.Location, &i.Location,
&i.IsActive,
&i.WalletID, &i.WalletID,
&i.BranchManagerID, &i.BranchManagerID,
&i.CompanyID, &i.CompanyID,
@ -217,7 +219,7 @@ func (q *Queries) GetAllSupportedOperations(ctx context.Context) ([]SupportedOpe
} }
const GetBranchByCashier = `-- name: GetBranchByCashier :one const GetBranchByCashier = `-- name: GetBranchByCashier :one
SELECT branches.id, branches.name, branches.location, branches.wallet_id, branches.branch_manager_id, branches.company_id, branches.is_self_owned, branches.created_at, branches.updated_at SELECT branches.id, branches.name, branches.location, branches.is_active, branches.wallet_id, branches.branch_manager_id, branches.company_id, branches.is_self_owned, branches.created_at, branches.updated_at
FROM branch_cashiers FROM branch_cashiers
JOIN branches ON branch_cashiers.branch_id = branches.id JOIN branches ON branch_cashiers.branch_id = branches.id
WHERE branch_cashiers.user_id = $1 WHERE branch_cashiers.user_id = $1
@ -230,6 +232,7 @@ func (q *Queries) GetBranchByCashier(ctx context.Context, userID int64) (Branch,
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.Location, &i.Location,
&i.IsActive,
&i.WalletID, &i.WalletID,
&i.BranchManagerID, &i.BranchManagerID,
&i.CompanyID, &i.CompanyID,
@ -241,7 +244,7 @@ func (q *Queries) GetBranchByCashier(ctx context.Context, userID int64) (Branch,
} }
const GetBranchByCompanyID = `-- name: GetBranchByCompanyID :many const GetBranchByCompanyID = `-- name: GetBranchByCompanyID :many
SELECT id, name, location, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at, manager_name, manager_phone_number, balance SELECT id, name, location, is_active, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at, manager_name, manager_phone_number, balance
FROM branch_details FROM branch_details
WHERE company_id = $1 WHERE company_id = $1
` `
@ -259,6 +262,7 @@ func (q *Queries) GetBranchByCompanyID(ctx context.Context, companyID int64) ([]
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.Location, &i.Location,
&i.IsActive,
&i.WalletID, &i.WalletID,
&i.BranchManagerID, &i.BranchManagerID,
&i.CompanyID, &i.CompanyID,
@ -280,7 +284,7 @@ func (q *Queries) GetBranchByCompanyID(ctx context.Context, companyID int64) ([]
} }
const GetBranchByID = `-- name: GetBranchByID :one const GetBranchByID = `-- name: GetBranchByID :one
SELECT id, name, location, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at, manager_name, manager_phone_number, balance SELECT id, name, location, is_active, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at, manager_name, manager_phone_number, balance
FROM branch_details FROM branch_details
WHERE id = $1 WHERE id = $1
` `
@ -292,6 +296,7 @@ func (q *Queries) GetBranchByID(ctx context.Context, id int64) (BranchDetail, er
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.Location, &i.Location,
&i.IsActive,
&i.WalletID, &i.WalletID,
&i.BranchManagerID, &i.BranchManagerID,
&i.CompanyID, &i.CompanyID,
@ -306,7 +311,7 @@ func (q *Queries) GetBranchByID(ctx context.Context, id int64) (BranchDetail, er
} }
const GetBranchByManagerID = `-- name: GetBranchByManagerID :many const GetBranchByManagerID = `-- name: GetBranchByManagerID :many
SELECT id, name, location, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at, manager_name, manager_phone_number, balance SELECT id, name, location, is_active, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at, manager_name, manager_phone_number, balance
FROM branch_details FROM branch_details
WHERE branch_manager_id = $1 WHERE branch_manager_id = $1
` `
@ -324,6 +329,7 @@ func (q *Queries) GetBranchByManagerID(ctx context.Context, branchManagerID int6
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.Location, &i.Location,
&i.IsActive,
&i.WalletID, &i.WalletID,
&i.BranchManagerID, &i.BranchManagerID,
&i.CompanyID, &i.CompanyID,
@ -392,7 +398,7 @@ func (q *Queries) GetBranchOperations(ctx context.Context, branchID int64) ([]Ge
} }
const SearchBranchByName = `-- name: SearchBranchByName :many const SearchBranchByName = `-- name: SearchBranchByName :many
SELECT id, name, location, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at, manager_name, manager_phone_number, balance SELECT id, name, location, is_active, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at, manager_name, manager_phone_number, balance
FROM branch_details FROM branch_details
WHERE name ILIKE '%' || $1 || '%' WHERE name ILIKE '%' || $1 || '%'
` `
@ -410,6 +416,7 @@ func (q *Queries) SearchBranchByName(ctx context.Context, dollar_1 pgtype.Text)
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.Location, &i.Location,
&i.IsActive,
&i.WalletID, &i.WalletID,
&i.BranchManagerID, &i.BranchManagerID,
&i.CompanyID, &i.CompanyID,
@ -438,7 +445,7 @@ SET name = COALESCE($2, name),
company_id = COALESCE($5, company_id), company_id = COALESCE($5, company_id),
is_self_owned = COALESCE($6, is_self_owned) is_self_owned = COALESCE($6, is_self_owned)
WHERE id = $1 WHERE id = $1
RETURNING id, name, location, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at RETURNING id, name, location, is_active, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at
` `
type UpdateBranchParams struct { type UpdateBranchParams struct {
@ -464,6 +471,7 @@ func (q *Queries) UpdateBranch(ctx context.Context, arg UpdateBranchParams) (Bra
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.Location, &i.Location,
&i.IsActive,
&i.WalletID, &i.WalletID,
&i.BranchManagerID, &i.BranchManagerID,
&i.CompanyID, &i.CompanyID,

View File

@ -112,6 +112,7 @@ type Branch struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Location string `json:"location"` Location string `json:"location"`
IsActive bool `json:"is_active"`
WalletID int64 `json:"wallet_id"` WalletID int64 `json:"wallet_id"`
BranchManagerID int64 `json:"branch_manager_id"` BranchManagerID int64 `json:"branch_manager_id"`
CompanyID int64 `json:"company_id"` CompanyID int64 `json:"company_id"`
@ -130,6 +131,7 @@ type BranchDetail struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Location string `json:"location"` Location string `json:"location"`
IsActive bool `json:"is_active"`
WalletID int64 `json:"wallet_id"` WalletID int64 `json:"wallet_id"`
BranchManagerID int64 `json:"branch_manager_id"` BranchManagerID int64 `json:"branch_manager_id"`
CompanyID int64 `json:"company_id"` CompanyID int64 `json:"company_id"`
@ -363,25 +365,25 @@ type Transaction struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Amount int64 `json:"amount"` Amount int64 `json:"amount"`
BranchID int64 `json:"branch_id"` BranchID int64 `json:"branch_id"`
CompanyID int64 `json:"company_id"` CompanyID pgtype.Int8 `json:"company_id"`
CashierID int64 `json:"cashier_id"` CashierID pgtype.Int8 `json:"cashier_id"`
CashierName string `json:"cashier_name"` CashierName pgtype.Text `json:"cashier_name"`
BetID int64 `json:"bet_id"` BetID pgtype.Int8 `json:"bet_id"`
NumberOfOutcomes int64 `json:"number_of_outcomes"` NumberOfOutcomes pgtype.Int8 `json:"number_of_outcomes"`
Type int64 `json:"type"` Type pgtype.Int8 `json:"type"`
PaymentOption int64 `json:"payment_option"` PaymentOption pgtype.Int8 `json:"payment_option"`
FullName string `json:"full_name"` FullName pgtype.Text `json:"full_name"`
PhoneNumber string `json:"phone_number"` PhoneNumber pgtype.Text `json:"phone_number"`
BankCode string `json:"bank_code"` BankCode pgtype.Text `json:"bank_code"`
BeneficiaryName string `json:"beneficiary_name"` BeneficiaryName pgtype.Text `json:"beneficiary_name"`
AccountName string `json:"account_name"` AccountName pgtype.Text `json:"account_name"`
AccountNumber string `json:"account_number"` AccountNumber pgtype.Text `json:"account_number"`
ReferenceNumber string `json:"reference_number"` ReferenceNumber pgtype.Text `json:"reference_number"`
Verified bool `json:"verified"` Verified bool `json:"verified"`
ApprovedBy pgtype.Int8 `json:"approved_by"` ApprovedBy pgtype.Int8 `json:"approved_by"`
ApproverName pgtype.Text `json:"approver_name"` ApproverName pgtype.Text `json:"approver_name"`
BranchLocation string `json:"branch_location"` BranchLocation pgtype.Text `json:"branch_location"`
BranchName string `json:"branch_name"` BranchName pgtype.Text `json:"branch_name"`
CreatedAt pgtype.Timestamp `json:"created_at"` CreatedAt pgtype.Timestamp `json:"created_at"`
UpdatedAt pgtype.Timestamp `json:"updated_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"`
} }
@ -471,6 +473,12 @@ type Wallet struct {
CashBalance pgtype.Numeric `json:"cash_balance"` CashBalance pgtype.Numeric `json:"cash_balance"`
} }
type WalletThresholdNotification struct {
CompanyID int64 `json:"company_id"`
Threshold float64 `json:"threshold"`
NotifiedAt pgtype.Timestamptz `json:"notified_at"`
}
type WalletTransfer struct { type WalletTransfer struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Amount int64 `json:"amount"` Amount int64 `json:"amount"`

131
gen/db/monitor.sql.go Normal file
View File

@ -0,0 +1,131 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: monitor.sql
package dbgen
import (
"context"
)
const CountThresholdNotifications = `-- name: CountThresholdNotifications :one
SELECT COUNT(*)
FROM wallet_threshold_notifications
WHERE company_id = $1 AND threshold = $2
`
type CountThresholdNotificationsParams struct {
CompanyID int64 `json:"company_id"`
Threshold float64 `json:"threshold"`
}
func (q *Queries) CountThresholdNotifications(ctx context.Context, arg CountThresholdNotificationsParams) (int64, error) {
row := q.db.QueryRow(ctx, CountThresholdNotifications, arg.CompanyID, arg.Threshold)
var count int64
err := row.Scan(&count)
return count, err
}
const CreateThresholdNotification = `-- name: CreateThresholdNotification :exec
INSERT INTO wallet_threshold_notifications (company_id, threshold)
VALUES ($1, $2)
`
type CreateThresholdNotificationParams struct {
CompanyID int64 `json:"company_id"`
Threshold float64 `json:"threshold"`
}
func (q *Queries) CreateThresholdNotification(ctx context.Context, arg CreateThresholdNotificationParams) error {
_, err := q.db.Exec(ctx, CreateThresholdNotification, arg.CompanyID, arg.Threshold)
return err
}
const GetAllCompaniesBranch = `-- name: GetAllCompaniesBranch :many
SELECT id, name, wallet_id, admin_id
FROM companies
`
type GetAllCompaniesBranchRow struct {
ID int64 `json:"id"`
Name string `json:"name"`
WalletID int64 `json:"wallet_id"`
AdminID int64 `json:"admin_id"`
}
func (q *Queries) GetAllCompaniesBranch(ctx context.Context) ([]GetAllCompaniesBranchRow, error) {
rows, err := q.db.Query(ctx, GetAllCompaniesBranch)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllCompaniesBranchRow
for rows.Next() {
var i GetAllCompaniesBranchRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.WalletID,
&i.AdminID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetBranchesByCompanyID = `-- name: GetBranchesByCompanyID :many
SELECT
id,
name,
location,
wallet_id,
branch_manager_id,
company_id,
is_self_owned
FROM branches
WHERE company_id = $1
`
type GetBranchesByCompanyIDRow struct {
ID int64 `json:"id"`
Name string `json:"name"`
Location string `json:"location"`
WalletID int64 `json:"wallet_id"`
BranchManagerID int64 `json:"branch_manager_id"`
CompanyID int64 `json:"company_id"`
IsSelfOwned bool `json:"is_self_owned"`
}
func (q *Queries) GetBranchesByCompanyID(ctx context.Context, companyID int64) ([]GetBranchesByCompanyIDRow, error) {
rows, err := q.db.Query(ctx, GetBranchesByCompanyID, companyID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetBranchesByCompanyIDRow
for rows.Next() {
var i GetBranchesByCompanyIDRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Location,
&i.WalletID,
&i.BranchManagerID,
&i.CompanyID,
&i.IsSelfOwned,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -58,22 +58,22 @@ RETURNING id, amount, branch_id, company_id, cashier_id, cashier_name, bet_id, n
type CreateTransactionParams struct { type CreateTransactionParams struct {
Amount int64 `json:"amount"` Amount int64 `json:"amount"`
BranchID int64 `json:"branch_id"` BranchID int64 `json:"branch_id"`
CashierID int64 `json:"cashier_id"` CashierID pgtype.Int8 `json:"cashier_id"`
BetID int64 `json:"bet_id"` BetID pgtype.Int8 `json:"bet_id"`
Type int64 `json:"type"` Type pgtype.Int8 `json:"type"`
PaymentOption int64 `json:"payment_option"` PaymentOption pgtype.Int8 `json:"payment_option"`
FullName string `json:"full_name"` FullName pgtype.Text `json:"full_name"`
PhoneNumber string `json:"phone_number"` PhoneNumber pgtype.Text `json:"phone_number"`
BankCode string `json:"bank_code"` BankCode pgtype.Text `json:"bank_code"`
BeneficiaryName string `json:"beneficiary_name"` BeneficiaryName pgtype.Text `json:"beneficiary_name"`
AccountName string `json:"account_name"` AccountName pgtype.Text `json:"account_name"`
AccountNumber string `json:"account_number"` AccountNumber pgtype.Text `json:"account_number"`
ReferenceNumber string `json:"reference_number"` ReferenceNumber pgtype.Text `json:"reference_number"`
NumberOfOutcomes int64 `json:"number_of_outcomes"` NumberOfOutcomes pgtype.Int8 `json:"number_of_outcomes"`
BranchName string `json:"branch_name"` BranchName pgtype.Text `json:"branch_name"`
BranchLocation string `json:"branch_location"` BranchLocation pgtype.Text `json:"branch_location"`
CompanyID int64 `json:"company_id"` CompanyID pgtype.Int8 `json:"company_id"`
CashierName string `json:"cashier_name"` CashierName pgtype.Text `json:"cashier_name"`
} }
func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (Transaction, error) { func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (Transaction, error) {

View File

@ -59,18 +59,8 @@ INSERT INTO users (
company_id company_id
) )
VALUES ( VALUES (
$1, $1, $2, $3, $4, $5, $6,
$2, $7, $8, $9, $10, $11, $12
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10,
$11,
$12
) )
RETURNING id, RETURNING id,
first_name, first_name,

18
go.mod
View File

@ -56,11 +56,27 @@ require (
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // 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/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/net v0.38.0 // indirect golang.org/x/net v0.38.0 // direct
golang.org/x/sync v0.12.0 // indirect golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.31.0 // indirect golang.org/x/tools v0.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
require (
github.com/golang/snappy v0.0.4 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/zap v1.27.0
)
require (
github.com/resend/resend-go/v2 v2.20.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
)

35
go.sum
View File

@ -52,6 +52,10 @@ github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27X
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
@ -96,6 +100,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U= github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U=
github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
@ -104,6 +110,8 @@ github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT9
github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/resend/resend-go/v2 v2.20.0 h1:MrIrgV0aHhwRgmcRPw33Nexn6aGJvCvG2XwfFpAMBGM=
github.com/resend/resend-go/v2 v2.20.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@ -147,30 +155,52 @@ github.com/valyala/fasthttp v1.36.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxn
github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI= github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI=
github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU= github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -182,20 +212,25 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@ -30,6 +30,8 @@ var (
ErrInvalidVeliAPIURL = errors.New("Veli API URL is invalid") ErrInvalidVeliAPIURL = errors.New("Veli API URL is invalid")
ErrInvalidVeliOperatorKey = errors.New("Veli operator key is invalid") ErrInvalidVeliOperatorKey = errors.New("Veli operator key is invalid")
ErrInvalidVeliSecretKey = errors.New("Veli secret key is invalid") ErrInvalidVeliSecretKey = errors.New("Veli secret key is invalid")
ErrMissingResendApiKey = errors.New("missing Resend Api key")
ErrMissingResendSenderEmail = errors.New("missing Resend sender name")
) )
type AleaPlayConfig struct { type AleaPlayConfig struct {
@ -75,6 +77,8 @@ type Config struct {
PopOK domain.PopOKConfig PopOK domain.PopOKConfig
AleaPlay AleaPlayConfig `mapstructure:"alea_play"` AleaPlay AleaPlayConfig `mapstructure:"alea_play"`
VeliGames VeliGamesConfig `mapstructure:"veli_games"` VeliGames VeliGamesConfig `mapstructure:"veli_games"`
ResendApiKey string
ResendSenderEmail string
} }
func NewConfig() (*Config, error) { func NewConfig() (*Config, error) {
@ -287,6 +291,19 @@ func (c *Config) loadEnv() error {
return ErrMissingBetToken return ErrMissingBetToken
} }
c.Bet365Token = betToken c.Bet365Token = betToken
resendApiKey := os.Getenv("RESEND_API_KEY")
if resendApiKey == "" {
return ErrMissingResendApiKey
}
c.ResendApiKey = resendApiKey
resendSenderEmail := os.Getenv("RESEND_SENDER_EMAIL")
if resendSenderEmail == "" {
return ErrMissingResendSenderEmail
}
c.ResendSenderEmail = resendSenderEmail
return nil return nil
} }

View File

@ -7,6 +7,7 @@ type Branch struct {
WalletID int64 WalletID int64
BranchManagerID int64 BranchManagerID int64
CompanyID int64 CompanyID int64
IsSuspended bool
IsSelfOwned bool IsSelfOwned bool
} }
@ -18,6 +19,7 @@ type BranchDetail struct {
Balance Currency Balance Currency
BranchManagerID int64 BranchManagerID int64
CompanyID int64 CompanyID int64
IsSuspended bool
IsSelfOwned bool IsSelfOwned bool
ManagerName string ManagerName string
ManagerPhoneNumber string ManagerPhoneNumber string

View File

@ -192,9 +192,9 @@ func (r ChapaDepositRequest) Validate() error {
if r.PhoneNumber == "" { if r.PhoneNumber == "" {
return errors.New("phone number is required") return errors.New("phone number is required")
} }
if r.BranchID == 0 { // if r.BranchID == 0 {
return errors.New("branch ID is required") // return errors.New("branch ID is required")
} // }
return nil return nil
} }

View File

@ -0,0 +1,12 @@
package domain
type LogEntry struct {
Level string `json:"level" bson:"level"`
Message string `json:"message" bson:"message"`
Timestamp string `json:"timestamp" bson:"timestamp"`
Fields map[string]interface{} `json:"fields" bson:"fields"`
Caller string `json:"caller" bson:"caller"`
Stack string `json:"stacktrace" bson:"stacktrace"`
Service string `json:"service" bson:"service"`
Env string `json:"env" bson:"env"`
}

View File

@ -22,6 +22,10 @@ const (
NotificationTypeBetOverload NotificationType = "bet_overload" NotificationTypeBetOverload NotificationType = "bet_overload"
NotificationTypeSignUpWelcome NotificationType = "signup_welcome" NotificationTypeSignUpWelcome NotificationType = "signup_welcome"
NotificationTypeOTPSent NotificationType = "otp_sent" NotificationTypeOTPSent NotificationType = "otp_sent"
NOTIFICATION_TYPE_WALLET NotificationType = "wallet_threshold"
NOTIFICATION_TYPE_TRANSFER NotificationType = "transfer_failed"
NOTIFICATION_TYPE_ADMIN_ALERT NotificationType = "admin_alert"
NOTIFICATION_RECEIVER_ADMIN NotificationRecieverSide = "admin"
NotificationRecieverSideAdmin NotificationRecieverSide = "admin" NotificationRecieverSideAdmin NotificationRecieverSide = "admin"
NotificationRecieverSideCustomer NotificationRecieverSide = "customer" NotificationRecieverSideCustomer NotificationRecieverSide = "customer"

123
internal/domain/report.go Normal file
View File

@ -0,0 +1,123 @@
package domain
import "time"
type ValidOutcomeStatus struct {
Value OutcomeStatus
Valid bool // Valid is true if Value is not NULL
}
// ReportFilter contains filters for report generation
type ReportFilter struct {
StartTime ValidTime `json:"start_time"`
EndTime ValidTime `json:"end_time"`
CompanyID ValidInt64 `json:"company_id"`
BranchID ValidInt64 `json:"branch_id"`
UserID ValidInt64 `json:"user_id"`
SportID ValidString `json:"sport_id"`
Status ValidOutcomeStatus `json:"status"`
}
// BetStat represents aggregated bet statistics
type BetStat struct {
Date time.Time
TotalBets int64
TotalStakes Currency
TotalWins int64
TotalPayouts Currency
AverageOdds float64
}
// ExtremeValues represents extreme values in betting
type ExtremeValues struct {
HighestStake Currency
HighestPayout Currency
}
// CustomerBetActivity represents customer betting activity
type CustomerBetActivity struct {
CustomerID int64
TotalBets int64
TotalStakes Currency
TotalWins int64
TotalPayouts Currency
FirstBetDate time.Time
LastBetDate time.Time
AverageOdds float64
}
// BranchBetActivity represents branch betting activity
type BranchBetActivity struct {
BranchID int64
TotalBets int64
TotalStakes Currency
TotalWins int64
TotalPayouts Currency
}
// BranchDetail represents branch details
// type BranchDetail struct {
// Name string
// Location string
// ManagerName string
// }
// BranchTransactions represents branch transaction totals
type BranchTransactions struct {
Deposits Currency
Withdrawals Currency
}
// SportBetActivity represents sport betting activity
type SportBetActivity struct {
SportID string
TotalBets int64
TotalStakes Currency
TotalWins int64
TotalPayouts Currency
AverageOdds float64
}
// CustomerDetail represents customer details
type CustomerDetail struct {
Name string
}
// BalanceSummary represents wallet balance summary
type BalanceSummary struct {
TotalBalance Currency
ActiveBalance Currency
InactiveBalance Currency
BettableBalance Currency
NonBettableBalance Currency
}
// In your domain package
type CustomerPreferences struct {
FavoriteSport string `json:"favorite_sport"`
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 ErrorResponse struct {
Message string `json:"message"`
Error string `json:"error,omitempty"`
}

View File

@ -13,8 +13,8 @@ type User struct {
ID int64 ID int64
FirstName string FirstName string
LastName string LastName string
Email string Email string `json:"email"`
PhoneNumber string PhoneNumber string `json:"phone_number"`
Password []byte Password []byte
Role Role Role Role
// //
@ -29,6 +29,7 @@ type User struct {
// //
CompanyID ValidInt64 CompanyID ValidInt64
} }
type RegisterUserReq struct { type RegisterUserReq struct {
FirstName string FirstName string
LastName string LastName string
@ -62,6 +63,7 @@ type UpdateUserReq struct {
FirstName ValidString FirstName ValidString
LastName ValidString LastName ValidString
Suspended ValidBool Suspended ValidBool
CompanyID ValidInt64 CompanyID ValidInt64
} }

View File

@ -1,8 +1,15 @@
package customlogger package customlogger
import ( import (
"context"
"fmt"
"log/slog" "log/slog"
"os" "os"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
) )
var LogLevels = map[string]slog.Level{ var LogLevels = map[string]slog.Level{
@ -24,7 +31,7 @@ func NewLogger(env string, lvl slog.Level) *slog.Logger {
panic("Failed to create log directory: " + err.Error()) panic("Failed to create log directory: " + err.Error())
} }
file, err := os.OpenFile("logs/app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) file, err := os.OpenFile("logs/app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil { if err != nil {
panic("Failed to open log file: " + err.Error()) panic("Failed to open log file: " + err.Error())
} }
@ -48,3 +55,59 @@ func NewLogger(env string, lvl slog.Level) *slog.Logger {
return logger return logger
} }
// MongoLogger wraps a MongoDB connection and logger
type MongoLogger struct {
client *mongo.Client
collection *mongo.Collection
}
// NewMongoLogger creates a new MongoDB-connected logger
func NewMongoLogger(mongoURI, dbName, collectionName string) (*MongoLogger, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 1. Connect to MongoDB with retries
client, err := mongo.Connect(ctx, options.Client().
ApplyURI(mongoURI).
SetServerAPIOptions(options.ServerAPI(options.ServerAPIVersion1)),
)
if err != nil {
return nil, fmt.Errorf("MongoDB connection failed: %w", err)
}
// 2. Verify connection
err = client.Ping(ctx, nil)
if err != nil {
return nil, fmt.Errorf("MongoDB ping failed: %w", err)
}
// 3. Get collection handle
coll := client.Database(dbName).Collection(collectionName)
return &MongoLogger{
client: client,
collection: coll,
}, nil
}
// Log writes a log entry to MongoDB
func (ml *MongoLogger) Log(level, message string, attrs map[string]interface{}) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := ml.collection.InsertOne(ctx, bson.M{
"timestamp": time.Now(),
"level": level,
"message": message,
"attrs": attrs,
})
return err
}
// Close safely disconnects from MongoDB
func (ml *MongoLogger) Close() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return ml.client.Disconnect(ctx)
}

View File

@ -0,0 +1,30 @@
package mongoLogger
import (
"log"
"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)
if err != nil {
log.Fatalf("failed to create MongoDB core: %v", err)
}
consoleEncoder := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())
consoleCore := zapcore.NewCore(consoleEncoder, zapcore.Lock(os.Stdout), zapcore.DebugLevel)
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"))
}

View File

@ -0,0 +1,89 @@
package mongoLogger
import (
"context"
"fmt"
"time"
"maps"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"go.uber.org/zap/zapcore"
)
type MongoCore struct {
collection *mongo.Collection
level zapcore.Level
fields []zapcore.Field
}
func NewMongoCore(uri, dbName, collectionName string, level zapcore.Level) (zapcore.Core, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
if err != nil {
return nil, err
}
if err := client.Ping(ctx, nil); err != nil {
return nil, fmt.Errorf("unable to connect to MongoDB: %w", err)
}
coll := client.Database(dbName).Collection(collectionName)
return &MongoCore{
collection: coll,
level: level,
}, nil
}
func (mc *MongoCore) Enabled(lvl zapcore.Level) bool {
return lvl >= mc.level
}
func (mc *MongoCore) With(fields []zapcore.Field) zapcore.Core {
clone := *mc
clone.fields = append(clone.fields, fields...)
return &clone
}
func (mc *MongoCore) Check(entry zapcore.Entry, checkedEntry *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if mc.Enabled(entry.Level) {
return checkedEntry.AddCore(entry, mc)
}
return checkedEntry
}
func (mc *MongoCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
logMap := make(map[string]interface{})
enc := zapcore.NewMapObjectEncoder()
for _, f := range append(mc.fields, fields...) {
f.AddTo(enc)
}
maps.Copy(logMap, enc.Fields)
doc := bson.M{
"level": entry.Level.String(),
"message": entry.Message,
"timestamp": entry.Time.UTC().Format(time.RFC3339Nano),
"fields": logMap,
"caller": entry.Caller.String(),
"stacktrace": entry.Stack,
"service": "fortunebet-backend",
"env": "dev",
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := mc.collection.InsertOne(ctx, doc)
return err
}
func (mc *MongoCore) Sync() error {
return nil
}

View File

@ -1,7 +1,17 @@
package helpers package helpers
import "github.com/google/uuid" import (
"fmt"
"math/rand/v2"
"github.com/google/uuid"
)
func GenerateID() string { func GenerateID() string {
return uuid.New().String() return uuid.New().String()
} }
func GenerateOTP() string {
num := 100000 + rand.UintN(899999)
return fmt.Sprintf("%d", num) // 6 digit random number [100,000 - 999,999]
}

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"fmt"
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
@ -257,3 +258,158 @@ func (s *Store) DeleteBranchCashier(ctx context.Context, userID int64) error {
return s.queries.DeleteBranchCashier(ctx, userID) return s.queries.DeleteBranchCashier(ctx, userID)
} }
// GetBranchCounts returns total and active branch counts
func (s *Store) GetBranchCounts(ctx context.Context, filter domain.ReportFilter) (total, active int64, err error) {
query := `SELECT
COUNT(*) as total,
COUNT(CASE WHEN is_active = true THEN 1 END) as active
FROM branches`
args := []interface{}{}
argPos := 1
// Add filters if provided
if filter.CompanyID.Valid {
query += fmt.Sprintf(" WHERE 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) == 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++
}
row := s.conn.QueryRow(ctx, query, args...)
err = row.Scan(&total, &active)
if err != nil {
return 0, 0, fmt.Errorf("failed to get branch counts: %w", err)
}
return total, active, nil
}
// GetBranchDetails returns branch details map
func (s *Store) GetBranchDetails(ctx context.Context, filter domain.ReportFilter) (map[int64]domain.BranchDetail, error) {
query := `SELECT
b.id,
b.name,
b.location,
CONCAT(u.first_name, ' ', u.last_name) as manager_name
FROM branches b
LEFT JOIN users u ON b.branch_manager_id = u.id`
args := []interface{}{}
argPos := 1
// Add filters if provided
if filter.CompanyID.Valid {
query += fmt.Sprintf(" WHERE b.company_id = $%d", argPos)
args = append(args, filter.CompanyID.Value)
argPos++
}
if filter.BranchID.Valid {
query += fmt.Sprintf(" AND %sb.id = $%d", func() string {
if len(args) == 0 {
return " WHERE "
}
return " AND "
}(), argPos)
args = append(args, filter.BranchID.Value)
argPos++
}
if filter.StartTime.Valid {
query += fmt.Sprintf(" AND %sb.created_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 b.created_at <= $%d", argPos)
args = append(args, filter.EndTime.Value)
argPos++
}
rows, err := s.conn.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query branch details: %w", err)
}
defer rows.Close()
details := make(map[int64]domain.BranchDetail)
for rows.Next() {
var id int64
var detail domain.BranchDetail
if err := rows.Scan(&id, &detail.Name, &detail.Location, &detail.ManagerName); err != nil {
return nil, fmt.Errorf("failed to scan branch detail: %w", err)
}
details[id] = detail
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
return details, nil
}
// In internal/repository/branch.go
func (s *Store) GetAllCompaniesBranch(ctx context.Context) ([]domain.Company, error) {
dbCompanies, err := s.queries.GetAllCompanies(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get all companies: %w", err)
}
companies := make([]domain.Company, 0, len(dbCompanies))
for _, dbCompany := range dbCompanies {
companies = append(companies, domain.Company{
ID: dbCompany.ID,
Name: dbCompany.Name,
WalletID: dbCompany.WalletID,
AdminID: dbCompany.AdminID,
})
}
return companies, nil
}
// In internal/repository/branch.go
func (s *Store) GetBranchesByCompany(ctx context.Context, companyID int64) ([]domain.Branch, error) {
dbBranches, err := s.queries.GetBranchByCompanyID(ctx, companyID)
if err != nil {
return nil, fmt.Errorf("failed to get branches for company %d: %w", companyID, err)
}
branches := make([]domain.Branch, 0, len(dbBranches))
for _, dbBranch := range dbBranches {
branch := domain.Branch{
ID: dbBranch.ID,
Name: dbBranch.Name,
Location: dbBranch.Location,
WalletID: dbBranch.WalletID,
CompanyID: dbBranch.CompanyID,
IsSelfOwned: dbBranch.IsSelfOwned,
}
branch.BranchManagerID = dbBranch.BranchManagerID
branches = append(branches, branch)
}
return branches, nil
}

View File

@ -7,6 +7,7 @@ import (
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"golang.org/x/net/websocket"
) )
type NotificationRepository interface { type NotificationRepository interface {
@ -27,6 +28,10 @@ func NewNotificationRepository(store *Store) NotificationRepository {
return &Repository{store: store} return &Repository{store: store}
} }
func (r *Repository) ConnectWebSocket(ctx context.Context, recipientID int64, c *websocket.Conn) error {
return nil
}
func (r *Repository) CreateNotification(ctx context.Context, notification *domain.Notification) (*domain.Notification, error) { func (r *Repository) CreateNotification(ctx context.Context, notification *domain.Notification) (*domain.Notification, error) {
var errorSeverity pgtype.Text var errorSeverity pgtype.Text
if notification.ErrorSeverity != nil { if notification.ErrorSeverity != nil {

View File

@ -7,6 +7,7 @@ import (
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
// "golang.org/x/net/websocket"
) )
type Store struct { type Store struct {
@ -49,3 +50,12 @@ func (s *Store) BeginTx(ctx context.Context) (*dbgen.Queries, pgx.Tx, error) {
q := s.queries.WithTx(tx) q := s.queries.WithTx(tx)
return q, tx, nil return q, tx, nil
} }
// func (s *Store) ConnectWebSocket(ctx context.Context, recipientID int64, c *websocket.Conn) error {
// // Implement WebSocket connection logic
// return nil
// }
// func (s *Store) DisconnectWebSocket(recipientID int64) {
// // Implement WebSocket disconnection logic
// }

View File

@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"fmt"
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
@ -13,18 +14,18 @@ func convertDBTransaction(transaction dbgen.Transaction) domain.Transaction {
ID: transaction.ID, ID: transaction.ID,
Amount: domain.Currency(transaction.Amount), Amount: domain.Currency(transaction.Amount),
BranchID: transaction.BranchID, BranchID: transaction.BranchID,
CashierID: transaction.CashierID, CashierID: transaction.CashierID.Int64,
BetID: transaction.BetID, BetID: transaction.BetID.Int64,
NumberOfOutcomes: transaction.NumberOfOutcomes, NumberOfOutcomes: transaction.NumberOfOutcomes.Int64,
Type: domain.TransactionType(transaction.Type), Type: domain.TransactionType(transaction.Type.Int64),
PaymentOption: domain.PaymentOption(transaction.PaymentOption), PaymentOption: domain.PaymentOption(transaction.PaymentOption.Int64),
FullName: transaction.FullName, FullName: transaction.FullName.String,
PhoneNumber: transaction.PhoneNumber, PhoneNumber: transaction.PhoneNumber.String,
BankCode: transaction.BankCode, BankCode: transaction.BankCode.String,
BeneficiaryName: transaction.BeneficiaryName, BeneficiaryName: transaction.BeneficiaryName.String,
AccountName: transaction.AccountName, AccountName: transaction.AccountName.String,
AccountNumber: transaction.AccountNumber, AccountNumber: transaction.AccountNumber.String,
ReferenceNumber: transaction.ReferenceNumber, ReferenceNumber: transaction.ReferenceNumber.String,
ApprovedBy: domain.ValidInt64{ ApprovedBy: domain.ValidInt64{
Value: transaction.ApprovedBy.Int64, Value: transaction.ApprovedBy.Int64,
Valid: transaction.ApprovedBy.Valid, Valid: transaction.ApprovedBy.Valid,
@ -32,10 +33,10 @@ func convertDBTransaction(transaction dbgen.Transaction) domain.Transaction {
CreatedAt: transaction.CreatedAt.Time, CreatedAt: transaction.CreatedAt.Time,
UpdatedAt: transaction.UpdatedAt.Time, UpdatedAt: transaction.UpdatedAt.Time,
Verified: transaction.Verified, Verified: transaction.Verified,
BranchName: transaction.BranchName, BranchName: transaction.BranchName.String,
BranchLocation: transaction.BranchLocation, BranchLocation: transaction.BranchLocation.String,
CashierName: transaction.CashierName, CashierName: transaction.CashierName.String,
CompanyID: transaction.CompanyID, CompanyID: transaction.CompanyID.Int64,
ApproverName: domain.ValidString{ ApproverName: domain.ValidString{
Value: transaction.ApproverName.String, Value: transaction.ApproverName.String,
Valid: transaction.ApprovedBy.Valid, Valid: transaction.ApprovedBy.Valid,
@ -47,22 +48,22 @@ func convertCreateTransaction(transaction domain.CreateTransaction) dbgen.Create
return dbgen.CreateTransactionParams{ return dbgen.CreateTransactionParams{
Amount: int64(transaction.Amount), Amount: int64(transaction.Amount),
BranchID: transaction.BranchID, BranchID: transaction.BranchID,
CashierID: transaction.CashierID, CashierID: pgtype.Int8{Int64: transaction.CashierID, Valid: true},
BetID: transaction.BetID, BetID: pgtype.Int8{Int64: transaction.BetID, Valid: true},
Type: int64(transaction.Type), Type: pgtype.Int8{Int64: int64(transaction.Type), Valid: true},
PaymentOption: int64(transaction.PaymentOption), PaymentOption: pgtype.Int8{Int64: int64(transaction.PaymentOption), Valid: true},
FullName: transaction.FullName, FullName: pgtype.Text{String: transaction.FullName, Valid: transaction.FullName != ""},
PhoneNumber: transaction.PhoneNumber, PhoneNumber: pgtype.Text{String: transaction.PhoneNumber, Valid: transaction.PhoneNumber != ""},
BankCode: transaction.BankCode, BankCode: pgtype.Text{String: transaction.BankCode, Valid: transaction.BankCode != ""},
BeneficiaryName: transaction.BeneficiaryName, BeneficiaryName: pgtype.Text{String: transaction.BeneficiaryName, Valid: transaction.BeneficiaryName != ""},
AccountName: transaction.AccountName, AccountName: pgtype.Text{String: transaction.AccountName, Valid: transaction.AccountName != ""},
AccountNumber: transaction.AccountNumber, AccountNumber: pgtype.Text{String: transaction.AccountNumber, Valid: transaction.AccountNumber != ""},
ReferenceNumber: transaction.ReferenceNumber, ReferenceNumber: pgtype.Text{String: transaction.ReferenceNumber, Valid: transaction.ReferenceNumber != ""},
NumberOfOutcomes: transaction.NumberOfOutcomes, NumberOfOutcomes: pgtype.Int8{Int64: transaction.NumberOfOutcomes, Valid: true},
BranchName: transaction.BranchName, BranchName: pgtype.Text{String: transaction.BranchName, Valid: transaction.BranchName != ""},
BranchLocation: transaction.BranchLocation, BranchLocation: pgtype.Text{String: transaction.BranchLocation, Valid: transaction.BranchLocation != ""},
CashierName: transaction.CashierName, CashierName: pgtype.Text{String: transaction.CashierName, Valid: transaction.CashierName != ""},
CompanyID: transaction.CompanyID, CompanyID: pgtype.Int8{Int64: transaction.CompanyID, Valid: true},
} }
} }
@ -139,3 +140,106 @@ func (s *Store) UpdateTransactionVerified(ctx context.Context, id int64, verifie
}) })
return err return err
} }
// GetTransactionTotals returns total deposits and withdrawals
func (s *Store) GetTransactionTotals(ctx context.Context, filter domain.ReportFilter) (deposits, withdrawals domain.Currency, err error) {
query := `SELECT
COALESCE(SUM(CASE WHEN type = 1 THEN amount ELSE 0 END), 0) as deposits,
COALESCE(SUM(CASE WHEN type = 0 THEN amount ELSE 0 END), 0) as withdrawals
FROM transactions`
args := []interface{}{}
argPos := 1
if filter.CompanyID.Valid {
query += fmt.Sprintf(" WHERE company_id = $%d", argPos)
args = append(args, filter.CompanyID.Value)
argPos++
} else if filter.BranchID.Valid {
query += fmt.Sprintf(" WHERE branch_id = $%d", argPos)
args = append(args, filter.BranchID.Value)
argPos++
} else if filter.UserID.Valid {
query += fmt.Sprintf(" WHERE cashier_id = $%d", argPos)
args = append(args, filter.UserID.Value)
argPos++
}
if filter.StartTime.Valid {
query += fmt.Sprintf(" AND %screated_at >= $%d", func() string {
if len(args) == 0 {
return ""
}
return " "
}(), 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(&deposits, &withdrawals)
if err != nil {
return 0, 0, fmt.Errorf("failed to get transaction totals: %w", err)
}
return deposits, withdrawals, nil
}
// GetBranchTransactionTotals returns transaction totals by branch
func (s *Store) GetBranchTransactionTotals(ctx context.Context, filter domain.ReportFilter) (map[int64]domain.BranchTransactions, error) {
query := `SELECT
branch_id,
COALESCE(SUM(CASE WHEN type = 1 THEN amount ELSE 0 END), 0) as deposits,
COALESCE(SUM(CASE WHEN type = 0 THEN amount ELSE 0 END), 0) as withdrawals
FROM transactions`
args := []interface{}{}
argPos := 1
if filter.CompanyID.Valid {
query += fmt.Sprintf(" WHERE 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) == 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++
}
query += " GROUP BY branch_id"
rows, err := s.conn.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query branch transaction totals: %w", err)
}
defer rows.Close()
totals := make(map[int64]domain.BranchTransactions)
for rows.Next() {
var branchID int64
var transactions domain.BranchTransactions
if err := rows.Scan(&branchID, &transactions.Deposits, &transactions.Withdrawals); err != nil {
return nil, fmt.Errorf("failed to scan branch transaction totals: %w", err)
}
totals[branchID] = transactions
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
return totals, nil
}

View File

@ -464,3 +464,250 @@ func (s *Store) CreateUserWithoutOtp(ctx context.Context, user domain.User, is_c
Suspended: userRes.Suspended, Suspended: userRes.Suspended,
}, nil }, nil
} }
// GetCustomerCounts returns total and active customer counts
func (s *Store) GetCustomerCounts(ctx context.Context, filter domain.ReportFilter) (total, active int64, err error) {
query := `SELECT
COUNT(*) as total,
SUM(CASE WHEN suspended = false THEN 1 ELSE 0 END) as active
FROM users WHERE role = 'customer'`
args := []interface{}{}
argPos := 1
// 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.BranchID.Valid {
query += fmt.Sprintf(" AND id IN (SELECT user_id FROM branch_cashiers WHERE branch_id = $%d)", argPos)
args = append(args, filter.BranchID.Value)
argPos++
}
if filter.StartTime.Valid {
query += fmt.Sprintf(" AND 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 := s.conn.QueryRow(ctx, query, args...)
err = row.Scan(&total, &active)
if err != nil {
return 0, 0, fmt.Errorf("failed to get customer counts: %w", err)
}
return total, active, nil
}
// GetCustomerDetails returns customer details map
func (s *Store) GetCustomerDetails(ctx context.Context, filter domain.ReportFilter) (map[int64]domain.CustomerDetail, error) {
query := `SELECT id, first_name, last_name
FROM users WHERE role = 'customer'`
args := []interface{}{}
argPos := 1
// 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.BranchID.Valid {
query += fmt.Sprintf(" AND id IN (SELECT user_id FROM branch_cashiers WHERE branch_id = $%d)", argPos)
args = append(args, filter.BranchID.Value)
argPos++
}
if filter.StartTime.Valid {
query += fmt.Sprintf(" AND 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++
}
rows, err := s.conn.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query customer details: %w", err)
}
defer rows.Close()
details := make(map[int64]domain.CustomerDetail)
for rows.Next() {
var id int64
var firstName, lastName string
if err := rows.Scan(&id, &firstName, &lastName); err != nil {
return nil, fmt.Errorf("failed to scan customer detail: %w", err)
}
details[id] = domain.CustomerDetail{
Name: fmt.Sprintf("%s %s", firstName, lastName),
}
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
return details, nil
}
// GetBranchCustomerCounts returns customer counts per branch
func (s *Store) GetBranchCustomerCounts(ctx context.Context, filter domain.ReportFilter) (map[int64]int64, error) {
query := `SELECT branch_id, COUNT(DISTINCT user_id)
FROM branch_cashiers
JOIN users ON branch_cashiers.user_id = users.id
WHERE users.role = 'customer'`
args := []interface{}{}
argPos := 1
// Add filters if provided
if filter.CompanyID.Valid {
query += fmt.Sprintf(" AND branch_id IN (SELECT id FROM branches WHERE company_id = $%d)", argPos)
args = append(args, filter.CompanyID.Value)
argPos++
}
if filter.BranchID.Valid {
query += fmt.Sprintf(" AND branch_id = $%d", argPos)
args = append(args, filter.BranchID.Value)
argPos++
}
if filter.StartTime.Valid {
query += fmt.Sprintf(" AND users.created_at >= $%d", argPos)
args = append(args, filter.StartTime.Value)
argPos++
}
if filter.EndTime.Valid {
query += fmt.Sprintf(" AND users.created_at <= $%d", argPos)
args = append(args, filter.EndTime.Value)
argPos++
}
query += " GROUP BY branch_id"
rows, err := s.conn.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query branch customer counts: %w", err)
}
defer rows.Close()
counts := make(map[int64]int64)
for rows.Next() {
var branchID int64
var count int64
if err := rows.Scan(&branchID, &count); err != nil {
return nil, fmt.Errorf("failed to scan branch customer count: %w", err)
}
counts[branchID] = count
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
return counts, nil
}
func (s *Store) GetCustomerPreferences(ctx context.Context, filter domain.ReportFilter) (map[int64]domain.CustomerPreferences, error) {
query := `WITH customer_sports AS (
SELECT
b.user_id,
bo.sport_id,
COUNT(*) as bet_count,
ROW_NUMBER() OVER (PARTITION BY b.user_id ORDER BY COUNT(*) DESC) as sport_rank
FROM bets b
JOIN bet_outcomes bo ON b.id = bo.bet_id
WHERE b.user_id IS NOT NULL AND bo.sport_id IS NOT NULL
),
customer_markets AS (
SELECT
b.user_id,
bo.market_name,
COUNT(*) as bet_count,
ROW_NUMBER() OVER (PARTITION BY b.user_id ORDER BY COUNT(*) DESC) as market_rank
FROM bets b
JOIN bet_outcomes bo ON b.id = bo.bet_id
WHERE b.user_id IS NOT NULL AND bo.market_name IS NOT NULL
`
args := []interface{}{}
argPos := 1
// Add filters if provided
if filter.CompanyID.Valid {
query += fmt.Sprintf(" AND b.company_id = $%d", argPos)
args = append(args, filter.CompanyID.Value)
argPos++
}
if filter.BranchID.Valid {
query += fmt.Sprintf(" AND b.branch_id = $%d", argPos)
args = append(args, filter.BranchID.Value)
argPos++
}
if filter.UserID.Valid {
query += fmt.Sprintf(" AND b.user_id = $%d", argPos)
args = append(args, filter.UserID.Value)
argPos++
}
if filter.StartTime.Valid {
query += fmt.Sprintf(" AND b.created_at >= $%d", argPos)
args = append(args, filter.StartTime.Value)
argPos++
}
if filter.EndTime.Valid {
query += fmt.Sprintf(" AND b.created_at <= $%d", argPos)
args = append(args, filter.EndTime.Value)
argPos++
}
query += ` GROUP BY b.user_id, bo.sport_id
),
favorite_sports AS (
SELECT user_id, sport_id
FROM customer_sports
WHERE sport_rank = 1
),
favorite_markets AS (
SELECT user_id, market_name
FROM customer_markets
WHERE market_rank = 1
)
SELECT
fs.user_id,
fs.sport_id as favorite_sport,
fm.market_name as favorite_market
FROM favorite_sports fs
LEFT JOIN favorite_markets fm ON fs.user_id = fm.user_id`
rows, err := s.conn.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query customer preferences: %w", err)
}
defer rows.Close()
preferences := make(map[int64]domain.CustomerPreferences)
for rows.Next() {
var userID int64
var pref domain.CustomerPreferences
if err := rows.Scan(&userID, &pref.FavoriteSport, &pref.FavoriteMarket); err != nil {
return nil, fmt.Errorf("failed to scan customer preference: %w", err)
}
preferences[userID] = pref
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
return preferences, nil
}

View File

@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"fmt"
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
@ -163,3 +164,64 @@ func (s *Store) UpdateWalletActive(ctx context.Context, id int64, isActive bool)
}) })
return err return err
} }
// GetBalanceSummary returns wallet balance summary
func (s *Store) GetBalanceSummary(ctx context.Context, filter domain.ReportFilter) (domain.BalanceSummary, error) {
var summary domain.BalanceSummary
query := `SELECT
COALESCE(SUM(balance), 0) as total_balance,
COALESCE(SUM(CASE WHEN is_active = true THEN balance ELSE 0 END), 0) as active_balance,
COALESCE(SUM(CASE WHEN is_active = false THEN balance ELSE 0 END), 0) as inactive_balance,
COALESCE(SUM(CASE WHEN is_bettable = true THEN balance ELSE 0 END), 0) as bettable_balance,
COALESCE(SUM(CASE WHEN is_bettable = false THEN balance ELSE 0 END), 0) as non_bettable_balance
FROM wallets`
args := []interface{}{}
argPos := 1
// Add filters if provided
if filter.CompanyID.Valid {
query += fmt.Sprintf(" WHERE user_id IN (SELECT id FROM users WHERE company_id = $%d)", argPos)
args = append(args, filter.CompanyID.Value)
argPos++
} else if filter.BranchID.Valid {
query += fmt.Sprintf(" WHERE user_id IN (SELECT user_id FROM branch_cashiers WHERE branch_id = $%d)", argPos)
args = append(args, filter.BranchID.Value)
argPos++
} else if filter.UserID.Valid {
query += fmt.Sprintf(" WHERE user_id = $%d", argPos)
args = append(args, filter.UserID.Value)
argPos++
}
if filter.StartTime.Valid {
query += fmt.Sprintf(" AND %screated_at >= $%d", func() string {
if len(args) == 0 {
return ""
}
return " "
}(), 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(
&summary.TotalBalance,
&summary.ActiveBalance,
&summary.InactiveBalance,
&summary.BettableBalance,
&summary.NonBettableBalance,
)
if err != nil {
return domain.BalanceSummary{}, fmt.Errorf("failed to get balance summary: %w", err)
}
return summary, nil
}

View File

@ -48,7 +48,7 @@ func (s *Service) Login(ctx context.Context, email, phone string, password strin
// If old refresh token is not revoked, revoke it // If old refresh token is not revoked, revoke it
if err == nil && !oldRefreshToken.Revoked { if err == nil && !oldRefreshToken.Revoked {
err = s.tokenStore.RevokeRefreshToken(ctx, oldRefreshToken.Token) err = s.tokenStore.RevokeRefreshToken(ctx, oldRefreshToken.Token)
if(err != nil) { if err != nil {
return LoginSuccess{}, err return LoginSuccess{}, err
} }
} }

View File

@ -2,6 +2,7 @@ package bet
import ( import (
"context" "context"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
) )
@ -20,4 +21,24 @@ type BetStore interface {
UpdateStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error UpdateStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error
UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error)
DeleteBet(ctx context.Context, id int64) error DeleteBet(ctx context.Context, id int64) error
GetBetSummary(ctx context.Context, filter domain.ReportFilter) (
totalStakes domain.Currency,
totalBets int64,
activeBets int64,
totalWins int64,
totalLosses int64,
winBalance domain.Currency,
err error,
)
GetBetStats(ctx context.Context, filter domain.ReportFilter) ([]domain.BetStat, error)
GetSportPopularity(ctx context.Context, filter domain.ReportFilter) (map[time.Time]string, error)
GetMarketPopularity(ctx context.Context, filter domain.ReportFilter) (map[time.Time]string, error)
GetExtremeValues(ctx context.Context, filter domain.ReportFilter) (map[time.Time]domain.ExtremeValues, error)
GetCustomerBetActivity(ctx context.Context, filter domain.ReportFilter) ([]domain.CustomerBetActivity, error)
GetCustomerPreferences(ctx context.Context, filter domain.ReportFilter) (map[int64]domain.CustomerPreferences, error)
GetBranchBetActivity(ctx context.Context, filter domain.ReportFilter) ([]domain.BranchBetActivity, error)
GetSportBetActivity(ctx context.Context, filter domain.ReportFilter) ([]domain.SportBetActivity, error)
GetSportDetails(ctx context.Context, filter domain.ReportFilter) (map[string]string, error)
GetSportMarketPopularity(ctx context.Context, filter domain.ReportFilter) (map[string]string, error)
} }

View File

@ -17,6 +17,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
"go.uber.org/zap"
) )
var ( var (
@ -33,9 +34,10 @@ type Service struct {
walletSvc wallet.Service walletSvc wallet.Service
branchSvc branch.Service branchSvc branch.Service
logger *slog.Logger logger *slog.Logger
mongoLogger *zap.Logger
} }
func NewService(betStore BetStore, eventSvc event.Service, prematchSvc odds.ServiceImpl, walletSvc wallet.Service, branchSvc branch.Service, logger *slog.Logger) *Service { func NewService(betStore BetStore, eventSvc event.Service, prematchSvc odds.ServiceImpl, walletSvc wallet.Service, branchSvc branch.Service, logger *slog.Logger, mongoLogger *zap.Logger) *Service {
return &Service{ return &Service{
betStore: betStore, betStore: betStore,
eventSvc: eventSvc, eventSvc: eventSvc,
@ -43,6 +45,7 @@ func NewService(betStore BetStore, eventSvc event.Service, prematchSvc odds.Serv
walletSvc: walletSvc, walletSvc: walletSvc,
branchSvc: branchSvc, branchSvc: branchSvc,
logger: logger, logger: logger,
mongoLogger: mongoLogger,
} }
} }
@ -58,37 +61,56 @@ func (s *Service) GenerateCashoutID() (string, error) {
const length int = 13 const length int = 13
charLen := big.NewInt(int64(len(chars))) charLen := big.NewInt(int64(len(chars)))
result := make([]byte, length) result := make([]byte, length)
for i := 0; i < length; i++ { for i := 0; i < length; i++ {
index, err := rand.Int(rand.Reader, charLen) index, err := rand.Int(rand.Reader, charLen)
if err != nil { if err != nil {
s.mongoLogger.Error("failed to generate random index for cashout ID",
zap.Int("position", i),
zap.Error(err),
)
return "", err return "", err
} }
result[i] = chars[index.Int64()] result[i] = chars[index.Int64()]
} }
return string(result), nil return string(result), nil
} }
func (s *Service) GenerateBetOutcome(ctx context.Context, eventID int64, marketID int64, oddID int64) (domain.CreateBetOutcome, error) { func (s *Service) GenerateBetOutcome(ctx context.Context, eventID int64, marketID int64, oddID int64) (domain.CreateBetOutcome, error) {
// TODO: Change this when you refactor the database code
eventIDStr := strconv.FormatInt(eventID, 10) eventIDStr := strconv.FormatInt(eventID, 10)
marketIDStr := strconv.FormatInt(marketID, 10) marketIDStr := strconv.FormatInt(marketID, 10)
oddIDStr := strconv.FormatInt(oddID, 10) oddIDStr := strconv.FormatInt(oddID, 10)
event, err := s.eventSvc.GetUpcomingEventByID(ctx, eventIDStr) event, err := s.eventSvc.GetUpcomingEventByID(ctx, eventIDStr)
if err != nil { if err != nil {
s.mongoLogger.Error("failed to fetch upcoming event by ID",
zap.Int64("event_id", eventID),
zap.Error(err),
)
return domain.CreateBetOutcome{}, ErrEventHasBeenRemoved return domain.CreateBetOutcome{}, ErrEventHasBeenRemoved
} }
currentTime := time.Now() currentTime := time.Now()
if event.StartTime.Before(currentTime) { if event.StartTime.Before(currentTime) {
s.mongoLogger.Error("event has already started",
zap.Int64("event_id", eventID),
zap.Time("event_start_time", event.StartTime),
zap.Time("current_time", currentTime),
)
return domain.CreateBetOutcome{}, ErrEventHasNotEnded return domain.CreateBetOutcome{}, ErrEventHasNotEnded
} }
odds, err := s.prematchSvc.GetRawOddsByMarketID(ctx, marketIDStr, eventIDStr) odds, err := s.prematchSvc.GetRawOddsByMarketID(ctx, marketIDStr, eventIDStr)
if err != nil { if err != nil {
s.mongoLogger.Error("failed to get raw odds by market ID",
zap.Int64("event_id", eventID),
zap.Int64("market_id", marketID),
zap.Error(err),
)
return domain.CreateBetOutcome{}, err return domain.CreateBetOutcome{}, err
} }
type rawOddType struct { type rawOddType struct {
ID string ID string
Name string Name string
@ -98,29 +120,51 @@ func (s *Service) GenerateBetOutcome(ctx context.Context, eventID int64, marketI
} }
var selectedOdd rawOddType var selectedOdd rawOddType
var isOddFound bool = false var isOddFound bool
for _, raw := range odds.RawOdds { for _, raw := range odds.RawOdds {
var rawOdd rawOddType var rawOdd rawOddType
rawBytes, err := json.Marshal(raw) rawBytes, err := json.Marshal(raw)
if err != nil {
s.mongoLogger.Error("failed to marshal raw odd",
zap.Any("raw", raw),
zap.Error(err),
)
continue
}
err = json.Unmarshal(rawBytes, &rawOdd) err = json.Unmarshal(rawBytes, &rawOdd)
if err != nil { if err != nil {
fmt.Printf("Failed to unmarshal raw odd %v", err) s.mongoLogger.Error("failed to unmarshal raw odd",
zap.ByteString("raw_bytes", rawBytes),
zap.Error(err),
)
continue continue
} }
if rawOdd.ID == oddIDStr { if rawOdd.ID == oddIDStr {
selectedOdd = rawOdd selectedOdd = rawOdd
isOddFound = true isOddFound = true
break
} }
} }
if !isOddFound { if !isOddFound {
s.mongoLogger.Error("odd ID not found in raw odds",
zap.Int64("odd_id", oddID),
zap.Int64("market_id", marketID),
zap.Int64("event_id", eventID),
)
return domain.CreateBetOutcome{}, ErrRawOddInvalid return domain.CreateBetOutcome{}, ErrRawOddInvalid
} }
parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32)
if err != nil { if err != nil {
s.mongoLogger.Error("failed to parse selected odd value",
zap.String("odd", selectedOdd.Odds),
zap.Int64("odd_id", oddID),
zap.Error(err),
)
return domain.CreateBetOutcome{}, err return domain.CreateBetOutcome{}, err
} }
newOutcome := domain.CreateBetOutcome{ newOutcome := domain.CreateBetOutcome{
EventID: eventID, EventID: eventID,
OddID: oddID, OddID: oddID,
@ -137,13 +181,14 @@ func (s *Service) GenerateBetOutcome(ctx context.Context, eventID int64, marketI
} }
return newOutcome, nil return newOutcome, nil
} }
func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID int64, role domain.Role) (domain.CreateBetRes, error) { func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID int64, role domain.Role) (domain.CreateBetRes, error) {
// You can move the loop over req.Outcomes and all the business logic here.
if len(req.Outcomes) > 30 { if len(req.Outcomes) > 30 {
s.mongoLogger.Error("too many outcomes",
zap.Int("count", len(req.Outcomes)),
zap.Int64("user_id", userID),
)
return domain.CreateBetRes{}, ErrOutcomeLimit return domain.CreateBetRes{}, ErrOutcomeLimit
} }
@ -153,17 +198,25 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID
for _, outcomeReq := range req.Outcomes { for _, outcomeReq := range req.Outcomes {
newOutcome, err := s.GenerateBetOutcome(ctx, outcomeReq.EventID, outcomeReq.MarketID, outcomeReq.OddID) newOutcome, err := s.GenerateBetOutcome(ctx, outcomeReq.EventID, outcomeReq.MarketID, outcomeReq.OddID)
if err != nil { if err != nil {
s.mongoLogger.Error("failed to generate outcome",
zap.Int64("event_id", outcomeReq.EventID),
zap.Int64("market_id", outcomeReq.MarketID),
zap.Int64("odd_id", outcomeReq.OddID),
zap.Int64("user_id", userID),
zap.Error(err),
)
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
totalOdds = totalOdds * float32(newOutcome.Odd) totalOdds *= float32(newOutcome.Odd)
outcomes = append(outcomes, newOutcome) outcomes = append(outcomes, newOutcome)
} }
// Handle role-specific logic and wallet deduction if needed.
var cashoutID string
cashoutID, err := s.GenerateCashoutID() cashoutID, err := s.GenerateCashoutID()
if err != nil { if err != nil {
s.mongoLogger.Error("failed to generate cashout ID",
zap.Int64("user_id", userID),
zap.Error(err),
)
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
@ -175,106 +228,117 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID
PhoneNumber: req.PhoneNumber, PhoneNumber: req.PhoneNumber,
CashoutID: cashoutID, CashoutID: cashoutID,
} }
switch role { switch role {
case domain.RoleCashier: case domain.RoleCashier:
branch, err := s.branchSvc.GetBranchByCashier(ctx, userID) branch, err := s.branchSvc.GetBranchByCashier(ctx, userID)
if err != nil { if err != nil {
s.mongoLogger.Error("failed to get branch by cashier",
zap.Int64("user_id", userID),
zap.Error(err),
)
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
// Deduct from wallet:
// TODO: Make this percentage come from the company deductedAmount := req.Amount / 10
var deductedAmount = req.Amount / 10
err = s.walletSvc.DeductFromWallet(ctx, branch.WalletID, domain.ToCurrency(deductedAmount)) err = s.walletSvc.DeductFromWallet(ctx, branch.WalletID, domain.ToCurrency(deductedAmount))
if err != nil { if err != nil {
s.mongoLogger.Error("failed to deduct from wallet",
zap.Int64("wallet_id", branch.WalletID),
zap.Float32("amount", deductedAmount),
zap.Error(err),
)
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
newBet.BranchID = domain.ValidInt64{
Value: branch.ID,
Valid: true,
}
newBet.CompanyID = domain.ValidInt64{ newBet.BranchID = domain.ValidInt64{Value: branch.ID, Valid: true}
Value: branch.CompanyID, newBet.CompanyID = domain.ValidInt64{Value: branch.CompanyID, Valid: true}
Valid: true, newBet.UserID = domain.ValidInt64{Value: userID, Valid: true}
}
newBet.UserID = domain.ValidInt64{
Value: userID,
Valid: true,
}
newBet.IsShopBet = true newBet.IsShopBet = true
// bet, err = s.betStore.CreateBet(ctx)
case domain.RoleBranchManager, domain.RoleAdmin, domain.RoleSuperAdmin: case domain.RoleBranchManager, domain.RoleAdmin, domain.RoleSuperAdmin:
// TODO: restrict the Branch ID of Admin and Branch Manager to only the branches within their own company
// If a non cashier wants to create a bet, they will need to provide the Branch ID
if req.BranchID == nil { if req.BranchID == nil {
s.mongoLogger.Error("branch ID required for admin/manager",
zap.Int64("user_id", userID),
)
return domain.CreateBetRes{}, ErrBranchIDRequired return domain.CreateBetRes{}, ErrBranchIDRequired
} }
branch, err := s.branchSvc.GetBranchByID(ctx, *req.BranchID) branch, err := s.branchSvc.GetBranchByID(ctx, *req.BranchID)
if err != nil { if err != nil {
s.mongoLogger.Error("failed to get branch by ID",
zap.Int64("branch_id", *req.BranchID),
zap.Error(err),
)
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
// Deduct from wallet:
// TODO: Make this percentage come from the company deductedAmount := req.Amount / 10
var deductedAmount = req.Amount / 10
err = s.walletSvc.DeductFromWallet(ctx, branch.WalletID, domain.ToCurrency(deductedAmount)) err = s.walletSvc.DeductFromWallet(ctx, branch.WalletID, domain.ToCurrency(deductedAmount))
if err != nil { if err != nil {
s.mongoLogger.Error("wallet deduction failed",
zap.Int64("wallet_id", branch.WalletID),
zap.Float32("amount", deductedAmount),
zap.Error(err),
)
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
newBet.BranchID = domain.ValidInt64{ newBet.BranchID = domain.ValidInt64{Value: branch.ID, Valid: true}
Value: branch.ID, newBet.CompanyID = domain.ValidInt64{Value: branch.CompanyID, Valid: true}
Valid: true, newBet.UserID = domain.ValidInt64{Value: userID, Valid: true}
}
newBet.CompanyID = domain.ValidInt64{
Value: branch.CompanyID,
Valid: true,
}
newBet.UserID = domain.ValidInt64{
Value: userID,
Valid: true,
}
newBet.IsShopBet = true newBet.IsShopBet = true
case domain.RoleCustomer: case domain.RoleCustomer:
// Get User Wallet wallets, err := s.walletSvc.GetWalletsByUser(ctx, userID)
wallet, err := s.walletSvc.GetWalletsByUser(ctx, userID)
if err != nil { if err != nil {
s.mongoLogger.Error("failed to get customer wallets",
zap.Int64("user_id", userID),
zap.Error(err),
)
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
userWallet := wallet[0] userWallet := wallets[0]
err = s.walletSvc.DeductFromWallet(ctx, userWallet.ID, domain.ToCurrency(req.Amount)) err = s.walletSvc.DeductFromWallet(ctx, userWallet.ID, domain.ToCurrency(req.Amount))
if err != nil { if err != nil {
s.mongoLogger.Error("wallet deduction failed for customer",
zap.Int64("wallet_id", userWallet.ID),
zap.Float32("amount", req.Amount),
zap.Error(err),
)
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
newBet.UserID = domain.ValidInt64{ newBet.UserID = domain.ValidInt64{Value: userID, Valid: true}
Value: userID,
Valid: true,
}
newBet.IsShopBet = false newBet.IsShopBet = false
default: default:
s.mongoLogger.Error("unknown role type",
zap.String("role", string(role)),
zap.Int64("user_id", userID),
)
return domain.CreateBetRes{}, fmt.Errorf("Unknown Role Type") return domain.CreateBetRes{}, fmt.Errorf("Unknown Role Type")
} }
bet, err := s.CreateBet(ctx, newBet) bet, err := s.CreateBet(ctx, newBet)
if err != nil { if err != nil {
s.mongoLogger.Error("failed to create bet",
zap.Int64("user_id", userID),
zap.Error(err),
)
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
// Associate outcomes with the bet.
for i := range outcomes { for i := range outcomes {
outcomes[i].BetID = bet.ID outcomes[i].BetID = bet.ID
} }
rows, err := s.betStore.CreateBetOutcome(ctx, outcomes) rows, err := s.betStore.CreateBetOutcome(ctx, outcomes)
if err != nil { if err != nil {
s.mongoLogger.Error("failed to create bet outcomes",
zap.Int64("bet_id", bet.ID),
zap.Error(err),
)
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
@ -289,14 +353,24 @@ func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID string,
var totalOdds float32 = 1 var totalOdds float32 = 1
markets, err := s.prematchSvc.GetPrematchOddsByUpcomingID(ctx, eventID) markets, err := s.prematchSvc.GetPrematchOddsByUpcomingID(ctx, eventID)
if err != nil { if err != nil {
s.logger.Error("failed to get odds for event", "event id", eventID, "error", err) s.logger.Error("failed to get odds for event", "event id", eventID, "error", err)
s.mongoLogger.Error("failed to get odds for event",
zap.String("eventID", eventID),
zap.Int32("sportID", sportID),
zap.String("homeTeam", HomeTeam),
zap.String("awayTeam", AwayTeam),
zap.Error(err))
return nil, 0, err return nil, 0, err
} }
if len(markets) == 0 { if len(markets) == 0 {
s.logger.Error("empty odds for event", "event id", eventID) s.logger.Error("empty odds for event", "event id", eventID)
s.mongoLogger.Warn("empty odds for event",
zap.String("eventID", eventID),
zap.Int32("sportID", sportID),
zap.String("homeTeam", HomeTeam),
zap.String("awayTeam", AwayTeam))
return nil, 0, fmt.Errorf("empty odds or event %v", eventID) return nil, 0, fmt.Errorf("empty odds or event %v", eventID)
} }
@ -325,35 +399,55 @@ func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID string,
err = json.Unmarshal(rawBytes, &selectedOdd) err = json.Unmarshal(rawBytes, &selectedOdd)
if err != nil { if err != nil {
fmt.Printf("Failed to unmarshal raw odd %v", err) s.logger.Error("Failed to unmarshal raw odd", "error", err)
s.mongoLogger.Warn("Failed to unmarshal raw odd",
zap.String("eventID", eventID),
zap.Int32("sportID", sportID),
zap.Error(err))
continue continue
} }
parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32)
if err != nil { if err != nil {
s.logger.Error("Failed to parse odd", "error", err) s.logger.Error("Failed to parse odd", "error", err)
s.mongoLogger.Warn("Failed to parse odd",
zap.String("eventID", eventID),
zap.String("oddValue", selectedOdd.Odds),
zap.Error(err))
continue continue
} }
eventID, err := strconv.ParseInt(eventID, 10, 64)
eventIDInt, err := strconv.ParseInt(eventID, 10, 64)
if err != nil { if err != nil {
s.logger.Error("Failed to get event id", "error", err) s.logger.Error("Failed to parse eventID", "error", err)
s.mongoLogger.Warn("Failed to parse eventID",
zap.String("eventID", eventID),
zap.Error(err))
continue continue
} }
oddID, err := strconv.ParseInt(selectedOdd.ID, 10, 64) oddID, err := strconv.ParseInt(selectedOdd.ID, 10, 64)
if err != nil { if err != nil {
s.logger.Error("Failed to get odd id", "error", err) s.logger.Error("Failed to parse oddID", "error", err)
s.mongoLogger.Warn("Failed to parse oddID",
zap.String("oddID", selectedOdd.ID),
zap.Error(err))
continue continue
} }
marketID, err := strconv.ParseInt(market.MarketID, 10, 64) marketID, err := strconv.ParseInt(market.MarketID, 10, 64)
if err != nil { if err != nil {
s.logger.Error("Failed to get odd id", "error", err) s.logger.Error("Failed to parse marketID", "error", err)
s.mongoLogger.Warn("Failed to parse marketID",
zap.String("marketID", market.MarketID),
zap.Error(err))
continue continue
} }
marketName := market.MarketName marketName := market.MarketName
newOdds = append(newOdds, domain.CreateBetOutcome{ newOdds = append(newOdds, domain.CreateBetOutcome{
EventID: eventID, EventID: eventIDInt,
OddID: oddID, OddID: oddID,
MarketID: marketID, MarketID: marketID,
SportID: int64(sportID), SportID: int64(sportID),
@ -367,15 +461,27 @@ func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID string,
Expires: StartTime, Expires: StartTime,
}) })
totalOdds = totalOdds * float32(parsedOdd) totalOdds *= float32(parsedOdd)
} }
if len(newOdds) == 0 { if len(newOdds) == 0 {
s.logger.Error("Bet Outcomes is empty for market", "selectedMarket", selectedMarkets[0].MarketName) s.logger.Error("Bet Outcomes is empty for market", "selectedMarkets", len(selectedMarkets))
s.mongoLogger.Error("Bet Outcomes is empty for market",
zap.String("eventID", eventID),
zap.Int32("sportID", sportID),
zap.String("homeTeam", HomeTeam),
zap.String("awayTeam", AwayTeam),
zap.Int("selectedMarkets", len(selectedMarkets)))
return nil, 0, ErrGenerateRandomOutcome return nil, 0, ErrGenerateRandomOutcome
} }
// ✅ Final success log (optional)
s.mongoLogger.Info("Random bet outcomes generated successfully",
zap.String("eventID", eventID),
zap.Int32("sportID", sportID),
zap.Int("numOutcomes", len(newOdds)),
zap.Float32("totalOdds", totalOdds))
return newOdds, totalOdds, nil return newOdds, totalOdds, nil
} }
@ -392,10 +498,17 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, le
}) })
if err != nil { if err != nil {
s.mongoLogger.Error("failed to get paginated upcoming events",
zap.Int64("userID", userID),
zap.Int64("branchID", branchID),
zap.Error(err))
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
if len(events) == 0 { if len(events) == 0 {
s.mongoLogger.Warn("no events available for random bet",
zap.Int64("userID", userID),
zap.Int64("branchID", branchID))
return domain.CreateBetRes{}, ErrNoEventsAvailable return domain.CreateBetRes{}, ErrNoEventsAvailable
} }
@ -422,6 +535,11 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, le
if err != nil { if err != nil {
s.logger.Error("failed to generate random bet outcome", "event id", event.ID, "error", err) s.logger.Error("failed to generate random bet outcome", "event id", event.ID, "error", err)
s.mongoLogger.Error("failed to generate random bet outcome",
zap.Int64("userID", userID),
zap.Int64("branchID", branchID),
zap.String("eventID", event.ID),
zap.String("error", fmt.Sprintf("%v", err)))
continue continue
} }
@ -431,6 +549,9 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, le
} }
if len(randomOdds) == 0 { if len(randomOdds) == 0 {
s.logger.Error("Failed to generate random any outcomes for all events") s.logger.Error("Failed to generate random any outcomes for all events")
s.mongoLogger.Error("Failed to generate random any outcomes for all events",
zap.Int64("userID", userID),
zap.Int64("branchID", branchID))
return domain.CreateBetRes{}, ErrGenerateRandomOutcome return domain.CreateBetRes{}, ErrGenerateRandomOutcome
} }
@ -440,6 +561,9 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, le
cashoutID, err = s.GenerateCashoutID() cashoutID, err = s.GenerateCashoutID()
if err != nil { if err != nil {
s.mongoLogger.Error("Failed to generate cash out ID",
zap.Int64("userID", userID),
zap.Int64("branchID", branchID))
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
@ -457,6 +581,10 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, le
bet, err := s.CreateBet(ctx, newBet) bet, err := s.CreateBet(ctx, newBet)
if err != nil { if err != nil {
s.mongoLogger.Error("Failed to create a new random bet",
zap.Int64("userID", userID),
zap.Int64("branchID", branchID),
zap.String("bet", fmt.Sprintf("%+v", newBet)))
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
@ -466,11 +594,19 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, le
rows, err := s.betStore.CreateBetOutcome(ctx, randomOdds) rows, err := s.betStore.CreateBetOutcome(ctx, randomOdds)
if err != nil { if err != nil {
s.mongoLogger.Error("Failed to create a new random bet outcome",
zap.Int64("userID", userID),
zap.Int64("branchID", branchID),
zap.String("randomOdds", fmt.Sprintf("%+v", randomOdds)))
return domain.CreateBetRes{}, err return domain.CreateBetRes{}, err
} }
res := domain.ConvertCreateBet(bet, rows) res := domain.ConvertCreateBet(bet, rows)
s.mongoLogger.Info("Random bets placed successfully",
zap.Int64("userID", userID),
zap.Int64("branchID", branchID),
zap.String("response", fmt.Sprintf("%+v", res)))
return res, nil return res, nil
} }
@ -505,10 +641,12 @@ func (s *Service) UpdateCashOut(ctx context.Context, id int64, cashedOut bool) e
} }
func (s *Service) UpdateStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error { func (s *Service) UpdateStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error {
bet, err := s.GetBetByID(ctx, id) bet, err := s.GetBetByID(ctx, id)
if err != nil { if err != nil {
s.logger.Error("Failed to update bet status. Invalid bet id") s.mongoLogger.Error("failed to update bet status: invalid bet ID",
zap.Int64("bet_id", id),
zap.Error(err),
)
return err return err
} }
@ -521,22 +659,30 @@ func (s *Service) UpdateStatus(ctx context.Context, id int64, status domain.Outc
customerWallet, err := s.walletSvc.GetCustomerWallet(ctx, id) customerWallet, err := s.walletSvc.GetCustomerWallet(ctx, id)
if err != nil { if err != nil {
s.logger.Error("Failed to update bet status. Invalid customer wallet id") s.mongoLogger.Error("failed to get customer wallet",
zap.Int64("bet_id", id),
zap.Error(err),
)
return err return err
} }
var amount domain.Currency var amount domain.Currency
if status == domain.OUTCOME_STATUS_WIN { switch status {
case domain.OUTCOME_STATUS_WIN:
amount = domain.CalculateWinnings(bet.Amount, bet.TotalOdds) amount = domain.CalculateWinnings(bet.Amount, bet.TotalOdds)
} else if status == domain.OUTCOME_STATUS_HALF { case domain.OUTCOME_STATUS_HALF:
amount = (domain.CalculateWinnings(bet.Amount, bet.TotalOdds)) / 2 amount = domain.CalculateWinnings(bet.Amount, bet.TotalOdds) / 2
} else { default:
amount = bet.Amount amount = bet.Amount
} }
err = s.walletSvc.AddToWallet(ctx, customerWallet.RegularID, amount)
err = s.walletSvc.AddToWallet(ctx, customerWallet.RegularID, amount)
if err != nil { if err != nil {
s.logger.Error("Failed to update bet status. Failed to update user wallet") s.mongoLogger.Error("failed to add winnings to wallet",
zap.Int64("wallet_id", customerWallet.RegularID),
zap.Float32("amount", float32(amount)),
zap.Error(err),
)
return err return err
} }
@ -546,92 +692,89 @@ func (s *Service) UpdateStatus(ctx context.Context, id int64, status domain.Outc
func (s *Service) CheckBetOutcomeForBet(ctx context.Context, betID int64) (domain.OutcomeStatus, error) { func (s *Service) CheckBetOutcomeForBet(ctx context.Context, betID int64) (domain.OutcomeStatus, error) {
betOutcomes, err := s.betStore.GetBetOutcomeByBetID(ctx, betID) betOutcomes, err := s.betStore.GetBetOutcomeByBetID(ctx, betID)
if err != nil { if err != nil {
s.mongoLogger.Error("failed to get bet outcomes",
zap.Int64("bet_id", betID),
zap.Error(err),
)
return domain.OUTCOME_STATUS_PENDING, err return domain.OUTCOME_STATUS_PENDING, err
} }
status := domain.OUTCOME_STATUS_PENDING status := domain.OUTCOME_STATUS_PENDING
for _, betOutcome := range betOutcomes { for _, betOutcome := range betOutcomes {
// If any of the bet outcomes are pending return
if betOutcome.Status == domain.OUTCOME_STATUS_PENDING { if betOutcome.Status == domain.OUTCOME_STATUS_PENDING {
s.mongoLogger.Info("outcome still pending",
zap.Int64("bet_id", betID),
)
return domain.OUTCOME_STATUS_PENDING, ErrOutcomesNotCompleted return domain.OUTCOME_STATUS_PENDING, ErrOutcomesNotCompleted
} }
if betOutcome.Status == domain.OUTCOME_STATUS_ERROR { if betOutcome.Status == domain.OUTCOME_STATUS_ERROR {
s.mongoLogger.Info("outcome contains error",
zap.Int64("bet_id", betID),
)
return domain.OUTCOME_STATUS_ERROR, nil return domain.OUTCOME_STATUS_ERROR, nil
} }
// The bet status can only be updated if its not lost or error
// If all the bet outcomes are a win, then set the bet status to win
// If even one of the bet outcomes is a loss then set the bet status to loss
// If even one of the bet outcomes is an error, then set the bet status to error
switch status { switch status {
case domain.OUTCOME_STATUS_PENDING: case domain.OUTCOME_STATUS_PENDING:
status = betOutcome.Status status = betOutcome.Status
case domain.OUTCOME_STATUS_WIN: case domain.OUTCOME_STATUS_WIN:
if betOutcome.Status == domain.OUTCOME_STATUS_LOSS { switch betOutcome.Status {
case domain.OUTCOME_STATUS_LOSS:
status = domain.OUTCOME_STATUS_LOSS status = domain.OUTCOME_STATUS_LOSS
} else if betOutcome.Status == domain.OUTCOME_STATUS_HALF { case domain.OUTCOME_STATUS_HALF:
status = domain.OUTCOME_STATUS_HALF status = domain.OUTCOME_STATUS_HALF
} else if betOutcome.Status == domain.OUTCOME_STATUS_WIN { case domain.OUTCOME_STATUS_VOID:
status = domain.OUTCOME_STATUS_WIN
} else if betOutcome.Status == domain.OUTCOME_STATUS_VOID {
status = domain.OUTCOME_STATUS_VOID status = domain.OUTCOME_STATUS_VOID
} else { case domain.OUTCOME_STATUS_WIN:
// remain win
default:
status = domain.OUTCOME_STATUS_ERROR status = domain.OUTCOME_STATUS_ERROR
} }
case domain.OUTCOME_STATUS_LOSS: case domain.OUTCOME_STATUS_LOSS:
if betOutcome.Status == domain.OUTCOME_STATUS_LOSS { // stay as LOSS regardless of others
status = domain.OUTCOME_STATUS_LOSS
} else if betOutcome.Status == domain.OUTCOME_STATUS_HALF {
status = domain.OUTCOME_STATUS_LOSS
} else if betOutcome.Status == domain.OUTCOME_STATUS_WIN {
status = domain.OUTCOME_STATUS_LOSS
} else if betOutcome.Status == domain.OUTCOME_STATUS_VOID {
status = domain.OUTCOME_STATUS_LOSS
} else {
status = domain.OUTCOME_STATUS_ERROR
}
case domain.OUTCOME_STATUS_VOID: case domain.OUTCOME_STATUS_VOID:
if betOutcome.Status == domain.OUTCOME_STATUS_VOID || switch betOutcome.Status {
betOutcome.Status == domain.OUTCOME_STATUS_WIN || case domain.OUTCOME_STATUS_LOSS:
betOutcome.Status == domain.OUTCOME_STATUS_HALF {
status = domain.OUTCOME_STATUS_VOID
} else if betOutcome.Status == domain.OUTCOME_STATUS_LOSS {
status = domain.OUTCOME_STATUS_LOSS status = domain.OUTCOME_STATUS_LOSS
case domain.OUTCOME_STATUS_WIN, domain.OUTCOME_STATUS_HALF, domain.OUTCOME_STATUS_VOID:
} else { // remain VOID
default:
status = domain.OUTCOME_STATUS_ERROR status = domain.OUTCOME_STATUS_ERROR
} }
case domain.OUTCOME_STATUS_HALF: case domain.OUTCOME_STATUS_HALF:
if betOutcome.Status == domain.OUTCOME_STATUS_HALF || switch betOutcome.Status {
betOutcome.Status == domain.OUTCOME_STATUS_WIN { case domain.OUTCOME_STATUS_LOSS:
status = domain.OUTCOME_STATUS_HALF
} else if betOutcome.Status == domain.OUTCOME_STATUS_LOSS {
status = domain.OUTCOME_STATUS_LOSS status = domain.OUTCOME_STATUS_LOSS
} else if betOutcome.Status == domain.OUTCOME_STATUS_VOID { case domain.OUTCOME_STATUS_VOID:
status = domain.OUTCOME_STATUS_VOID status = domain.OUTCOME_STATUS_VOID
} else { case domain.OUTCOME_STATUS_HALF, domain.OUTCOME_STATUS_WIN:
// remain HALF
default:
status = domain.OUTCOME_STATUS_ERROR status = domain.OUTCOME_STATUS_ERROR
} }
default: default:
// If the status is not pending, win, loss or error, then set the status to error
status = domain.OUTCOME_STATUS_ERROR status = domain.OUTCOME_STATUS_ERROR
} }
} }
if status == domain.OUTCOME_STATUS_PENDING || status == domain.OUTCOME_STATUS_ERROR { if status == domain.OUTCOME_STATUS_PENDING || status == domain.OUTCOME_STATUS_ERROR {
// If the status is pending or error, then we don't need to update the bet s.mongoLogger.Info("bet status not updated due to status",
s.logger.Info("bet not updated", "bet id", betID, "status", status) zap.Int64("bet_id", betID),
return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("Error when processing bet outcomes") zap.String("final_status", string(status)),
)
} }
return status, nil return status, nil
} }
func (s *Service) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error) { func (s *Service) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error) {
betOutcome, err := s.betStore.UpdateBetOutcomeStatus(ctx, id, status) betOutcome, err := s.betStore.UpdateBetOutcomeStatus(ctx, id, status)
if err != nil { if err != nil {
s.mongoLogger.Error("failed to update bet outcome status",
zap.Int64("betID", id),
zap.Error(err),
)
return domain.BetOutcome{}, err return domain.BetOutcome{}, err
} }

View File

@ -23,4 +23,10 @@ type BranchStore interface {
CreateBranchCashier(ctx context.Context, branchID int64, userID int64) error CreateBranchCashier(ctx context.Context, branchID int64, userID int64) error
GetBranchByCashier(ctx context.Context, userID int64) (domain.Branch, error) GetBranchByCashier(ctx context.Context, userID int64) (domain.Branch, error)
DeleteBranchCashier(ctx context.Context, userID int64) error DeleteBranchCashier(ctx context.Context, userID int64) error
GetBranchCounts(ctx context.Context, filter domain.ReportFilter) (total, active int64, err error)
GetBranchDetails(ctx context.Context, filter domain.ReportFilter) (map[int64]domain.BranchDetail, error)
GetAllCompaniesBranch(ctx context.Context) ([]domain.Company, error)
GetBranchesByCompany(ctx context.Context, companyID int64) ([]domain.Branch, error)
} }

View File

@ -70,3 +70,11 @@ func (s *Service) DeleteBranchOperation(ctx context.Context, branchID int64, ope
func (s *Service) DeleteBranchCashier(ctx context.Context, userID int64) error { func (s *Service) DeleteBranchCashier(ctx context.Context, userID int64) error {
return s.branchStore.DeleteBranchCashier(ctx, userID) return s.branchStore.DeleteBranchCashier(ctx, userID)
} }
func (s *Service) GetAllCompaniesBranch(ctx context.Context) ([]domain.Company, error) {
return s.branchStore.GetAllCompaniesBranch(ctx)
}
func (s *Service) GetBranchesByCompany(ctx context.Context, companyID int64) ([]domain.Branch, error) {
return s.branchStore.GetBranchesByCompany(ctx, companyID)
}

View File

@ -63,13 +63,16 @@ func (c *Client) IssuePayment(ctx context.Context, payload domain.ChapaTransferP
// service/chapa_service.go // service/chapa_service.go
func (c *Client) InitPayment(ctx context.Context, req domain.InitPaymentRequest) (string, error) { func (c *Client) InitPayment(ctx context.Context, req domain.InitPaymentRequest) (string, error) {
fmt.Println("\n\nInit payment request: ", req)
payloadBytes, err := json.Marshal(req) payloadBytes, err := json.Marshal(req)
if err != nil { if err != nil {
fmt.Println("\n\nWe are here")
return "", fmt.Errorf("failed to serialize payload: %w", err) return "", fmt.Errorf("failed to serialize payload: %w", err)
} }
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/transaction/initialize", bytes.NewBuffer(payloadBytes)) httpReq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/transaction/initialize", bytes.NewBuffer(payloadBytes))
if err != nil { if err != nil {
fmt.Println("\n\nWe are here 2")
return "", fmt.Errorf("failed to create HTTP request: %w", err) return "", fmt.Errorf("failed to create HTTP request: %w", err)
} }
@ -78,12 +81,14 @@ func (c *Client) InitPayment(ctx context.Context, req domain.InitPaymentRequest)
resp, err := c.HTTPClient.Do(httpReq) resp, err := c.HTTPClient.Do(httpReq)
if err != nil { if err != nil {
fmt.Println("\n\nWe are here 3")
return "", fmt.Errorf("chapa HTTP request failed: %w", err) return "", fmt.Errorf("chapa HTTP request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 { 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)) return "", fmt.Errorf("chapa error: status %d, body: %s", resp.StatusCode, string(body))
} }
@ -93,6 +98,8 @@ func (c *Client) InitPayment(ctx context.Context, req domain.InitPaymentRequest)
} `json:"data"` } `json:"data"`
} }
fmt.Printf("\n\nInit payment response body: %v\n\n", response)
if err := json.Unmarshal(body, &response); err != nil { if err := json.Unmarshal(body, &response); err != nil {
return "", fmt.Errorf("failed to parse chapa response: %w", err) return "", fmt.Errorf("failed to parse chapa response: %w", err)
} }

View File

@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"time"
// "log/slog" // "log/slog"
"strconv" "strconv"
@ -116,6 +117,7 @@ func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.Chap
} }
return err return err
} }
if txn.Verified { if txn.Verified {
return nil // already processed return nil // already processed
} }
@ -170,29 +172,41 @@ func (s *Service) WithdrawUsingChapa(ctx context.Context, userID int64, req doma
return fmt.Errorf("user not found: %w", err) return fmt.Errorf("user not found: %w", err)
} }
branch, err := s.branchStore.GetBranchByID(ctx, req.BranchID) banks, err := s.GetSupportedBanks()
if err != nil { validBank := false
return err for _, bank := range banks {
} if strconv.FormatInt(bank.Id, 10) == req.BankCode {
validBank = true
wallets, err := s.walletStore.GetWalletsByUser(ctx, userID)
if err != nil {
return err
}
var targetWallet *domain.Wallet
for _, w := range wallets {
if w.ID == req.WalletID {
targetWallet = &w
break break
} }
} }
if !validBank {
if targetWallet == nil { return fmt.Errorf("invalid bank code")
return fmt.Errorf("no wallet found with the specified ID")
} }
if !targetWallet.IsWithdraw || !targetWallet.IsActive { // 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") return fmt.Errorf("wallet not eligible for withdrawal")
} }
@ -229,13 +243,13 @@ func (s *Service) WithdrawUsingChapa(ctx context.Context, userID int64, req doma
BeneficiaryName: req.BeneficiaryName, BeneficiaryName: req.BeneficiaryName,
PaymentOption: domain.PaymentOption(domain.BANK), PaymentOption: domain.PaymentOption(domain.BANK),
BranchID: req.BranchID, BranchID: req.BranchID,
BranchName: branch.Name, // BranchName: branch.Name,
BranchLocation: branch.Location, // BranchLocation: branch.Location,
// CashierID: user.ID, // CashierID: user.ID,
// CashierName: user.FullName, // CashierName: user.FullName,
FullName: user.FirstName + " " + user.LastName, FullName: user.FirstName + " " + user.LastName,
PhoneNumber: user.PhoneNumber, PhoneNumber: user.PhoneNumber,
CompanyID: branch.CompanyID, // CompanyID: branch.CompanyID,
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to create transaction: %w", err) return fmt.Errorf("failed to create transaction: %w", err)
@ -257,6 +271,10 @@ func (s *Service) DepositUsingChapa(ctx context.Context, userID int64, req domai
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
if req.Amount <= 0 {
return "", fmt.Errorf("amount must be positive")
}
user, err := s.userStore.GetUserByID(ctx, userID) user, err := s.userStore.GetUserByID(ctx, userID)
if err != nil { if err != nil {
return "", err return "", err
@ -269,20 +287,22 @@ func (s *Service) DepositUsingChapa(ctx context.Context, userID int64, req domai
txID := uuid.New().String() txID := uuid.New().String()
_, err = s.transactionStore.CreateTransaction(ctx, domain.CreateTransaction{ fmt.Printf("\n\nChapa deposit transaction created: %v%v\n\n", branch, user)
Amount: req.Amount,
Type: domain.TransactionType(domain.TRANSACTION_DEPOSIT), // _, err = s.transactionStore.CreateTransaction(ctx, domain.CreateTransaction{
ReferenceNumber: txID, // Amount: req.Amount,
BranchID: req.BranchID, // Type: domain.TransactionType(domain.TRANSACTION_DEPOSIT),
BranchName: branch.Name, // ReferenceNumber: txID,
BranchLocation: branch.Location, // BranchID: req.BranchID,
FullName: user.FirstName + " " + user.LastName, // BranchName: branch.Name,
PhoneNumber: user.PhoneNumber, // BranchLocation: branch.Location,
CompanyID: branch.CompanyID, // FullName: user.FirstName + " " + user.LastName,
}) // PhoneNumber: user.PhoneNumber,
if err != nil { // // CompanyID: branch.CompanyID,
return "", err // })
} // if err != nil {
// return "", err
// }
// Fetch user details for Chapa payment // Fetch user details for Chapa payment
userInfo, err := s.userStore.GetUserByID(ctx, userID) userInfo, err := s.userStore.GetUserByID(ctx, userID)
@ -290,6 +310,8 @@ func (s *Service) DepositUsingChapa(ctx context.Context, userID int64, req domai
return "", err return "", err
} }
// fmt.Printf("\n\nCallbackURL is:%v\n\n", s.config.CHAPA_CALLBACK_URL)
// Build Chapa InitPaymentRequest (matches Chapa API) // Build Chapa InitPaymentRequest (matches Chapa API)
paymentReq := domain.InitPaymentRequest{ paymentReq := domain.InitPaymentRequest{
Amount: req.Amount, Amount: req.Amount,
@ -298,14 +320,19 @@ func (s *Service) DepositUsingChapa(ctx context.Context, userID int64, req domai
FirstName: userInfo.FirstName, FirstName: userInfo.FirstName,
LastName: userInfo.LastName, LastName: userInfo.LastName,
TxRef: txID, TxRef: txID,
CallbackURL: s.config.CHAPA_CALLBACK_URL, CallbackURL: "https://fortunebet.com/api/v1/payments/callback",
ReturnURL: s.config.CHAPA_RETURN_URL, ReturnURL: "https://fortunebet.com/api/v1/payment-success",
} }
// Call Chapa to initialize payment // Call Chapa to initialize payment
paymentURL, err := s.chapaClient.InitPayment(ctx, paymentReq) var paymentURL string
if err != nil { maxRetries := 3
return "", err for range maxRetries {
paymentURL, err = s.chapaClient.InitPayment(ctx, paymentReq)
if err == nil {
break
}
time.Sleep(1 * time.Second) // Backoff
} }
// Commit DB transaction // Commit DB transaction

View File

@ -0,0 +1,15 @@
package report
import (
"context"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
)
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)
}

View File

@ -0,0 +1,509 @@
package report
import (
"context"
"errors"
"log/slog"
"sort"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/user"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
)
var (
ErrInvalidTimeRange = errors.New("invalid time range - start time must be before end time")
ErrInvalidReportCriteria = errors.New("invalid report criteria")
)
type Service struct {
betStore bet.BetStore
walletStore wallet.WalletStore
transactionStore transaction.TransactionStore
branchStore branch.BranchStore
userStore user.UserStore
logger *slog.Logger
}
func NewService(
betStore bet.BetStore,
walletStore wallet.WalletStore,
transactionStore transaction.TransactionStore,
branchStore branch.BranchStore,
userStore user.UserStore,
logger *slog.Logger,
) *Service {
return &Service{
betStore: betStore,
walletStore: walletStore,
transactionStore: transactionStore,
branchStore: branchStore,
userStore: userStore,
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) {
if err := validateTimeRange(filter); err != nil {
return DashboardSummary{}, err
}
var summary DashboardSummary
var err error
// Get bets summary
summary.TotalStakes, summary.TotalBets, summary.ActiveBets, summary.TotalWins, summary.TotalLosses, summary.WinBalance, err =
s.betStore.GetBetSummary(ctx, filter)
if err != nil {
s.logger.Error("failed to get bet summary", "error", err)
return DashboardSummary{}, err
}
// Get customer metrics
summary.CustomerCount, summary.ActiveCustomers, err = s.userStore.GetCustomerCounts(ctx, filter)
if err != nil {
s.logger.Error("failed to get customer counts", "error", err)
return DashboardSummary{}, err
}
// Get branch metrics
summary.BranchesCount, summary.ActiveBranches, err = s.branchStore.GetBranchCounts(ctx, filter)
if err != nil {
s.logger.Error("failed to get branch counts", "error", err)
return 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
}
// Calculate derived metrics
if summary.TotalBets > 0 {
summary.AverageStake = summary.TotalStakes / domain.Currency(summary.TotalBets)
summary.WinRate = float64(summary.TotalWins) / float64(summary.TotalBets) * 100
summary.Profit = summary.TotalStakes - summary.WinBalance
}
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) {
if err := validateTimeRange(filter); err != nil {
return nil, err
}
// Get basic bet stats
betStats, err := s.betStore.GetBetStats(ctx, filter)
if err != nil {
s.logger.Error("failed to get bet stats", "error", err)
return nil, err
}
// Get sport popularity
sportPopularity, err := s.betStore.GetSportPopularity(ctx, filter)
if err != nil {
s.logger.Error("failed to get sport popularity", "error", err)
return nil, err
}
// Get market popularity
marketPopularity, err := s.betStore.GetMarketPopularity(ctx, filter)
if err != nil {
s.logger.Error("failed to get market popularity", "error", err)
return nil, err
}
// Get extreme values
extremeValues, err := s.betStore.GetExtremeValues(ctx, filter)
if err != nil {
s.logger.Error("failed to get extreme values", "error", err)
return nil, err
}
// Combine data into analysis
var analysis []BetAnalysis
for _, stat := range betStats {
a := BetAnalysis{
Date: stat.Date,
TotalBets: stat.TotalBets,
TotalStakes: stat.TotalStakes,
TotalWins: stat.TotalWins,
TotalPayouts: stat.TotalPayouts,
Profit: stat.TotalStakes - stat.TotalPayouts,
AverageOdds: stat.AverageOdds,
}
// Add sport popularity
if sport, ok := sportPopularity[stat.Date]; ok {
a.MostPopularSport = sport
}
// Add market popularity
if market, ok := marketPopularity[stat.Date]; ok {
a.MostPopularMarket = market
}
// Add extreme values
if extremes, ok := extremeValues[stat.Date]; ok {
a.HighestStake = extremes.HighestStake
a.HighestPayout = extremes.HighestPayout
}
analysis = append(analysis, a)
}
// Sort by date
sort.Slice(analysis, func(i, j int) bool {
return analysis[i].Date.Before(analysis[j].Date)
})
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) {
if err := validateTimeRange(filter); err != nil {
return nil, err
}
// Get customer bet activity
customerBets, err := s.betStore.GetCustomerBetActivity(ctx, filter)
if err != nil {
s.logger.Error("failed to get customer bet activity", "error", err)
return nil, err
}
// Get customer details
customerDetails, err := s.userStore.GetCustomerDetails(ctx, filter)
if err != nil {
s.logger.Error("failed to get customer details", "error", err)
return nil, err
}
// Get customer preferences
customerPrefs, err := s.betStore.GetCustomerPreferences(ctx, filter)
if err != nil {
s.logger.Error("failed to get customer preferences", "error", err)
return nil, err
}
// Combine data into activity report
var activities []CustomerActivity
for _, bet := range customerBets {
activity := CustomerActivity{
CustomerID: bet.CustomerID,
TotalBets: bet.TotalBets,
TotalStakes: bet.TotalStakes,
TotalWins: bet.TotalWins,
TotalPayouts: bet.TotalPayouts,
Profit: bet.TotalStakes - bet.TotalPayouts,
FirstBetDate: bet.FirstBetDate,
LastBetDate: bet.LastBetDate,
AverageStake: bet.TotalStakes / domain.Currency(bet.TotalBets),
AverageOdds: bet.AverageOdds,
}
// Add customer details
if details, ok := customerDetails[bet.CustomerID]; ok {
activity.CustomerName = details.Name
}
// Add preferences
if prefs, ok := customerPrefs[bet.CustomerID]; ok {
activity.FavoriteSport = prefs.FavoriteSport
activity.FavoriteMarket = prefs.FavoriteMarket
}
// Calculate win rate
if bet.TotalBets > 0 {
activity.WinRate = float64(bet.TotalWins) / float64(bet.TotalBets) * 100
}
// Determine activity level
activity.ActivityLevel = calculateActivityLevel(bet.TotalBets, bet.TotalStakes)
activities = append(activities, activity)
}
// Sort by total stakes (descending)
sort.Slice(activities, func(i, j int) bool {
return activities[i].TotalStakes > activities[j].TotalStakes
})
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) {
// Get branch bet activity
branchBets, err := s.betStore.GetBranchBetActivity(ctx, filter)
if err != nil {
s.logger.Error("failed to get branch bet activity", "error", err)
return nil, err
}
// Get branch details
branchDetails, err := s.branchStore.GetBranchDetails(ctx, filter)
if err != nil {
s.logger.Error("failed to get branch details", "error", err)
return nil, err
}
// Get branch transactions
branchTransactions, err := s.transactionStore.GetBranchTransactionTotals(ctx, filter)
if err != nil {
s.logger.Error("failed to get branch transactions", "error", err)
return nil, err
}
// Get branch customer counts
branchCustomers, err := s.userStore.GetBranchCustomerCounts(ctx, filter)
if err != nil {
s.logger.Error("failed to get branch customer counts", "error", err)
return nil, err
}
// Combine data into performance report
var performances []BranchPerformance
for _, bet := range branchBets {
performance := BranchPerformance{
BranchID: bet.BranchID,
TotalBets: bet.TotalBets,
TotalStakes: bet.TotalStakes,
TotalWins: bet.TotalWins,
TotalPayouts: bet.TotalPayouts,
Profit: bet.TotalStakes - bet.TotalPayouts,
}
// Add branch details
if details, ok := branchDetails[bet.BranchID]; ok {
performance.BranchName = details.Name
performance.Location = details.Location
performance.ManagerName = details.ManagerName
}
// Add transactions
if transactions, ok := branchTransactions[bet.BranchID]; ok {
performance.Deposits = transactions.Deposits
performance.Withdrawals = transactions.Withdrawals
}
// Add customer counts
if customers, ok := branchCustomers[bet.BranchID]; ok {
performance.CustomerCount = customers
}
// Calculate metrics
if bet.TotalBets > 0 {
performance.WinRate = float64(bet.TotalWins) / float64(bet.TotalBets) * 100
performance.AverageStake = bet.TotalStakes / domain.Currency(bet.TotalBets)
}
// Calculate performance score
performance.PerformanceScore = calculatePerformanceScore(performance)
performances = append(performances, performance)
}
// Sort by performance score (descending)
sort.Slice(performances, func(i, j int) bool {
return performances[i].PerformanceScore > performances[j].PerformanceScore
})
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) {
// Get sport bet activity
sportBets, err := s.betStore.GetSportBetActivity(ctx, filter)
if err != nil {
s.logger.Error("failed to get sport bet activity", "error", err)
return nil, err
}
// Get sport details (names)
sportDetails, err := s.betStore.GetSportDetails(ctx, filter)
if err != nil {
s.logger.Error("failed to get sport details", "error", err)
return nil, err
}
// Get sport market popularity
sportMarkets, err := s.betStore.GetSportMarketPopularity(ctx, filter)
if err != nil {
s.logger.Error("failed to get sport market popularity", "error", err)
return nil, err
}
// Combine data into performance report
var performances []SportPerformance
for _, bet := range sportBets {
performance := SportPerformance{
SportID: bet.SportID,
TotalBets: bet.TotalBets,
TotalStakes: bet.TotalStakes,
TotalWins: bet.TotalWins,
TotalPayouts: bet.TotalPayouts,
Profit: bet.TotalStakes - bet.TotalPayouts,
AverageOdds: bet.AverageOdds,
}
// Add sport details
if details, ok := sportDetails[bet.SportID]; ok {
performance.SportName = details
}
// Add market popularity
if market, ok := sportMarkets[bet.SportID]; ok {
performance.MostPopularMarket = market
}
// 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) and assign popularity rank
sort.Slice(performances, func(i, j int) bool {
return performances[i].TotalStakes > performances[j].TotalStakes
})
for i := range performances {
performances[i].PopularityRank = i + 1
}
return performances, nil
}
// Helper functions
func validateTimeRange(filter domain.ReportFilter) error {
if filter.StartTime.Valid && filter.EndTime.Valid {
if filter.StartTime.Value.After(filter.EndTime.Value) {
return ErrInvalidTimeRange
}
}
return nil
}
func calculateActivityLevel(totalBets int64, totalStakes domain.Currency) string {
switch {
case totalBets > 100 || totalStakes > 10000:
return "High"
case totalBets > 50 || totalStakes > 5000:
return "Medium"
default:
return "Low"
}
}
func calculatePerformanceScore(perf BranchPerformance) float64 {
// Simple scoring algorithm - can be enhanced based on business rules
profitScore := float64(perf.Profit) / 1000
customerScore := float64(perf.CustomerCount) * 0.1
betScore := float64(perf.TotalBets) * 0.01
winRateScore := perf.WinRate * 0.1
return profitScore + customerScore + betScore + winRateScore
}

File diff suppressed because it is too large Load Diff

View File

@ -12,4 +12,7 @@ type TransactionStore interface {
GetAllTransactions(ctx context.Context, filter domain.TransactionFilter) ([]domain.Transaction, error) GetAllTransactions(ctx context.Context, filter domain.TransactionFilter) ([]domain.Transaction, error)
GetTransactionByBranch(ctx context.Context, id int64) ([]domain.Transaction, error) GetTransactionByBranch(ctx context.Context, id int64) ([]domain.Transaction, error)
UpdateTransactionVerified(ctx context.Context, id int64, verified bool, approvedBy int64, approverName string) error UpdateTransactionVerified(ctx context.Context, id int64, verified bool, approvedBy int64, approverName string) error
GetTransactionTotals(ctx context.Context, filter domain.ReportFilter) (deposits, withdrawals domain.Currency, err error)
GetBranchTransactionTotals(ctx context.Context, filter domain.ReportFilter) (map[int64]domain.BranchTransactions, error)
} }

View File

@ -2,14 +2,32 @@ package user
import ( import (
"context" "context"
"errors"
"fmt"
"time" "time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/pkgs/helpers"
afro "github.com/amanuelabay/afrosms-go"
"github.com/resend/resend-go/v2"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium) error { func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium) error {
otpCode := "123456" // Generate OTP code otpCode := helpers.GenerateOTP()
message := fmt.Sprintf("Welcome to Fortune bets, your OTP is %s please don't share with anyone.", otpCode)
switch medium {
case domain.OtpMediumSms:
if err := s.SendSMSOTP(ctx, sentTo, message); err != nil {
return err
}
case domain.OtpMediumEmail:
if err := s.SendEmailOTP(ctx, sentTo, message); err != nil {
return err
}
}
otp := domain.Otp{ otp := domain.Otp{
SentTo: sentTo, SentTo: sentTo,
@ -21,19 +39,9 @@ func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpF
ExpiresAt: time.Now().Add(OtpExpiry), ExpiresAt: time.Now().Add(OtpExpiry),
} }
err := s.otpStore.CreateOtp(ctx, otp) return s.otpStore.CreateOtp(ctx, otp)
if err != nil {
return err
} }
switch medium {
case domain.OtpMediumSms:
return s.smsGateway.SendSMSOTP(ctx, sentTo, otpCode)
case domain.OtpMediumEmail:
return s.emailGateway.SendEmailOTP(ctx, sentTo, otpCode)
}
return nil
}
func hashPassword(plaintextPassword string) ([]byte, error) { func hashPassword(plaintextPassword string) ([]byte, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12)
if err != nil { if err != nil {
@ -42,3 +50,50 @@ func hashPassword(plaintextPassword string) ([]byte, error) {
return hash, nil return hash, nil
} }
func (s *Service) SendSMSOTP(ctx context.Context, receiverPhone, message string) error {
apiKey := s.config.AFRO_SMS_API_KEY
senderName := s.config.AFRO_SMS_SENDER_NAME
hostURL := s.config.ADRO_SMS_HOST_URL
endpoint := "/api/send"
// API endpoint has been updated
// TODO: no need for package for the afro message operations (pretty simple stuff)
request := afro.GetRequest(apiKey, endpoint, hostURL)
request.BaseURL = "https://api.afromessage.com/api/send"
request.Method = "GET"
request.Sender(senderName)
request.To(receiverPhone, message)
response, err := afro.MakeRequestWithContext(ctx, request)
if err != nil {
return err
}
if response["acknowledge"] == "success" {
return nil
} else {
fmt.Println(response["response"].(map[string]interface{}))
return errors.New("SMS delivery failed")
}
}
func (s *Service) SendEmailOTP(ctx context.Context, receiverEmail, message string) error {
apiKey := s.config.ResendApiKey
client := resend.NewClient(apiKey)
formattedSenderEmail := "FortuneBets <" + s.config.ResendSenderEmail + ">"
params := &resend.SendEmailRequest{
From: formattedSenderEmail,
To: []string{receiverEmail},
Subject: "FortuneBets - One Time Password",
Text: message,
}
_, err := client.Emails.Send(params)
if err != nil {
return err
}
return nil
}

View File

@ -23,6 +23,10 @@ type UserStore interface {
GetUserByPhone(ctx context.Context, phoneNum string) (domain.User, error) GetUserByPhone(ctx context.Context, phoneNum string) (domain.User, error)
SearchUserByNameOrPhone(ctx context.Context, searchString string, role *domain.Role, companyID domain.ValidInt64) ([]domain.User, error) 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 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)
GetCustomerDetails(ctx context.Context, filter domain.ReportFilter) (map[int64]domain.CustomerDetail, error)
GetBranchCustomerCounts(ctx context.Context, filter domain.ReportFilter) (map[int64]int64, error)
} }
type SmsGateway interface { type SmsGateway interface {
SendSMSOTP(ctx context.Context, phoneNumber, otp string) error SendSMSOTP(ctx context.Context, phoneNumber, otp string) error

View File

@ -2,6 +2,8 @@ package user
import ( import (
"time" "time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
) )
const ( const (
@ -11,19 +13,17 @@ const (
type Service struct { type Service struct {
userStore UserStore userStore UserStore
otpStore OtpStore otpStore OtpStore
smsGateway SmsGateway config *config.Config
emailGateway EmailGateway
} }
func NewService( func NewService(
userStore UserStore, userStore UserStore,
otpStore OtpStore, smsGateway SmsGateway, otpStore OtpStore,
emailGateway EmailGateway, cfg *config.Config,
) *Service { ) *Service {
return &Service{ return &Service{
userStore: userStore, userStore: userStore,
otpStore: otpStore, otpStore: otpStore,
smsGateway: smsGateway, config: cfg,
emailGateway: emailGateway,
} }
} }

View File

@ -0,0 +1,216 @@
// internal/services/walletmonitor/service.go
package monitor
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch"
notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
)
type Service struct {
walletSvc wallet.Service
branchSvc branch.Service
notificationSvc *notificationservice.Service
logger *slog.Logger
thresholds []float64
checkInterval time.Duration
stopCh chan struct{}
wg sync.WaitGroup
initialDeposits map[int64]domain.Currency // companyID -> initial deposit
mu sync.RWMutex
}
func NewService(
walletSvc wallet.Service,
branchSvc branch.Service,
notificationSvc *notificationservice.Service, // Change to pointer
logger *slog.Logger,
checkInterval time.Duration,
) *Service {
return &Service{
walletSvc: walletSvc,
branchSvc: branchSvc,
notificationSvc: notificationSvc, // Now storing the pointer
logger: logger,
thresholds: []float64{0.75, 0.50, 0.25, 0.10, 0.05},
checkInterval: checkInterval,
stopCh: make(chan struct{}),
initialDeposits: make(map[int64]domain.Currency),
}
}
func (s *Service) Start() {
s.wg.Add(1)
go s.monitorWallets()
}
func (s *Service) Stop() {
close(s.stopCh)
s.wg.Wait()
}
func (s *Service) monitorWallets() {
defer s.wg.Done()
ticker := time.NewTicker(s.checkInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.checkWalletThresholds()
case <-s.stopCh:
return
}
}
}
func (s *Service) checkWalletThresholds() {
ctx := context.Background()
// Get all company wallets
companies, err := s.branchSvc.GetAllCompaniesBranch(ctx)
if err != nil {
s.logger.Error("failed to get companies", "error", err)
return
}
for _, company := range companies {
wallet, err := s.walletSvc.GetWalletByID(ctx, company.WalletID)
if err != nil {
s.logger.Error("failed to get company wallet", "company_id", company.ID, "error", err)
continue
}
// Initialize initial deposit if not set
s.mu.Lock()
if _, exists := s.initialDeposits[company.ID]; !exists {
s.initialDeposits[company.ID] = wallet.Balance
s.mu.Unlock()
continue
}
initialDeposit := s.initialDeposits[company.ID]
s.mu.Unlock()
if initialDeposit == 0 {
continue // avoid division by zero
}
currentBalance := wallet.Balance
currentPercentage := float64(currentBalance) / float64(initialDeposit)
for _, threshold := range s.thresholds {
if currentPercentage <= threshold {
// Check if we've already notified for this threshold
key := notificationKey(company.ID, threshold)
if s.hasNotified(key) {
continue
}
// Send notifications
s.sendThresholdNotifications(ctx, company.ID, threshold, currentBalance, initialDeposit)
s.markAsNotified(key)
}
}
}
}
func (s *Service) sendThresholdNotifications(
ctx context.Context,
companyID int64,
threshold float64,
currentBalance domain.Currency,
initialDeposit domain.Currency,
) {
// Get all recipients (branch managers, admins, super admins for this company)
recipients, err := s.getNotificationRecipients(ctx, companyID)
if err != nil {
s.logger.Error("failed to get notification recipients", "company_id", companyID, "error", err)
return
}
thresholdPercent := int(threshold * 100)
message := buildNotificationMessage(thresholdPercent, currentBalance, initialDeposit)
for _, recipientID := range recipients {
notification := &domain.Notification{
RecipientID: recipientID,
Type: domain.NOTIFICATION_TYPE_WALLET,
Level: domain.NotificationLevelWarning,
Reciever: domain.NotificationRecieverSideAdmin,
DeliveryChannel: domain.DeliveryChannelInApp,
Payload: domain.NotificationPayload{
Headline: "Wallet Threshold Alert",
Message: message,
},
Priority: 2, // Medium priority
}
if err := s.notificationSvc.SendNotification(ctx, notification); err != nil {
s.logger.Error("failed to send threshold notification",
"recipient_id", recipientID,
"company_id", companyID,
"threshold", thresholdPercent,
"error", err)
}
}
s.logger.Info("sent wallet threshold notifications",
"company_id", companyID,
"threshold", thresholdPercent,
"recipient_count", len(recipients))
}
func (s *Service) getNotificationRecipients(ctx context.Context, companyID int64) ([]int64, error) {
// Get branch managers for this company
branches, err := s.branchSvc.GetBranchesByCompany(ctx, companyID)
if err != nil {
return nil, err
}
var recipientIDs []int64
// Add branch managers
for _, branch := range branches {
if branch.BranchManagerID != 0 {
recipientIDs = append(recipientIDs, branch.BranchManagerID)
}
}
// Add company admins (implementation depends on your user service)
// This would typically query users with admin role for this company
return recipientIDs, nil
}
func (s *Service) hasNotified(key string) bool {
fmt.Println(key)
// Implement your notification tracking logic here
// Could use a cache or database to track which thresholds have been notified
return false
}
func (s *Service) markAsNotified(key string) {
// Implement your notification tracking logic here
// Mark that this threshold has been notified
}
func notificationKey(companyID int64, threshold float64) string {
return fmt.Sprintf("%d_%.2f", companyID, threshold)
}
func buildNotificationMessage(thresholdPercent int, currentBalance, initialDeposit domain.Currency) string {
return fmt.Sprintf(
"Company wallet balance has reached %d%% of initial deposit. Current balance: %.2f, Initial deposit: %.2f",
thresholdPercent,
float64(currentBalance)/100, // Assuming currency is in cents
float64(initialDeposit)/100,
)
}

View File

@ -16,6 +16,8 @@ type WalletStore interface {
GetAllBranchWallets(ctx context.Context) ([]domain.BranchWallet, error) GetAllBranchWallets(ctx context.Context) ([]domain.BranchWallet, error)
UpdateBalance(ctx context.Context, id int64, balance domain.Currency) error UpdateBalance(ctx context.Context, id int64, balance domain.Currency) error
UpdateWalletActive(ctx context.Context, id int64, isActive bool) error UpdateWalletActive(ctx context.Context, id int64, isActive bool) error
GetBalanceSummary(ctx context.Context, filter domain.ReportFilter) (domain.BalanceSummary, error)
} }
type TransferStore interface { type TransferStore interface {

View File

@ -1,13 +1,23 @@
package wallet package wallet
import (
"log/slog"
notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication"
)
type Service struct { type Service struct {
walletStore WalletStore walletStore WalletStore
transferStore TransferStore transferStore TransferStore
notificationStore notificationservice.NotificationStore
logger *slog.Logger
} }
func NewService(walletStore WalletStore, transferStore TransferStore) *Service { func NewService(walletStore WalletStore, transferStore TransferStore, notificationStore notificationservice.NotificationStore, logger *slog.Logger) *Service {
return &Service{ return &Service{
walletStore: walletStore, walletStore: walletStore,
transferStore: transferStore, transferStore: transferStore,
notificationStore: notificationStore,
logger: logger,
} }
} }

View File

@ -3,15 +3,96 @@ package wallet
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
) )
var ( var (
ErrWalletNotTransferable = errors.New("wallet is not transferable") ErrWalletNotTransferable = errors.New("wallet is not transferable")
ErrInsufficientBalance = errors.New("wallet balance is insufficient")
) )
func (s *Service) CreateTransfer(ctx context.Context, transfer domain.CreateTransfer) (domain.Transfer, error) { func (s *Service) CreateTransfer(ctx context.Context, transfer domain.CreateTransfer) (domain.Transfer, error) {
senderWallet, err := s.walletStore.GetWalletByID(ctx, transfer.SenderWalletID.Value)
receiverWallet, err := s.walletStore.GetWalletByID(ctx, transfer.ReceiverWalletID)
if err != nil {
return domain.Transfer{}, fmt.Errorf("failed to get sender wallet: %w", err)
}
// Check if wallet has sufficient balance
if senderWallet.Balance < transfer.Amount || senderWallet.Balance == 0 {
// Send notification to customer
customerNotification := &domain.Notification{
RecipientID: receiverWallet.UserID,
Type: domain.NOTIFICATION_TYPE_TRANSFER,
Level: domain.NotificationLevelError,
Reciever: domain.NotificationRecieverSideCustomer,
DeliveryChannel: domain.DeliveryChannelInApp,
Payload: domain.NotificationPayload{
Headline: "Service Temporarily Unavailable",
Message: "Our payment system is currently under maintenance. Please try again later.",
},
Priority: 2,
Metadata: []byte(fmt.Sprintf(`{
"transfer_amount": %d,
"current_balance": %d,
"wallet_id": %d,
"notification_type": "customer_facing"
}`, transfer.Amount, senderWallet.Balance, transfer.SenderWalletID.Value)),
}
// Send notification to admin team
adminNotification := &domain.Notification{
RecipientID: senderWallet.UserID,
Type: domain.NOTIFICATION_TYPE_ADMIN_ALERT,
Level: domain.NotificationLevelError,
Reciever: domain.NotificationRecieverSideAdmin,
DeliveryChannel: domain.DeliveryChannelEmail, // Or any preferred admin channel
Payload: domain.NotificationPayload{
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,
float64(senderWallet.Balance)/100,
float64(transfer.Amount)/100,
),
},
Priority: 1, // High priority for admin alerts
Metadata: fmt.Appendf(nil, `{
"wallet_id": %d,
"balance": %d,
"required_amount": %d,
"notification_type": "admin_alert"
}`, transfer.SenderWalletID.Value, senderWallet.Balance, transfer.Amount),
}
// Send both notifications
if err := s.notificationStore.SendNotification(ctx, customerNotification); err != nil {
s.logger.Error("failed to send customer notification",
"user_id", "",
"error", err)
}
// Get admin recipients and send to all
adminRecipients, err := s.notificationStore.ListRecipientIDs(ctx, domain.NotificationRecieverSideAdmin)
if err != nil {
s.logger.Error("failed to get admin recipients", "error", err)
} else {
for _, adminID := range adminRecipients {
adminNotification.RecipientID = adminID
if err := s.notificationStore.SendNotification(ctx, adminNotification); err != nil {
s.logger.Error("failed to send admin notification",
"admin_id", adminID,
"error", err)
}
}
}
return domain.Transfer{}, ErrInsufficientBalance
}
// Proceed with transfer if balance is sufficient
return s.transferStore.CreateTransfer(ctx, transfer) return s.transferStore.CreateTransfer(ctx, transfer)
} }
@ -38,6 +119,13 @@ func (s *Service) RefillWallet(ctx context.Context, transfer domain.CreateTransf
} }
// Add to receiver // Add to receiver
senderWallet, err := s.GetWalletByID(ctx, transfer.SenderWalletID.Value)
if err != nil {
return domain.Transfer{}, err
} else if senderWallet.Balance < transfer.Amount {
return domain.Transfer{}, ErrInsufficientBalance
}
err = s.walletStore.UpdateBalance(ctx, receiverWallet.ID, receiverWallet.Balance+transfer.Amount) err = s.walletStore.UpdateBalance(ctx, receiverWallet.ID, receiverWallet.Balance+transfer.Amount)
if err != nil { if err != nil {
return domain.Transfer{}, err return domain.Transfer{}, err

View File

@ -15,6 +15,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation"
referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/report"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/result" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/result"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction"
@ -46,6 +47,7 @@ type App struct {
userSvc *user.Service userSvc *user.Service
betSvc *bet.Service betSvc *bet.Service
virtualGameSvc virtualgameservice.VirtualGameService virtualGameSvc virtualgameservice.VirtualGameService
reportSvc *report.Service
chapaSvc *chapa.Service chapaSvc *chapa.Service
walletSvc *wallet.Service walletSvc *wallet.Service
transactionSvc *transaction.Service transactionSvc *transaction.Service
@ -69,6 +71,7 @@ func NewApp(
userSvc *user.Service, userSvc *user.Service,
ticketSvc *ticket.Service, ticketSvc *ticket.Service,
betSvc *bet.Service, betSvc *bet.Service,
reportSvc *report.Service,
chapaSvc *chapa.Service, chapaSvc *chapa.Service,
walletSvc *wallet.Service, walletSvc *wallet.Service,
transactionSvc *transaction.Service, transactionSvc *transaction.Service,
@ -110,6 +113,7 @@ func NewApp(
userSvc: userSvc, userSvc: userSvc,
ticketSvc: ticketSvc, ticketSvc: ticketSvc,
betSvc: betSvc, betSvc: betSvc,
reportSvc: reportSvc,
chapaSvc: chapaSvc, chapaSvc: chapaSvc,
walletSvc: walletSvc, walletSvc: walletSvc,
transactionSvc: transactionSvc, transactionSvc: transactionSvc,

View File

@ -16,6 +16,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation"
referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/result" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/result"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/report"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user"
@ -32,6 +33,7 @@ type Handler struct {
notificationSvc *notificationservice.Service notificationSvc *notificationservice.Service
userSvc *user.Service userSvc *user.Service
referralSvc referralservice.ReferralStore referralSvc referralservice.ReferralStore
reportSvc report.ReportStore
chapaSvc chapa.ChapaPort chapaSvc chapa.ChapaPort
walletSvc *wallet.Service walletSvc *wallet.Service
transactionSvc *transaction.Service transactionSvc *transaction.Service
@ -57,6 +59,7 @@ func New(
logger *slog.Logger, logger *slog.Logger,
notificationSvc *notificationservice.Service, notificationSvc *notificationservice.Service,
validator *customvalidator.CustomValidator, validator *customvalidator.CustomValidator,
reportSvc report.ReportStore,
chapaSvc chapa.ChapaPort, chapaSvc chapa.ChapaPort,
walletSvc *wallet.Service, walletSvc *wallet.Service,
referralSvc referralservice.ReferralStore, referralSvc referralservice.ReferralStore,
@ -81,6 +84,7 @@ func New(
return &Handler{ return &Handler{
logger: logger, logger: logger,
notificationSvc: notificationSvc, notificationSvc: notificationSvc,
reportSvc: reportSvc,
chapaSvc: chapaSvc, chapaSvc: chapaSvc,
walletSvc: walletSvc, walletSvc: walletSvc,
referralSvc: referralSvc, referralSvc: referralSvc,

View File

@ -0,0 +1,38 @@
package handlers
import (
"context"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func GetLogsHandler(appCtx context.Context) fiber.Handler {
return func(c *fiber.Ctx) error {
client, err := mongo.Connect(appCtx, options.Client().ApplyURI("mongodb://root:secret@mongo:27017/?authSource=admin"))
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "MongoDB connection failed: "+err.Error())
}
collection := client.Database("logdb").Collection("applogs")
filter := bson.M{}
opts := options.Find().SetSort(bson.D{{Key: "timestamp", Value: -1}}).SetLimit(100)
cursor, err := collection.Find(appCtx, filter, opts)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch logs: "+err.Error())
}
defer cursor.Close(appCtx)
var logs []domain.LogEntry
if err := cursor.All(appCtx, &logs); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Cursor decoding error: "+err.Error())
}
return c.JSON(logs)
}
}

View File

@ -0,0 +1,123 @@
package handlers
import (
"context"
"fmt"
"strconv"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/gofiber/fiber/v2"
)
// GetDashboardReport returns a comprehensive dashboard report
// @Summary Get dashboard report
// @Description Returns a comprehensive dashboard report with key metrics
// @Tags Reports
// @Accept json
// @Produce json
// @Param company_id query int false "Company ID filter"
// @Param branch_id query int false "Branch ID filter"
// @Param user_id query int false "User ID filter"
// @Param start_time query string false "Start time filter (RFC3339 format)"
// @Param end_time query string false "End time filter (RFC3339 format)"
// @Param sport_id query string false "Sport ID filter"
// @Param status query int false "Status filter (0=Pending, 1=Win, 2=Loss, 3=Half, 4=Void, 5=Error)"
// @Security ApiKeyAuth
// @Success 200 {object} report.DashboardSummary
// @Failure 400 {object} domain.ErrorResponse
// @Failure 401 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/reports/dashboard [get]
func (h *Handler) GetDashboardReport(c *fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Parse query parameters
filter, err := parseReportFilter(c)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid filter parameters",
Error: err.Error(),
})
}
// Get report data
summary, err := h.reportSvc.GetDashboardSummary(ctx, filter)
if err != nil {
h.logger.Error("failed to get dashboard report", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to generate report",
Error: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Dashboard reports generated successfully",
Success: true,
StatusCode: 200,
Data: summary,
})
// return c.Status(fiber.StatusOK).JSON(summary)
}
// parseReportFilter parses query parameters into ReportFilter
func parseReportFilter(c *fiber.Ctx) (domain.ReportFilter, error) {
var filter domain.ReportFilter
var err error
if c.Query("company_id") != "" {
companyID, err := strconv.ParseInt(c.Query("company_id"), 10, 64)
if err != nil {
return domain.ReportFilter{}, fmt.Errorf("invalid company_id: %w", err)
}
filter.CompanyID = domain.ValidInt64{Value: companyID, Valid: true}
}
if c.Query("branch_id") != "" {
branchID, err := strconv.ParseInt(c.Query("branch_id"), 10, 64)
if err != nil {
return domain.ReportFilter{}, fmt.Errorf("invalid branch_id: %w", err)
}
filter.BranchID = domain.ValidInt64{Value: branchID, Valid: true}
}
if c.Query("user_id") != "" {
userID, err := strconv.ParseInt(c.Query("user_id"), 10, 64)
if err != nil {
return domain.ReportFilter{}, fmt.Errorf("invalid user_id: %w", err)
}
filter.UserID = domain.ValidInt64{Value: userID, Valid: true}
}
if c.Query("start_time") != "" {
startTime, err := time.Parse(time.RFC3339, c.Query("start_time"))
if err != nil {
return domain.ReportFilter{}, fmt.Errorf("invalid start_time: %w", err)
}
filter.StartTime = domain.ValidTime{Value: startTime, Valid: true}
}
if c.Query("end_time") != "" {
endTime, err := time.Parse(time.RFC3339, c.Query("end_time"))
if err != nil {
return domain.ReportFilter{}, fmt.Errorf("invalid end_time: %w", err)
}
filter.EndTime = domain.ValidTime{Value: endTime, Valid: true}
}
if c.Query("sport_id") != "" {
filter.SportID = domain.ValidString{Value: c.Query("sport_id"), Valid: true}
}
if c.Query("status") != "" {
status, err := strconv.ParseInt(c.Query("status"), 10, 32)
if err != nil {
return domain.ReportFilter{}, fmt.Errorf("invalid status: %w", err)
}
filter.Status = domain.ValidOutcomeStatus{Value: domain.OutcomeStatus(status), Valid: true}
}
return filter, err
}

View File

@ -2,6 +2,7 @@ package handlers
import ( import (
"errors" "errors"
"fmt"
"strconv" "strconv"
"time" "time"
@ -243,6 +244,7 @@ func (h *Handler) SendResetCode(c *fiber.Ctx) error {
if err := h.userSvc.SendResetCode(c.Context(), medium, sentTo); err != nil { if err := h.userSvc.SendResetCode(c.Context(), medium, sentTo); err != nil {
h.logger.Error("Failed to send reset code", "error", err) h.logger.Error("Failed to send reset code", "error", err)
fmt.Println(err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to send reset code") return fiber.NewError(fiber.StatusInternalServerError, "Failed to send reset code")
} }
@ -250,8 +252,8 @@ func (h *Handler) SendResetCode(c *fiber.Ctx) error {
} }
type ResetPasswordReq struct { type ResetPasswordReq struct {
Email string `json:"email" validate:"email" example:"john.doe@example.com"` Email string `json:"email,omitempty" validate:"required_without=PhoneNumber,omitempty,email" example:"john.doe@example.com"`
PhoneNumber string `json:"phone_number" validate:"required_without=Email" example:"1234567890"` PhoneNumber string `json:"phone_number,omitempty" validate:"required_without=Email,omitempty" example:"1234567890"`
Password string `json:"password" validate:"required,min=8" example:"newpassword123"` Password string `json:"password" validate:"required,min=8" example:"newpassword123"`
Otp string `json:"otp" validate:"required" example:"123456"` Otp string `json:"otp" validate:"required" example:"123456"`
} }

View File

@ -1,11 +1,15 @@
package httpserver package httpserver
import ( import (
"context"
"fmt" "fmt"
"strconv" "strconv"
_ "github.com/SamuelTariku/FortuneBet-Backend/docs" _ "github.com/SamuelTariku/FortuneBet-Backend/docs"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
// "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger"
// "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet/monitor"
"github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/handlers" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/handlers"
@ -18,6 +22,7 @@ func (a *App) initAppRoutes() {
a.logger, a.logger,
a.NotidicationStore, a.NotidicationStore,
a.validator, a.validator,
a.reportSvc,
a.chapaSvc, a.chapaSvc,
a.walletSvc, a.walletSvc,
a.referralSvc, a.referralSvc,
@ -197,6 +202,22 @@ func (a *App) initAppRoutes() {
group.Post("/chapa/payments/deposit", a.authMiddleware, h.DepositUsingChapa) group.Post("/chapa/payments/deposit", a.authMiddleware, h.DepositUsingChapa)
group.Get("/chapa/banks", a.authMiddleware, h.ReadChapaBanks) group.Get("/chapa/banks", a.authMiddleware, h.ReadChapaBanks)
//Report Routes
group.Get("/reports/dashboard", a.authMiddleware, h.GetDashboardReport)
//Wallet Monitor Service
// group.Get("/debug/wallet-monitor/status", func(c *fiber.Ctx) error {
// return c.JSON(fiber.Map{
// "running": monitor.IsRunning(),
// "last_check": walletMonitorSvc.LastCheckTime(),
// })
// })
// group.Post("/debug/wallet-monitor/trigger", func(c *fiber.Ctx) error {
// walletMonitorSvc.ForceCheck()
// return c.SendStatus(fiber.StatusOK)
// })
// group.Post("/chapa/payments/initialize", h.InitializePayment) // group.Post("/chapa/payments/initialize", h.InitializePayment)
// group.Get("/chapa/payments/verify/:tx_ref", h.VerifyTransaction) // group.Get("/chapa/payments/verify/:tx_ref", h.VerifyTransaction)
// group.Post("/chapa/payments/callback", h.ReceiveWebhook) // group.Post("/chapa/payments/callback", h.ReceiveWebhook)
@ -212,6 +233,10 @@ func (a *App) initAppRoutes() {
group.Get("/veli-games/launch", h.LaunchVeliGame) group.Get("/veli-games/launch", h.LaunchVeliGame)
group.Post("/webhooks/veli-games", h.HandleVeliCallback) group.Post("/webhooks/veli-games", h.HandleVeliCallback)
//mongoDB logs
ctx := context.Background()
group.Get("/logs", handlers.GetLogsHandler(ctx))
// Recommendation Routes // Recommendation Routes
group.Get("/virtual-games/recommendations/:userID", h.GetRecommendations) group.Get("/virtual-games/recommendations/:userID", h.GetRecommendations)