report_service + wallet_monitoring
This commit is contained in:
parent
c2b547ff34
commit
c22a1bd6c4
75
cmd/main.go
75
cmd/main.go
|
|
@ -3,15 +3,21 @@ package main
|
|||
import (
|
||||
// "context"
|
||||
|
||||
// "context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
// "github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
|
||||
customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger"
|
||||
|
||||
// mongologger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger"
|
||||
|
||||
// "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger"
|
||||
mockemail "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_email"
|
||||
mocksms "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_sms"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/repository"
|
||||
|
|
@ -27,6 +33,7 @@ import (
|
|||
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation"
|
||||
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/ticket"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction"
|
||||
|
|
@ -35,6 +42,7 @@ import (
|
|||
alea "github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame/Alea"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/virtualGame/veli"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet/monitor"
|
||||
|
||||
// "github.com/SamuelTariku/FortuneBet-Backend/internal/utils"
|
||||
httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server"
|
||||
|
|
@ -70,6 +78,32 @@ func main() {
|
|||
}
|
||||
|
||||
logger := customlogger.NewLogger(cfg.Env, cfg.LogLevel)
|
||||
|
||||
// mongologger.Init()
|
||||
|
||||
// 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)
|
||||
v := customvalidator.NewCustomValidator(validator.New())
|
||||
|
||||
|
|
@ -82,18 +116,33 @@ func main() {
|
|||
eventSvc := event.New(cfg.Bet365Token, store)
|
||||
oddsSvc := odds.New(store, cfg, logger)
|
||||
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)
|
||||
branchSvc := branch.NewService(store)
|
||||
companySvc := company.NewService(store)
|
||||
betSvc := bet.NewService(store, eventSvc, oddsSvc, *walletSvc, *branchSvc, logger)
|
||||
resultSvc := result.NewService(store, cfg, logger, *betSvc)
|
||||
notificationRepo := repository.NewNotificationRepository(store)
|
||||
referalRepo := repository.NewReferralRepository(store)
|
||||
vitualGameRepo := repository.NewVirtualGameRepository(store)
|
||||
recommendationRepo := repository.NewRecommendationRepository(store)
|
||||
|
||||
notificationSvc := notificationservice.New(notificationRepo, logger, cfg)
|
||||
referalSvc := referralservice.New(referalRepo, *walletSvc, store, cfg, logger)
|
||||
virtualGameSvc := virtualgameservice.New(vitualGameRepo, *walletSvc, store, cfg, logger)
|
||||
aleaService := alea.NewAleaPlayService(
|
||||
|
|
@ -121,6 +170,24 @@ func main() {
|
|||
store,
|
||||
)
|
||||
|
||||
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.StartTicketCrons(*ticketSvc)
|
||||
|
||||
|
|
@ -128,7 +195,7 @@ func main() {
|
|||
JwtAccessKey: cfg.JwtKey,
|
||||
JwtAccessExpiry: cfg.AccessExpiry,
|
||||
}, userSvc,
|
||||
ticketSvc, betSvc, chapaSvc, walletSvc, transactionSvc, branchSvc, companySvc, notificationSvc, oddsSvc, eventSvc, referalSvc, virtualGameSvc, aleaService, veliService, recommendationSvc, resultSvc, cfg)
|
||||
ticketSvc, betSvc, reportSvc, chapaSvc, walletSvc, transactionSvc, branchSvc, companySvc, notificationSvc, oddsSvc, eventSvc, referalSvc, virtualGameSvc, aleaService, veliService, recommendationSvc, resultSvc, cfg)
|
||||
logger.Info("Starting server", "port", cfg.Port)
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
|
|
|
|||
|
|
@ -136,25 +136,25 @@ CREATE TABLE IF NOT EXISTS transactions (
|
|||
id BIGSERIAL PRIMARY KEY,
|
||||
amount BIGINT NOT NULL,
|
||||
branch_id BIGINT NOT NULL,
|
||||
company_id BIGINT NOT NULL,
|
||||
cashier_id BIGINT NOT NULL,
|
||||
cashier_name VARCHAR(255) NOT NULL,
|
||||
bet_id BIGINT NOT NULL,
|
||||
number_of_outcomes BIGINT NOT NULL,
|
||||
type BIGINT NOT NULL,
|
||||
payment_option BIGINT NOT NULL,
|
||||
full_name VARCHAR(255) NOT NULL,
|
||||
phone_number VARCHAR(255) NOT NULL,
|
||||
bank_code VARCHAR(255) NOT NULL,
|
||||
beneficiary_name VARCHAR(255) NOT NULL,
|
||||
account_name VARCHAR(255) NOT NULL,
|
||||
account_number VARCHAR(255) NOT NULL,
|
||||
reference_number VARCHAR(255) NOT NULL,
|
||||
company_id BIGINT,
|
||||
cashier_id BIGINT,
|
||||
cashier_name VARCHAR(255)L,
|
||||
bet_id BIGINT,
|
||||
number_of_outcomes BIGINT,
|
||||
type BIGINT,
|
||||
payment_option BIGINT,
|
||||
full_name VARCHAR(255),
|
||||
phone_number VARCHAR(255),
|
||||
bank_code VARCHAR(255),
|
||||
beneficiary_name VARCHAR(255),
|
||||
account_name VARCHAR(255),
|
||||
account_number VARCHAR(255),
|
||||
reference_number VARCHAR(255),
|
||||
verified BOOLEAN NOT NULL DEFAULT false,
|
||||
approved_by BIGINT,
|
||||
approver_name VARCHAR(255),
|
||||
branch_location VARCHAR(255) NOT NULL,
|
||||
branch_name VARCHAR(255) NOT NULL,
|
||||
branch_location VARCHAR(255),
|
||||
branch_name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
DROP TABLE notifications;
|
||||
DROP TABLE wallet_threshold_notifications;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,15 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|||
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_timestamp ON notifications (timestamp);
|
||||
|
|
|
|||
24
db/query/monitor.sql
Normal file
24
db/query/monitor.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
-- name: GetAllCompanies :many
|
||||
SELECT id, name, wallet_id, admin_id, created_at
|
||||
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);
|
||||
|
|
@ -17,6 +17,25 @@ services:
|
|||
volumes:
|
||||
- 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:
|
||||
image: migrate/migrate
|
||||
volumes:
|
||||
|
|
@ -44,14 +63,16 @@ services:
|
|||
- ${PORT}:8080
|
||||
environment:
|
||||
- DB_URL=postgresql://root:secret@postgres:5432/gh?sslmode=disable
|
||||
- MONGO_URI=mongodb://root:secret@mongo:27017
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- app
|
||||
command: ["/app/bin/web"]
|
||||
|
||||
|
||||
test:
|
||||
build:
|
||||
context: .
|
||||
|
|
@ -69,3 +90,4 @@ networks:
|
|||
|
||||
volumes:
|
||||
postgres_data:
|
||||
mongo_data:
|
||||
|
|
|
|||
151
docs/docs.go
151
docs/docs.go
|
|
@ -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}": {
|
||||
"get": {
|
||||
"description": "Returns a list of recommended virtual games for a specific user",
|
||||
|
|
@ -4629,6 +4719,17 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"domain.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.Odd": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -6285,6 +6386,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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -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}": {
|
||||
"get": {
|
||||
"description": "Returns a list of recommended virtual games for a specific user",
|
||||
|
|
@ -4621,6 +4711,17 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"domain.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.Odd": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -6277,6 +6378,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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -255,6 +255,13 @@ definitions:
|
|||
- $ref: '#/definitions/domain.OutcomeStatus'
|
||||
example: 1
|
||||
type: object
|
||||
domain.ErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
type: object
|
||||
domain.Odd:
|
||||
properties:
|
||||
category:
|
||||
|
|
@ -1415,6 +1422,39 @@ definitions:
|
|||
example: false
|
||||
type: boolean
|
||||
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:
|
||||
properties:
|
||||
data: {}
|
||||
|
|
@ -1766,6 +1806,64 @@ paths:
|
|||
summary: Withdraw using Chapa
|
||||
tags:
|
||||
- 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}:
|
||||
get:
|
||||
consumes:
|
||||
|
|
|
|||
15
go.mod
15
go.mod
|
|
@ -56,11 +56,24 @@ require (
|
|||
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.3
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/net v0.38.0 // direct
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // 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 go.uber.org/multierr v1.10.0 // indirect
|
||||
|
|
|
|||
33
go.sum
33
go.sum
|
|
@ -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/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/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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
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/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=
|
||||
|
|
@ -147,30 +153,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/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU=
|
||||
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/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.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/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-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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
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.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/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-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-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-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-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/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-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/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
|
|
@ -182,20 +210,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-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-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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
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-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.3/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.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
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/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-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.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/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
|
|||
|
|
@ -192,9 +192,9 @@ func (r ChapaDepositRequest) Validate() error {
|
|||
if r.PhoneNumber == "" {
|
||||
return errors.New("phone number is required")
|
||||
}
|
||||
if r.BranchID == 0 {
|
||||
return errors.New("branch ID is required")
|
||||
}
|
||||
// if r.BranchID == 0 {
|
||||
// return errors.New("branch ID is required")
|
||||
// }
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ const (
|
|||
NotificationTypeBetOverload NotificationType = "bet_overload"
|
||||
NotificationTypeSignUpWelcome NotificationType = "signup_welcome"
|
||||
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"
|
||||
NotificationRecieverSideCustomer NotificationRecieverSide = "customer"
|
||||
|
|
|
|||
123
internal/domain/report.go
Normal file
123
internal/domain/report.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
package customlogger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"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{
|
||||
|
|
@ -24,7 +31,7 @@ func NewLogger(env string, lvl slog.Level) *slog.Logger {
|
|||
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 {
|
||||
panic("Failed to open log file: " + err.Error())
|
||||
}
|
||||
|
|
@ -48,3 +55,59 @@ func NewLogger(env string, lvl slog.Level) *slog.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)
|
||||
}
|
||||
|
|
|
|||
47
internal/logger/mongoLogger/handler.go
Normal file
47
internal/logger/mongoLogger/handler.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package mongoLogger
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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 []LogEntry
|
||||
if err := cursor.All(appCtx, &logs); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Cursor decoding error: "+err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(logs)
|
||||
}
|
||||
}
|
||||
30
internal/logger/mongoLogger/init.go
Normal file
30
internal/logger/mongoLogger/init.go
Normal 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"))
|
||||
}
|
||||
89
internal/logger/mongoLogger/logger.go
Normal file
89
internal/logger/mongoLogger/logger.go
Normal 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
|
||||
}
|
||||
|
|
@ -2,6 +2,9 @@ package repository
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
// "fmt"
|
||||
|
||||
|
|
@ -10,6 +13,8 @@ import (
|
|||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
var logger *slog.Logger
|
||||
|
||||
func convertDBBet(bet dbgen.Bet) domain.Bet {
|
||||
return domain.Bet{
|
||||
ID: bet.ID,
|
||||
|
|
@ -132,6 +137,8 @@ func convertCreateBet(bet domain.CreateBet) dbgen.CreateBetParams {
|
|||
func (s *Store) CreateBet(ctx context.Context, bet domain.CreateBet) (domain.Bet, error) {
|
||||
newBet, err := s.queries.CreateBet(ctx, convertCreateBet(bet))
|
||||
if err != nil {
|
||||
fmt.Println("We are here")
|
||||
logger.Error("Failed to create bet", slog.String("error", err.Error()), slog.Any("bet", bet))
|
||||
return domain.Bet{}, err
|
||||
}
|
||||
return convertDBBet(newBet), err
|
||||
|
|
@ -289,3 +296,768 @@ func (s *Store) UpdateBetOutcomeStatus(ctx context.Context, id int64, status dom
|
|||
func (s *Store) DeleteBet(ctx context.Context, id int64) error {
|
||||
return s.queries.DeleteBet(ctx, id)
|
||||
}
|
||||
|
||||
// GetBetSummary returns aggregated bet statistics
|
||||
func (s *Store) GetBetSummary(ctx context.Context, filter domain.ReportFilter) (
|
||||
totalStakes domain.Currency,
|
||||
totalBets int64,
|
||||
activeBets int64,
|
||||
totalWins int64,
|
||||
totalLosses int64,
|
||||
winBalance domain.Currency,
|
||||
err error,
|
||||
) {
|
||||
query := `SELECT
|
||||
COALESCE(SUM(amount), 0) as total_stakes,
|
||||
COUNT(*) as total_bets,
|
||||
SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) as active_bets,
|
||||
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as total_wins,
|
||||
SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) as total_losses,
|
||||
COALESCE(SUM(CASE WHEN status = 1 THEN amount * total_odds ELSE 0 END), 0) as win_balance
|
||||
FROM bets`
|
||||
|
||||
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.BranchID.Valid {
|
||||
query += fmt.Sprintf(" AND %sbranch_id = $%d", func() string {
|
||||
if len(args) == 0 {
|
||||
return " WHERE "
|
||||
}
|
||||
return " AND "
|
||||
}(), argPos)
|
||||
args = append(args, filter.BranchID.Value)
|
||||
argPos++
|
||||
}
|
||||
if filter.UserID.Valid {
|
||||
query += fmt.Sprintf(" AND %suser_id = $%d", func() string {
|
||||
if len(args) == 0 {
|
||||
return " WHERE "
|
||||
}
|
||||
return " AND "
|
||||
}(), 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 " 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++
|
||||
}
|
||||
if filter.Status.Valid {
|
||||
query += fmt.Sprintf(" AND %sstatus = $%d", func() string {
|
||||
if len(args) == 0 {
|
||||
return " WHERE "
|
||||
}
|
||||
return " AND "
|
||||
}(), argPos)
|
||||
args = append(args, filter.Status.Value)
|
||||
argPos++
|
||||
}
|
||||
|
||||
row := s.conn.QueryRow(ctx, query, args...)
|
||||
err = row.Scan(&totalStakes, &totalBets, &activeBets, &totalWins, &totalLosses, &winBalance)
|
||||
if err != nil {
|
||||
return 0, 0, 0, 0, 0, 0, fmt.Errorf("failed to get bet summary: %w", err)
|
||||
}
|
||||
|
||||
return totalStakes, totalBets, activeBets, totalWins, totalLosses, winBalance, nil
|
||||
}
|
||||
|
||||
// GetBetStats returns bet statistics grouped by date
|
||||
func (s *Store) GetBetStats(ctx context.Context, filter domain.ReportFilter) ([]domain.BetStat, error) {
|
||||
query := `SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as total_bets,
|
||||
COALESCE(SUM(amount), 0) as total_stakes,
|
||||
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as total_wins,
|
||||
COALESCE(SUM(CASE WHEN status = 1 THEN amount * total_odds ELSE 0 END), 0) as total_payouts,
|
||||
AVG(total_odds) as average_odds
|
||||
FROM bets`
|
||||
|
||||
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.BranchID.Valid {
|
||||
query += fmt.Sprintf(" AND %sbranch_id = $%d", func() string {
|
||||
if len(args) == 0 {
|
||||
return " WHERE "
|
||||
}
|
||||
return " AND "
|
||||
}(), argPos)
|
||||
args = append(args, filter.BranchID.Value)
|
||||
argPos++
|
||||
}
|
||||
if filter.UserID.Valid {
|
||||
query += fmt.Sprintf(" AND %suser_id = $%d", func() string {
|
||||
if len(args) == 0 {
|
||||
return " WHERE "
|
||||
}
|
||||
return " AND "
|
||||
}(), 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 " 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++
|
||||
}
|
||||
if filter.Status.Valid {
|
||||
query += fmt.Sprintf(" AND %sstatus = $%d", func() string {
|
||||
if len(args) == 0 {
|
||||
return " WHERE "
|
||||
}
|
||||
return " AND "
|
||||
}(), argPos)
|
||||
args = append(args, filter.Status.Value)
|
||||
argPos++
|
||||
}
|
||||
|
||||
query += " GROUP BY DATE(created_at) ORDER BY DATE(created_at)"
|
||||
|
||||
rows, err := s.conn.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query bet stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stats []domain.BetStat
|
||||
for rows.Next() {
|
||||
var stat domain.BetStat
|
||||
if err := rows.Scan(
|
||||
&stat.Date,
|
||||
&stat.TotalBets,
|
||||
&stat.TotalStakes,
|
||||
&stat.TotalWins,
|
||||
&stat.TotalPayouts,
|
||||
&stat.AverageOdds,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan bet stat: %w", err)
|
||||
}
|
||||
stats = append(stats, stat)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetSportPopularity returns the most popular sport by date
|
||||
func (s *Store) GetSportPopularity(ctx context.Context, filter domain.ReportFilter) (map[time.Time]string, error) {
|
||||
query := `WITH sport_counts AS (
|
||||
SELECT
|
||||
DATE(b.created_at) as date,
|
||||
bo.sport_id,
|
||||
COUNT(*) as bet_count,
|
||||
ROW_NUMBER() OVER (PARTITION BY DATE(b.created_at) ORDER BY COUNT(*) DESC) as rank
|
||||
FROM bets b
|
||||
JOIN bet_outcomes bo ON b.id = bo.bet_id
|
||||
WHERE bo.sport_id 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++
|
||||
}
|
||||
if filter.Status.Valid {
|
||||
query += fmt.Sprintf(" AND b.status = $%d", argPos)
|
||||
args = append(args, filter.Status.Value)
|
||||
argPos++
|
||||
}
|
||||
|
||||
query += ` GROUP BY DATE(b.created_at), bo.sport_id
|
||||
)
|
||||
SELECT date, sport_id FROM sport_counts WHERE rank = 1`
|
||||
|
||||
rows, err := s.conn.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query sport popularity: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
popularity := make(map[time.Time]string)
|
||||
for rows.Next() {
|
||||
var date time.Time
|
||||
var sportID string
|
||||
if err := rows.Scan(&date, &sportID); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan sport popularity: %w", err)
|
||||
}
|
||||
popularity[date] = sportID
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
return popularity, nil
|
||||
}
|
||||
|
||||
// GetMarketPopularity returns the most popular market by date
|
||||
func (s *Store) GetMarketPopularity(ctx context.Context, filter domain.ReportFilter) (map[time.Time]string, error) {
|
||||
query := `WITH market_counts AS (
|
||||
SELECT
|
||||
DATE(b.created_at) as date,
|
||||
bo.market_name,
|
||||
COUNT(*) as bet_count,
|
||||
ROW_NUMBER() OVER (PARTITION BY DATE(b.created_at) ORDER BY COUNT(*) DESC) as rank
|
||||
FROM bets b
|
||||
JOIN bet_outcomes bo ON b.id = bo.bet_id
|
||||
WHERE 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++
|
||||
}
|
||||
if filter.Status.Valid {
|
||||
query += fmt.Sprintf(" AND b.status = $%d", argPos)
|
||||
args = append(args, filter.Status.Value)
|
||||
argPos++
|
||||
}
|
||||
|
||||
query += ` GROUP BY DATE(b.created_at), bo.market_name
|
||||
)
|
||||
SELECT date, market_name FROM market_counts WHERE rank = 1`
|
||||
|
||||
rows, err := s.conn.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query market popularity: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
popularity := make(map[time.Time]string)
|
||||
for rows.Next() {
|
||||
var date time.Time
|
||||
var marketName string
|
||||
if err := rows.Scan(&date, &marketName); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan market popularity: %w", err)
|
||||
}
|
||||
popularity[date] = marketName
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
return popularity, nil
|
||||
}
|
||||
|
||||
// GetExtremeValues returns the highest stake and payout by date
|
||||
func (s *Store) GetExtremeValues(ctx context.Context, filter domain.ReportFilter) (map[time.Time]domain.ExtremeValues, error) {
|
||||
query := `SELECT
|
||||
DATE(created_at) as date,
|
||||
MAX(amount) as highest_stake,
|
||||
MAX(CASE WHEN status = 1 THEN amount * total_odds ELSE 0 END) as highest_payout
|
||||
FROM bets`
|
||||
|
||||
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.BranchID.Valid {
|
||||
query += fmt.Sprintf(" AND %sbranch_id = $%d", func() string {
|
||||
if len(args) == 0 {
|
||||
return " WHERE "
|
||||
}
|
||||
return " AND "
|
||||
}(), argPos)
|
||||
args = append(args, filter.BranchID.Value)
|
||||
argPos++
|
||||
}
|
||||
if filter.UserID.Valid {
|
||||
query += fmt.Sprintf(" AND %suser_id = $%d", func() string {
|
||||
if len(args) == 0 {
|
||||
return " WHERE "
|
||||
}
|
||||
return " AND "
|
||||
}(), 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 " 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++
|
||||
}
|
||||
if filter.Status.Valid {
|
||||
query += fmt.Sprintf(" AND %sstatus = $%d", func() string {
|
||||
if len(args) == 0 {
|
||||
return " WHERE "
|
||||
}
|
||||
return " AND "
|
||||
}(), argPos)
|
||||
args = append(args, filter.Status.Value)
|
||||
argPos++
|
||||
}
|
||||
|
||||
query += " GROUP BY DATE(created_at)"
|
||||
|
||||
rows, err := s.conn.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query extreme values: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
extremes := make(map[time.Time]domain.ExtremeValues)
|
||||
for rows.Next() {
|
||||
var date time.Time
|
||||
var extreme domain.ExtremeValues
|
||||
if err := rows.Scan(&date, &extreme.HighestStake, &extreme.HighestPayout); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan extreme values: %w", err)
|
||||
}
|
||||
extremes[date] = extreme
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
return extremes, nil
|
||||
}
|
||||
|
||||
// GetCustomerBetActivity returns bet activity by customer
|
||||
func (s *Store) GetCustomerBetActivity(ctx context.Context, filter domain.ReportFilter) ([]domain.CustomerBetActivity, error) {
|
||||
query := `SELECT
|
||||
user_id as customer_id,
|
||||
COUNT(*) as total_bets,
|
||||
COALESCE(SUM(amount), 0) as total_stakes,
|
||||
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as total_wins,
|
||||
COALESCE(SUM(CASE WHEN status = 1 THEN amount * total_odds ELSE 0 END), 0) as total_payouts,
|
||||
MIN(created_at) as first_bet_date,
|
||||
MAX(created_at) as last_bet_date,
|
||||
AVG(total_odds) as average_odds
|
||||
FROM bets
|
||||
WHERE user_id IS NOT NULL`
|
||||
|
||||
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 branch_id = $%d", argPos)
|
||||
args = append(args, filter.BranchID.Value)
|
||||
argPos++
|
||||
}
|
||||
if filter.UserID.Valid {
|
||||
query += fmt.Sprintf(" AND user_id = $%d", argPos)
|
||||
args = append(args, filter.UserID.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++
|
||||
}
|
||||
if filter.Status.Valid {
|
||||
query += fmt.Sprintf(" AND status = $%d", argPos)
|
||||
args = append(args, filter.Status.Value)
|
||||
argPos++
|
||||
}
|
||||
|
||||
query += " GROUP BY user_id"
|
||||
|
||||
rows, err := s.conn.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query customer bet activity: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var activities []domain.CustomerBetActivity
|
||||
for rows.Next() {
|
||||
var activity domain.CustomerBetActivity
|
||||
if err := rows.Scan(
|
||||
&activity.CustomerID,
|
||||
&activity.TotalBets,
|
||||
&activity.TotalStakes,
|
||||
&activity.TotalWins,
|
||||
&activity.TotalPayouts,
|
||||
&activity.FirstBetDate,
|
||||
&activity.LastBetDate,
|
||||
&activity.AverageOdds,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan customer bet activity: %w", err)
|
||||
}
|
||||
activities = append(activities, activity)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
// GetBranchBetActivity returns bet activity by branch
|
||||
func (s *Store) GetBranchBetActivity(ctx context.Context, filter domain.ReportFilter) ([]domain.BranchBetActivity, error) {
|
||||
query := `SELECT
|
||||
branch_id,
|
||||
COUNT(*) as total_bets,
|
||||
COALESCE(SUM(amount), 0) as total_stakes,
|
||||
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as total_wins,
|
||||
COALESCE(SUM(CASE WHEN status = 1 THEN amount * total_odds ELSE 0 END), 0) as total_payouts
|
||||
FROM bets
|
||||
WHERE branch_id IS NOT NULL`
|
||||
|
||||
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 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++
|
||||
}
|
||||
if filter.Status.Valid {
|
||||
query += fmt.Sprintf(" AND status = $%d", argPos)
|
||||
args = append(args, filter.Status.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 bet activity: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var activities []domain.BranchBetActivity
|
||||
for rows.Next() {
|
||||
var activity domain.BranchBetActivity
|
||||
if err := rows.Scan(
|
||||
&activity.BranchID,
|
||||
&activity.TotalBets,
|
||||
&activity.TotalStakes,
|
||||
&activity.TotalWins,
|
||||
&activity.TotalPayouts,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan branch bet activity: %w", err)
|
||||
}
|
||||
activities = append(activities, activity)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
// GetSportBetActivity returns bet activity by sport
|
||||
func (s *Store) GetSportBetActivity(ctx context.Context, filter domain.ReportFilter) ([]domain.SportBetActivity, error) {
|
||||
query := `SELECT
|
||||
bo.sport_id,
|
||||
COUNT(*) as total_bets,
|
||||
COALESCE(SUM(b.amount), 0) as total_stakes,
|
||||
SUM(CASE WHEN b.status = 1 THEN 1 ELSE 0 END) as total_wins,
|
||||
COALESCE(SUM(CASE WHEN b.status = 1 THEN b.amount * b.total_odds ELSE 0 END), 0) as total_payouts,
|
||||
AVG(b.total_odds) as average_odds
|
||||
FROM bets b
|
||||
JOIN bet_outcomes bo ON b.id = bo.bet_id
|
||||
WHERE bo.sport_id 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++
|
||||
}
|
||||
if filter.Status.Valid {
|
||||
query += fmt.Sprintf(" AND b.status = $%d", argPos)
|
||||
args = append(args, filter.Status.Value)
|
||||
argPos++
|
||||
}
|
||||
|
||||
query += " GROUP BY bo.sport_id"
|
||||
|
||||
rows, err := s.conn.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query sport bet activity: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var activities []domain.SportBetActivity
|
||||
for rows.Next() {
|
||||
var activity domain.SportBetActivity
|
||||
if err := rows.Scan(
|
||||
&activity.SportID,
|
||||
&activity.TotalBets,
|
||||
&activity.TotalStakes,
|
||||
&activity.TotalWins,
|
||||
&activity.TotalPayouts,
|
||||
&activity.AverageOdds,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan sport bet activity: %w", err)
|
||||
}
|
||||
activities = append(activities, activity)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
// GetSportDetails returns sport names by ID
|
||||
func (s *Store) GetSportDetails(ctx context.Context, filter domain.ReportFilter) (map[string]string, error) {
|
||||
query := `SELECT DISTINCT bo.sport_id, e.match_name
|
||||
FROM bet_outcomes bo
|
||||
JOIN events e ON bo.event_id = e.id::bigint
|
||||
WHERE bo.sport_id IS NOT NULL`
|
||||
|
||||
args := []interface{}{}
|
||||
argPos := 1
|
||||
|
||||
// Add filters if provided
|
||||
if filter.StartTime.Valid {
|
||||
query += fmt.Sprintf(" AND bo.created_at >= $%d", argPos)
|
||||
args = append(args, filter.StartTime.Value)
|
||||
argPos++
|
||||
}
|
||||
if filter.EndTime.Valid {
|
||||
query += fmt.Sprintf(" AND bo.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 sport details: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
details := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var sportID, matchName string
|
||||
if err := rows.Scan(&sportID, &matchName); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan sport detail: %w", err)
|
||||
}
|
||||
details[sportID] = matchName
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
return details, nil
|
||||
}
|
||||
|
||||
// GetSportMarketPopularity returns most popular market by sport
|
||||
func (s *Store) GetSportMarketPopularity(ctx context.Context, filter domain.ReportFilter) (map[string]string, error) {
|
||||
query := `WITH market_counts AS (
|
||||
SELECT
|
||||
bo.sport_id,
|
||||
bo.market_name,
|
||||
COUNT(*) as bet_count,
|
||||
ROW_NUMBER() OVER (PARTITION BY bo.sport_id ORDER BY COUNT(*) DESC) as rank
|
||||
FROM bets b
|
||||
JOIN bet_outcomes bo ON b.id = bo.bet_id
|
||||
WHERE bo.sport_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++
|
||||
}
|
||||
if filter.Status.Valid {
|
||||
query += fmt.Sprintf(" AND b.status = $%d", argPos)
|
||||
args = append(args, filter.Status.Value)
|
||||
argPos++
|
||||
}
|
||||
|
||||
query += ` GROUP BY bo.sport_id, bo.market_name
|
||||
)
|
||||
SELECT sport_id, market_name FROM market_counts WHERE rank = 1`
|
||||
|
||||
rows, err := s.conn.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query sport market popularity: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
popularity := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var sportID, marketName string
|
||||
if err := rows.Scan(&sportID, &marketName); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan sport market popularity: %w", err)
|
||||
}
|
||||
popularity[sportID] = marketName
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
return popularity, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package repository
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
|
||||
|
|
@ -256,3 +257,158 @@ func (s *Store) DeleteBranchCashier(ctx context.Context, userID int64) error {
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
type NotificationRepository interface {
|
||||
|
|
@ -27,6 +28,10 @@ func NewNotificationRepository(store *Store) NotificationRepository {
|
|||
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) {
|
||||
var errorSeverity pgtype.Text
|
||||
if notification.ErrorSeverity != nil {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
// "golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
|
|
@ -49,3 +50,12 @@ func (s *Store) BeginTx(ctx context.Context) (*dbgen.Queries, pgx.Tx, error) {
|
|||
q := s.queries.WithTx(tx)
|
||||
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
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package repository
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
|
||||
|
|
@ -139,3 +140,106 @@ func (s *Store) UpdateTransactionVerified(ctx context.Context, id int64, verifie
|
|||
})
|
||||
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 = 'deposit' THEN amount ELSE 0 END), 0) as deposits,
|
||||
COALESCE(SUM(CASE WHEN type = 'withdrawal' 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 = 'deposit' THEN amount ELSE 0 END), 0) as deposits,
|
||||
COALESCE(SUM(CASE WHEN type = 'withdrawal' 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -446,3 +446,250 @@ func (s *Store) CreateUserWithoutOtp(ctx context.Context, user domain.User, is_c
|
|||
Suspended: userRes.Suspended,
|
||||
}, 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package repository
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
|
||||
|
|
@ -163,3 +164,64 @@ func (s *Store) UpdateWalletActive(ctx context.Context, id int64, isActive bool)
|
|||
})
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package bet
|
|||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
|
||||
)
|
||||
|
|
@ -20,4 +21,24 @@ type BetStore interface {
|
|||
UpdateStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error
|
||||
UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,4 +23,10 @@ type BranchStore interface {
|
|||
CreateBranchCashier(ctx context.Context, branchID int64, userID int64) error
|
||||
GetBranchByCashier(ctx context.Context, userID int64) (domain.Branch, error)
|
||||
DeleteBranchCashier(ctx context.Context, userID int64) error
|
||||
|
||||
GetBranchCounts(ctx context.Context, filter domain.ReportFilter) (total, active int64, err error)
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,3 +70,11 @@ func (s *Service) DeleteBranchOperation(ctx context.Context, branchID int64, ope
|
|||
func (s *Service) DeleteBranchCashier(ctx context.Context, userID int64) error {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,13 +63,16 @@ func (c *Client) IssuePayment(ctx context.Context, payload domain.ChapaTransferP
|
|||
|
||||
// service/chapa_service.go
|
||||
func (c *Client) InitPayment(ctx context.Context, req domain.InitPaymentRequest) (string, error) {
|
||||
fmt.Println("\n\nInit payment request: ", req)
|
||||
payloadBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
fmt.Println("\n\nWe are here")
|
||||
return "", fmt.Errorf("failed to serialize payload: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/transaction/initialize", bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
fmt.Println("\n\nWe are here 2")
|
||||
return "", fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -78,12 +81,14 @@ func (c *Client) InitPayment(ctx context.Context, req domain.InitPaymentRequest)
|
|||
|
||||
resp, err := c.HTTPClient.Do(httpReq)
|
||||
if err != nil {
|
||||
fmt.Println("\n\nWe are here 3")
|
||||
return "", fmt.Errorf("chapa HTTP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
fmt.Println("\n\nWe are here 4")
|
||||
return "", fmt.Errorf("chapa error: status %d, body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +98,8 @@ func (c *Client) InitPayment(ctx context.Context, req domain.InitPaymentRequest)
|
|||
} `json:"data"`
|
||||
}
|
||||
|
||||
fmt.Printf("\n\nInit payment response body: %v\n\n", response)
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return "", fmt.Errorf("failed to parse chapa response: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
// "log/slog"
|
||||
"strconv"
|
||||
|
|
@ -116,6 +117,7 @@ func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.Chap
|
|||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if txn.Verified {
|
||||
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)
|
||||
}
|
||||
|
||||
branch, err := s.branchStore.GetBranchByID(ctx, req.BranchID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
banks, err := s.GetSupportedBanks()
|
||||
validBank := false
|
||||
for _, bank := range banks {
|
||||
if strconv.FormatInt(bank.Id, 10) == req.BankCode {
|
||||
validBank = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetWallet == nil {
|
||||
return fmt.Errorf("no wallet found with the specified ID")
|
||||
if !validBank {
|
||||
return fmt.Errorf("invalid bank code")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
|
|
@ -229,13 +243,13 @@ func (s *Service) WithdrawUsingChapa(ctx context.Context, userID int64, req doma
|
|||
BeneficiaryName: req.BeneficiaryName,
|
||||
PaymentOption: domain.PaymentOption(domain.BANK),
|
||||
BranchID: req.BranchID,
|
||||
BranchName: branch.Name,
|
||||
BranchLocation: branch.Location,
|
||||
// BranchName: branch.Name,
|
||||
// BranchLocation: branch.Location,
|
||||
// CashierID: user.ID,
|
||||
// CashierName: user.FullName,
|
||||
FullName: user.FirstName + " " + user.LastName,
|
||||
PhoneNumber: user.PhoneNumber,
|
||||
CompanyID: branch.CompanyID,
|
||||
// CompanyID: branch.CompanyID,
|
||||
})
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
if req.Amount <= 0 {
|
||||
return "", fmt.Errorf("amount must be positive")
|
||||
}
|
||||
|
||||
user, err := s.userStore.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -269,20 +287,22 @@ func (s *Service) DepositUsingChapa(ctx context.Context, userID int64, req domai
|
|||
|
||||
txID := uuid.New().String()
|
||||
|
||||
_, err = s.transactionStore.CreateTransaction(ctx, domain.CreateTransaction{
|
||||
Amount: req.Amount,
|
||||
Type: domain.TransactionType(domain.TRANSACTION_DEPOSIT),
|
||||
ReferenceNumber: txID,
|
||||
BranchID: req.BranchID,
|
||||
BranchName: branch.Name,
|
||||
BranchLocation: branch.Location,
|
||||
FullName: user.FirstName + " " + user.LastName,
|
||||
PhoneNumber: user.PhoneNumber,
|
||||
CompanyID: branch.CompanyID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Printf("\n\nChapa deposit transaction created: %v%v\n\n", branch, user)
|
||||
|
||||
// _, err = s.transactionStore.CreateTransaction(ctx, domain.CreateTransaction{
|
||||
// Amount: req.Amount,
|
||||
// Type: domain.TransactionType(domain.TRANSACTION_DEPOSIT),
|
||||
// ReferenceNumber: txID,
|
||||
// BranchID: req.BranchID,
|
||||
// BranchName: branch.Name,
|
||||
// BranchLocation: branch.Location,
|
||||
// FullName: user.FirstName + " " + user.LastName,
|
||||
// PhoneNumber: user.PhoneNumber,
|
||||
// // CompanyID: branch.CompanyID,
|
||||
// })
|
||||
// if err != nil {
|
||||
// return "", err
|
||||
// }
|
||||
|
||||
// Fetch user details for Chapa payment
|
||||
userInfo, err := s.userStore.GetUserByID(ctx, userID)
|
||||
|
|
@ -290,6 +310,8 @@ func (s *Service) DepositUsingChapa(ctx context.Context, userID int64, req domai
|
|||
return "", err
|
||||
}
|
||||
|
||||
// fmt.Printf("\n\nCallbackURL is:%v\n\n", s.config.CHAPA_CALLBACK_URL)
|
||||
|
||||
// Build Chapa InitPaymentRequest (matches Chapa API)
|
||||
paymentReq := domain.InitPaymentRequest{
|
||||
Amount: req.Amount,
|
||||
|
|
@ -298,14 +320,19 @@ func (s *Service) DepositUsingChapa(ctx context.Context, userID int64, req domai
|
|||
FirstName: userInfo.FirstName,
|
||||
LastName: userInfo.LastName,
|
||||
TxRef: txID,
|
||||
CallbackURL: s.config.CHAPA_CALLBACK_URL,
|
||||
ReturnURL: s.config.CHAPA_RETURN_URL,
|
||||
CallbackURL: "https://fortunebet.com/api/v1/payments/callback",
|
||||
ReturnURL: "https://fortunebet.com/api/v1/payment-success",
|
||||
}
|
||||
|
||||
// Call Chapa to initialize payment
|
||||
paymentURL, err := s.chapaClient.InitPayment(ctx, paymentReq)
|
||||
if err != nil {
|
||||
return "", err
|
||||
var paymentURL string
|
||||
maxRetries := 3
|
||||
for range maxRetries {
|
||||
paymentURL, err = s.chapaClient.InitPayment(ctx, paymentReq)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(1 * time.Second) // Backoff
|
||||
}
|
||||
|
||||
// Commit DB transaction
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error {
|
|||
|
||||
eventID, err := strconv.ParseInt(event.ID, 10, 64)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to parse event id")
|
||||
s.logger.Error("Failed to parse event id", "error", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +58,7 @@ func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error {
|
|||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
log.Printf("❌ Failed to create request for event %d: %v", eventID, err)
|
||||
s.logger.Error("Failed to create request for event%d: %v", strconv.FormatInt(eventID, 10), err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
|
|||
15
internal/services/report/port.go
Normal file
15
internal/services/report/port.go
Normal 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)
|
||||
}
|
||||
509
internal/services/report/service.go
Normal file
509
internal/services/report/service.go
Normal 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
|
|
@ -12,4 +12,7 @@ type TransactionStore interface {
|
|||
GetAllTransactions(ctx context.Context, filter domain.TransactionFilter) ([]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
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ type UserStore interface {
|
|||
GetUserByPhone(ctx context.Context, phoneNum string) (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
|
||||
|
||||
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 {
|
||||
SendSMSOTP(ctx context.Context, phoneNumber, otp string) error
|
||||
|
|
|
|||
216
internal/services/wallet/monitor/service.go
Normal file
216
internal/services/wallet/monitor/service.go
Normal 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,
|
||||
)
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ type WalletStore interface {
|
|||
GetAllBranchWallets(ctx context.Context) ([]domain.BranchWallet, error)
|
||||
UpdateBalance(ctx context.Context, id int64, balance domain.Currency) error
|
||||
UpdateWalletActive(ctx context.Context, id int64, isActive bool) error
|
||||
|
||||
GetBalanceSummary(ctx context.Context, filter domain.ReportFilter) (domain.BalanceSummary, error)
|
||||
}
|
||||
|
||||
type TransferStore interface {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
package wallet
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
walletStore WalletStore
|
||||
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{
|
||||
walletStore: walletStore,
|
||||
transferStore: transferStore,
|
||||
notificationStore: notificationStore,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,96 @@ package wallet
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation"
|
||||
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/ticket"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction"
|
||||
|
|
@ -45,6 +46,7 @@ type App struct {
|
|||
userSvc *user.Service
|
||||
betSvc *bet.Service
|
||||
virtualGameSvc virtualgameservice.VirtualGameService
|
||||
reportSvc *report.Service
|
||||
chapaSvc *chapa.Service
|
||||
walletSvc *wallet.Service
|
||||
transactionSvc *transaction.Service
|
||||
|
|
@ -67,6 +69,7 @@ func NewApp(
|
|||
userSvc *user.Service,
|
||||
ticketSvc *ticket.Service,
|
||||
betSvc *bet.Service,
|
||||
reportSvc *report.Service,
|
||||
chapaSvc *chapa.Service,
|
||||
walletSvc *wallet.Service,
|
||||
transactionSvc *transaction.Service,
|
||||
|
|
@ -107,6 +110,7 @@ func NewApp(
|
|||
userSvc: userSvc,
|
||||
ticketSvc: ticketSvc,
|
||||
betSvc: betSvc,
|
||||
reportSvc: reportSvc,
|
||||
chapaSvc: chapaSvc,
|
||||
walletSvc: walletSvc,
|
||||
transactionSvc: transactionSvc,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation"
|
||||
referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/report"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/user"
|
||||
|
|
@ -30,6 +31,7 @@ type Handler struct {
|
|||
notificationSvc *notificationservice.Service
|
||||
userSvc *user.Service
|
||||
referralSvc referralservice.ReferralStore
|
||||
reportSvc report.ReportStore
|
||||
chapaSvc chapa.ChapaPort
|
||||
walletSvc *wallet.Service
|
||||
transactionSvc *transaction.Service
|
||||
|
|
@ -53,6 +55,7 @@ func New(
|
|||
logger *slog.Logger,
|
||||
notificationSvc *notificationservice.Service,
|
||||
validator *customvalidator.CustomValidator,
|
||||
reportSvc report.ReportStore,
|
||||
chapaSvc chapa.ChapaPort,
|
||||
walletSvc *wallet.Service,
|
||||
referralSvc referralservice.ReferralStore,
|
||||
|
|
@ -75,6 +78,7 @@ func New(
|
|||
return &Handler{
|
||||
logger: logger,
|
||||
notificationSvc: notificationSvc,
|
||||
reportSvc: reportSvc,
|
||||
chapaSvc: chapaSvc,
|
||||
walletSvc: walletSvc,
|
||||
referralSvc: referralSvc,
|
||||
|
|
|
|||
116
internal/web_server/handlers/report.go
Normal file
116
internal/web_server/handlers/report.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
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(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
|
||||
}
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
_ "github.com/SamuelTariku/FortuneBet-Backend/docs"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger"
|
||||
|
||||
// "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet/monitor"
|
||||
|
||||
"github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/handlers"
|
||||
|
||||
|
|
@ -18,6 +22,7 @@ func (a *App) initAppRoutes() {
|
|||
a.logger,
|
||||
a.NotidicationStore,
|
||||
a.validator,
|
||||
a.reportSvc,
|
||||
a.chapaSvc,
|
||||
a.walletSvc,
|
||||
a.referralSvc,
|
||||
|
|
@ -187,6 +192,22 @@ func (a *App) initAppRoutes() {
|
|||
group.Post("/chapa/payments/deposit", a.authMiddleware, h.DepositUsingChapa)
|
||||
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.Get("/chapa/payments/verify/:tx_ref", h.VerifyTransaction)
|
||||
// group.Post("/chapa/payments/callback", h.ReceiveWebhook)
|
||||
|
|
@ -202,6 +223,10 @@ func (a *App) initAppRoutes() {
|
|||
group.Get("/veli-games/launch", h.LaunchVeliGame)
|
||||
group.Post("/webhooks/veli-games", h.HandleVeliCallback)
|
||||
|
||||
//mongoDB logs
|
||||
ctx := context.Background()
|
||||
group.Get("/logs", mongoLogger.GetLogsHandler(ctx))
|
||||
|
||||
// Recommendation Routes
|
||||
group.Get("/virtual-games/recommendations/:userID", h.GetRecommendations)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user