From b0803c968ac2b7ddefdaae254c3abf5077aeb739 Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Thu, 19 Jun 2025 19:11:19 +0300 Subject: [PATCH] fix: moving ticket logic into service --- cmd/main.go | 2 +- db/migrations/000001_fortune.down.sql | 4 +- db/migrations/000001_fortune.up.sql | 6 + db/migrations/000007_setting_data.up.sql | 5 + db/query/settings.sql | 9 + docs/docs.go | 405 +++++++----------- docs/swagger.json | 405 +++++++----------- docs/swagger.yaml | 275 +++++------- gen/db/models.go | 7 + gen/db/settings.sql.go | 65 +++ internal/domain/settings.go | 15 + internal/domain/ticket.go | 28 ++ internal/repository/settings.go | 49 +++ internal/services/bet/service.go | 22 +- internal/services/settings/port.go | 12 + internal/services/settings/service.go | 25 ++ internal/services/ticket/service.go | 213 ++++++++- .../web_server/handlers/ticket_handler.go | 168 +------- 18 files changed, 874 insertions(+), 841 deletions(-) create mode 100644 db/migrations/000007_setting_data.up.sql create mode 100644 db/query/settings.sql create mode 100644 gen/db/settings.sql.go create mode 100644 internal/domain/settings.go create mode 100644 internal/repository/settings.go create mode 100644 internal/services/settings/port.go create mode 100644 internal/services/settings/service.go diff --git a/cmd/main.go b/cmd/main.go index 5331325..c430131 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -102,7 +102,6 @@ func main() { userSvc := user.NewService(store, store, cfg) eventSvc := event.New(cfg.Bet365Token, store) oddsSvc := odds.New(store, cfg, logger) - ticketSvc := ticket.NewService(store) notificationRepo := repository.NewNotificationRepository(store) virtuaGamesRepo := repository.NewVirtualGameRepository(store) notificationSvc := notificationservice.New(notificationRepo, logger, cfg) @@ -120,6 +119,7 @@ func main() { branchSvc := branch.NewService(store) companySvc := company.NewService(store) leagueSvc := league.New(store) + ticketSvc := ticket.NewService(store, eventSvc, *oddsSvc, domain.MongoDBLogger) betSvc := bet.NewService(store, eventSvc, *oddsSvc, *walletSvc, *branchSvc, logger, domain.MongoDBLogger) resultSvc := result.NewService(store, cfg, logger, *betSvc, *oddsSvc, eventSvc, leagueSvc, notificationSvc) referalRepo := repository.NewReferralRepository(store) diff --git a/db/migrations/000001_fortune.down.sql b/db/migrations/000001_fortune.down.sql index 2724f06..15b0598 100644 --- a/db/migrations/000001_fortune.down.sql +++ b/db/migrations/000001_fortune.down.sql @@ -76,4 +76,6 @@ DROP TABLE IF EXISTS refresh_tokens; DROP TABLE IF EXISTS otps; DROP TABLE IF EXISTS odds; DROP TABLE IF EXISTS events; -DROP TABLE IF EXISTS leagues; \ No newline at end of file +DROP TABLE IF EXISTS leagues; +DROP TABLE IF EXISTS teams; +DROP TABLE IF EXISTS settings; \ No newline at end of file diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 24ee6ae..8cf8b0f 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -264,6 +264,12 @@ CREATE TABLE teams ( bet365_id INT, logo_url TEXT ); +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); -- Views CREATE VIEW companies_details AS SELECT companies.*, diff --git a/db/migrations/000007_setting_data.up.sql b/db/migrations/000007_setting_data.up.sql new file mode 100644 index 0000000..d01ab65 --- /dev/null +++ b/db/migrations/000007_setting_data.up.sql @@ -0,0 +1,5 @@ +-- Settings Initial Data +INSERT INTO settings (key, value) +VALUES ('total_winnings_limit', '1000000') ON CONFLICT (key) DO +UPDATE +SET value = EXCLUDED.value; \ No newline at end of file diff --git a/db/query/settings.sql b/db/query/settings.sql new file mode 100644 index 0000000..f8a1e31 --- /dev/null +++ b/db/query/settings.sql @@ -0,0 +1,9 @@ +-- name: GetSettings :many +SELECT * +from settings; +-- name: SaveSetting :one +INSERT INTO settings (key, value, updated_at) +VALUES ($1, $2, CURRENT_TIMESTAMP) ON CONFLICT (key) DO +UPDATE +SET value = EXCLUDED.value +RETURNING *; \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index f8825e0..9e8338a 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -811,76 +811,6 @@ const docTemplate = `{ } } }, - "/api/veli/launch/{game_id}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Generates authenticated launch URL for Veli games", - "tags": [ - "Veli Games" - ], - "summary": "Launch a Veli game", - "parameters": [ - { - "type": "string", - "description": "Game ID (e.g., veli_aviator_v1)", - "name": "game_id", - "in": "path", - "required": true - }, - { - "type": "string", - "default": "USD", - "description": "Currency code", - "name": "currency", - "in": "query" - }, - { - "enum": [ - "real", - "demo" - ], - "type": "string", - "default": "real", - "description": "Game mode", - "name": "mode", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Returns launch URL", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "400": { - "description": "Invalid request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal server error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, "/auth/login": { "post": { "description": "Login customer", @@ -3377,7 +3307,7 @@ const docTemplate = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/handlers.TicketRes" + "$ref": "#/definitions/domain.TicketRes" } } }, @@ -3414,7 +3344,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.CreateTicketReq" + "$ref": "#/definitions/domain.CreateTicketReq" } } ], @@ -3422,7 +3352,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.CreateTicketRes" + "$ref": "#/definitions/domain.CreateTicketRes" } }, "400": { @@ -3466,7 +3396,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.TicketRes" + "$ref": "#/definitions/domain.TicketRes" } }, "400": { @@ -3484,6 +3414,38 @@ const docTemplate = `{ } } }, + "/top-leagues": { + "get": { + "description": "Retrieve all top leagues", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "prematch" + ], + "summary": "Retrieve all top leagues", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.UpcomingEvent" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/transaction": { "get": { "description": "Gets all the transactions", @@ -4577,70 +4539,6 @@ const docTemplate = `{ } } } - }, - "/webhooks/veli": { - "post": { - "description": "Processes game round settlements from Veli", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Veli Games" - ], - "summary": "Veli Games webhook handler", - "parameters": [ - { - "description": "Callback payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.VeliCallback" - } - } - ], - "responses": { - "200": { - "description": "Callback processed", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "400": { - "description": "Invalid payload", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "403": { - "description": "Invalid signature", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Processing error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } } }, "definitions": { @@ -4956,6 +4854,52 @@ const docTemplate = `{ } } }, + "domain.CreateTicketOutcomeReq": { + "type": "object", + "properties": { + "event_id": { + "description": "TicketID int64 ` + "`" + `json:\"ticket_id\" example:\"1\"` + "`" + `", + "type": "integer", + "example": 1 + }, + "market_id": { + "type": "integer", + "example": 1 + }, + "odd_id": { + "type": "integer", + "example": 1 + } + } + }, + "domain.CreateTicketReq": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.CreateTicketOutcomeReq" + } + } + } + }, + "domain.CreateTicketRes": { + "type": "object", + "properties": { + "created_number": { + "type": "integer", + "example": 3 + }, + "fast_code": { + "type": "integer", + "example": 1234 + } + } + }, "domain.DashboardSummary": { "type": "object", "properties": { @@ -5132,6 +5076,10 @@ const docTemplate = `{ "type": "boolean", "example": false }, + "is_featured": { + "type": "boolean", + "example": false + }, "name": { "type": "string", "example": "BPL" @@ -5193,6 +5141,17 @@ const docTemplate = `{ } } }, + "domain.OtpProvider": { + "type": "string", + "enum": [ + "twilio", + "aformessage" + ], + "x-enum-varnames": [ + "TwilioSms", + "AfroMessage" + ] + }, "domain.OutcomeStatus": { "type": "integer", "enum": [ @@ -5482,6 +5441,29 @@ const docTemplate = `{ } } }, + "domain.TicketRes": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "id": { + "type": "integer", + "example": 1 + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.TicketOutcome" + } + }, + "total_odds": { + "type": "number", + "example": 4.22 + } + } + }, "domain.UpcomingEvent": { "type": "object", "properties": { @@ -5551,51 +5533,6 @@ const docTemplate = `{ } } }, - "domain.VeliCallback": { - "type": "object", - "properties": { - "amount": { - "description": "Transaction amount", - "type": "number" - }, - "currency": { - "description": "e.g., \"USD\"", - "type": "string" - }, - "event_type": { - "description": "\"bet_placed\", \"game_result\", etc.", - "type": "string" - }, - "game_id": { - "description": "e.g., \"veli_aviator_v1\"", - "type": "string" - }, - "multiplier": { - "description": "For games with multipliers (Aviator/Plinko)", - "type": "number" - }, - "round_id": { - "description": "Unique round identifier (replaces transaction_id)", - "type": "string" - }, - "session_id": { - "description": "Matches VirtualGameSession.SessionToken", - "type": "string" - }, - "signature": { - "description": "HMAC-SHA256", - "type": "string" - }, - "timestamp": { - "description": "Unix timestamp", - "type": "integer" - }, - "user_id": { - "description": "Veli's user identifier", - "type": "string" - } - } - }, "domain.VirtualGame": { "type": "object", "properties": { @@ -5994,52 +5931,6 @@ const docTemplate = `{ } } }, - "handlers.CreateTicketOutcomeReq": { - "type": "object", - "properties": { - "event_id": { - "description": "TicketID int64 ` + "`" + `json:\"ticket_id\" example:\"1\"` + "`" + `", - "type": "integer", - "example": 1 - }, - "market_id": { - "type": "integer", - "example": 1 - }, - "odd_id": { - "type": "integer", - "example": 1 - } - } - }, - "handlers.CreateTicketReq": { - "type": "object", - "properties": { - "amount": { - "type": "number", - "example": 100 - }, - "outcomes": { - "type": "array", - "items": { - "$ref": "#/definitions/handlers.CreateTicketOutcomeReq" - } - } - } - }, - "handlers.CreateTicketRes": { - "type": "object", - "properties": { - "created_number": { - "type": "integer", - "example": 3 - }, - "fast_code": { - "type": "integer", - "example": 1234 - } - } - }, "handlers.CreateTransactionReq": { "type": "object", "properties": { @@ -6249,6 +6140,9 @@ const docTemplate = `{ }, "handlers.RegisterCodeReq": { "type": "object", + "required": [ + "provider" + ], "properties": { "email": { "type": "string", @@ -6257,11 +6151,22 @@ const docTemplate = `{ "phone_number": { "type": "string", "example": "1234567890" + }, + "provider": { + "allOf": [ + { + "$ref": "#/definitions/domain.OtpProvider" + } + ], + "example": "twilio" } } }, "handlers.RegisterUserReq": { "type": "object", + "required": [ + "provider" + ], "properties": { "email": { "type": "string", @@ -6287,6 +6192,14 @@ const docTemplate = `{ "type": "string", "example": "1234567890" }, + "provider": { + "allOf": [ + { + "$ref": "#/definitions/domain.OtpProvider" + } + ], + "example": "twilio" + }, "referal_code": { "type": "string", "example": "ABC123" @@ -6295,6 +6208,9 @@ const docTemplate = `{ }, "handlers.ResetCodeReq": { "type": "object", + "required": [ + "provider" + ], "properties": { "email": { "type": "string", @@ -6303,6 +6219,14 @@ const docTemplate = `{ "phone_number": { "type": "string", "example": "1234567890" + }, + "provider": { + "allOf": [ + { + "$ref": "#/definitions/domain.OtpProvider" + } + ], + "example": "twilio" } } }, @@ -6371,29 +6295,6 @@ const docTemplate = `{ } } }, - "handlers.TicketRes": { - "type": "object", - "properties": { - "amount": { - "type": "number", - "example": 100 - }, - "id": { - "type": "integer", - "example": 1 - }, - "outcomes": { - "type": "array", - "items": { - "$ref": "#/definitions/domain.TicketOutcome" - } - }, - "total_odds": { - "type": "number", - "example": 4.22 - } - } - }, "handlers.TransactionRes": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 1bb4270..0d51eec 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -803,76 +803,6 @@ } } }, - "/api/veli/launch/{game_id}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Generates authenticated launch URL for Veli games", - "tags": [ - "Veli Games" - ], - "summary": "Launch a Veli game", - "parameters": [ - { - "type": "string", - "description": "Game ID (e.g., veli_aviator_v1)", - "name": "game_id", - "in": "path", - "required": true - }, - { - "type": "string", - "default": "USD", - "description": "Currency code", - "name": "currency", - "in": "query" - }, - { - "enum": [ - "real", - "demo" - ], - "type": "string", - "default": "real", - "description": "Game mode", - "name": "mode", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Returns launch URL", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "400": { - "description": "Invalid request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal server error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, "/auth/login": { "post": { "description": "Login customer", @@ -3369,7 +3299,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/handlers.TicketRes" + "$ref": "#/definitions/domain.TicketRes" } } }, @@ -3406,7 +3336,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.CreateTicketReq" + "$ref": "#/definitions/domain.CreateTicketReq" } } ], @@ -3414,7 +3344,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.CreateTicketRes" + "$ref": "#/definitions/domain.CreateTicketRes" } }, "400": { @@ -3458,7 +3388,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.TicketRes" + "$ref": "#/definitions/domain.TicketRes" } }, "400": { @@ -3476,6 +3406,38 @@ } } }, + "/top-leagues": { + "get": { + "description": "Retrieve all top leagues", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "prematch" + ], + "summary": "Retrieve all top leagues", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.UpcomingEvent" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/transaction": { "get": { "description": "Gets all the transactions", @@ -4569,70 +4531,6 @@ } } } - }, - "/webhooks/veli": { - "post": { - "description": "Processes game round settlements from Veli", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Veli Games" - ], - "summary": "Veli Games webhook handler", - "parameters": [ - { - "description": "Callback payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.VeliCallback" - } - } - ], - "responses": { - "200": { - "description": "Callback processed", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "400": { - "description": "Invalid payload", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "403": { - "description": "Invalid signature", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Processing error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } } }, "definitions": { @@ -4948,6 +4846,52 @@ } } }, + "domain.CreateTicketOutcomeReq": { + "type": "object", + "properties": { + "event_id": { + "description": "TicketID int64 `json:\"ticket_id\" example:\"1\"`", + "type": "integer", + "example": 1 + }, + "market_id": { + "type": "integer", + "example": 1 + }, + "odd_id": { + "type": "integer", + "example": 1 + } + } + }, + "domain.CreateTicketReq": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.CreateTicketOutcomeReq" + } + } + } + }, + "domain.CreateTicketRes": { + "type": "object", + "properties": { + "created_number": { + "type": "integer", + "example": 3 + }, + "fast_code": { + "type": "integer", + "example": 1234 + } + } + }, "domain.DashboardSummary": { "type": "object", "properties": { @@ -5124,6 +5068,10 @@ "type": "boolean", "example": false }, + "is_featured": { + "type": "boolean", + "example": false + }, "name": { "type": "string", "example": "BPL" @@ -5185,6 +5133,17 @@ } } }, + "domain.OtpProvider": { + "type": "string", + "enum": [ + "twilio", + "aformessage" + ], + "x-enum-varnames": [ + "TwilioSms", + "AfroMessage" + ] + }, "domain.OutcomeStatus": { "type": "integer", "enum": [ @@ -5474,6 +5433,29 @@ } } }, + "domain.TicketRes": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "id": { + "type": "integer", + "example": 1 + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.TicketOutcome" + } + }, + "total_odds": { + "type": "number", + "example": 4.22 + } + } + }, "domain.UpcomingEvent": { "type": "object", "properties": { @@ -5543,51 +5525,6 @@ } } }, - "domain.VeliCallback": { - "type": "object", - "properties": { - "amount": { - "description": "Transaction amount", - "type": "number" - }, - "currency": { - "description": "e.g., \"USD\"", - "type": "string" - }, - "event_type": { - "description": "\"bet_placed\", \"game_result\", etc.", - "type": "string" - }, - "game_id": { - "description": "e.g., \"veli_aviator_v1\"", - "type": "string" - }, - "multiplier": { - "description": "For games with multipliers (Aviator/Plinko)", - "type": "number" - }, - "round_id": { - "description": "Unique round identifier (replaces transaction_id)", - "type": "string" - }, - "session_id": { - "description": "Matches VirtualGameSession.SessionToken", - "type": "string" - }, - "signature": { - "description": "HMAC-SHA256", - "type": "string" - }, - "timestamp": { - "description": "Unix timestamp", - "type": "integer" - }, - "user_id": { - "description": "Veli's user identifier", - "type": "string" - } - } - }, "domain.VirtualGame": { "type": "object", "properties": { @@ -5986,52 +5923,6 @@ } } }, - "handlers.CreateTicketOutcomeReq": { - "type": "object", - "properties": { - "event_id": { - "description": "TicketID int64 `json:\"ticket_id\" example:\"1\"`", - "type": "integer", - "example": 1 - }, - "market_id": { - "type": "integer", - "example": 1 - }, - "odd_id": { - "type": "integer", - "example": 1 - } - } - }, - "handlers.CreateTicketReq": { - "type": "object", - "properties": { - "amount": { - "type": "number", - "example": 100 - }, - "outcomes": { - "type": "array", - "items": { - "$ref": "#/definitions/handlers.CreateTicketOutcomeReq" - } - } - } - }, - "handlers.CreateTicketRes": { - "type": "object", - "properties": { - "created_number": { - "type": "integer", - "example": 3 - }, - "fast_code": { - "type": "integer", - "example": 1234 - } - } - }, "handlers.CreateTransactionReq": { "type": "object", "properties": { @@ -6241,6 +6132,9 @@ }, "handlers.RegisterCodeReq": { "type": "object", + "required": [ + "provider" + ], "properties": { "email": { "type": "string", @@ -6249,11 +6143,22 @@ "phone_number": { "type": "string", "example": "1234567890" + }, + "provider": { + "allOf": [ + { + "$ref": "#/definitions/domain.OtpProvider" + } + ], + "example": "twilio" } } }, "handlers.RegisterUserReq": { "type": "object", + "required": [ + "provider" + ], "properties": { "email": { "type": "string", @@ -6279,6 +6184,14 @@ "type": "string", "example": "1234567890" }, + "provider": { + "allOf": [ + { + "$ref": "#/definitions/domain.OtpProvider" + } + ], + "example": "twilio" + }, "referal_code": { "type": "string", "example": "ABC123" @@ -6287,6 +6200,9 @@ }, "handlers.ResetCodeReq": { "type": "object", + "required": [ + "provider" + ], "properties": { "email": { "type": "string", @@ -6295,6 +6211,14 @@ "phone_number": { "type": "string", "example": "1234567890" + }, + "provider": { + "allOf": [ + { + "$ref": "#/definitions/domain.OtpProvider" + } + ], + "example": "twilio" } } }, @@ -6363,29 +6287,6 @@ } } }, - "handlers.TicketRes": { - "type": "object", - "properties": { - "amount": { - "type": "number", - "example": 100 - }, - "id": { - "type": "integer", - "example": 1 - }, - "outcomes": { - "type": "array", - "items": { - "$ref": "#/definitions/domain.TicketOutcome" - } - }, - "total_odds": { - "type": "number", - "example": 4.22 - } - } - }, "handlers.TransactionRes": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index df02b3c..3bc00f1 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -211,6 +211,38 @@ definitions: - $ref: '#/definitions/domain.OutcomeStatus' example: 1 type: object + domain.CreateTicketOutcomeReq: + properties: + event_id: + description: TicketID int64 `json:"ticket_id" example:"1"` + example: 1 + type: integer + market_id: + example: 1 + type: integer + odd_id: + example: 1 + type: integer + type: object + domain.CreateTicketReq: + properties: + amount: + example: 100 + type: number + outcomes: + items: + $ref: '#/definitions/domain.CreateTicketOutcomeReq' + type: array + type: object + domain.CreateTicketRes: + properties: + created_number: + example: 3 + type: integer + fast_code: + example: 1234 + type: integer + type: object domain.DashboardSummary: properties: active_admins: @@ -337,6 +369,9 @@ definitions: is_active: example: false type: boolean + is_featured: + example: false + type: boolean name: example: BPL type: string @@ -378,6 +413,14 @@ definitions: source: type: string type: object + domain.OtpProvider: + enum: + - twilio + - aformessage + type: string + x-enum-varnames: + - TwilioSms + - AfroMessage domain.OutcomeStatus: enum: - 0 @@ -583,6 +626,22 @@ definitions: example: 1 type: integer type: object + domain.TicketRes: + properties: + amount: + example: 100 + type: number + id: + example: 1 + type: integer + outcomes: + items: + $ref: '#/definitions/domain.TicketOutcome' + type: array + total_odds: + example: 4.22 + type: number + type: object domain.UpcomingEvent: properties: away_kit_image: @@ -632,39 +691,6 @@ definitions: - $ref: '#/definitions/domain.EventStatus' description: Match Status for event type: object - domain.VeliCallback: - properties: - amount: - description: Transaction amount - type: number - currency: - description: e.g., "USD" - type: string - event_type: - description: '"bet_placed", "game_result", etc.' - type: string - game_id: - description: e.g., "veli_aviator_v1" - type: string - multiplier: - description: For games with multipliers (Aviator/Plinko) - type: number - round_id: - description: Unique round identifier (replaces transaction_id) - type: string - session_id: - description: Matches VirtualGameSession.SessionToken - type: string - signature: - description: HMAC-SHA256 - type: string - timestamp: - description: Unix timestamp - type: integer - user_id: - description: Veli's user identifier - type: string - type: object domain.VirtualGame: properties: category: @@ -946,38 +972,6 @@ definitions: example: SportsBook type: string type: object - handlers.CreateTicketOutcomeReq: - properties: - event_id: - description: TicketID int64 `json:"ticket_id" example:"1"` - example: 1 - type: integer - market_id: - example: 1 - type: integer - odd_id: - example: 1 - type: integer - type: object - handlers.CreateTicketReq: - properties: - amount: - example: 100 - type: number - outcomes: - items: - $ref: '#/definitions/handlers.CreateTicketOutcomeReq' - type: array - type: object - handlers.CreateTicketRes: - properties: - created_number: - example: 3 - type: integer - fast_code: - example: 1234 - type: integer - type: object handlers.CreateTransactionReq: properties: account_name: @@ -1126,6 +1120,12 @@ definitions: phone_number: example: "1234567890" type: string + provider: + allOf: + - $ref: '#/definitions/domain.OtpProvider' + example: twilio + required: + - provider type: object handlers.RegisterUserReq: properties: @@ -1147,9 +1147,15 @@ definitions: phone_number: example: "1234567890" type: string + provider: + allOf: + - $ref: '#/definitions/domain.OtpProvider' + example: twilio referal_code: example: ABC123 type: string + required: + - provider type: object handlers.ResetCodeReq: properties: @@ -1159,6 +1165,12 @@ definitions: phone_number: example: "1234567890" type: string + provider: + allOf: + - $ref: '#/definitions/domain.OtpProvider' + example: twilio + required: + - provider type: object handlers.ResetPasswordReq: properties: @@ -1205,22 +1217,6 @@ definitions: example: SportsBook type: string type: object - handlers.TicketRes: - properties: - amount: - example: 100 - type: number - id: - example: 1 - type: integer - outcomes: - items: - $ref: '#/definitions/domain.TicketOutcome' - type: array - total_odds: - example: 4.22 - type: number - type: object handlers.TransactionRes: properties: account_name: @@ -2077,52 +2073,6 @@ paths: summary: Process Alea Play game callback tags: - Alea Virtual Games - /api/veli/launch/{game_id}: - get: - description: Generates authenticated launch URL for Veli games - parameters: - - description: Game ID (e.g., veli_aviator_v1) - in: path - name: game_id - required: true - type: string - - default: USD - description: Currency code - in: query - name: currency - type: string - - default: real - description: Game mode - enum: - - real - - demo - in: query - name: mode - type: string - responses: - "200": - description: Returns launch URL - schema: - additionalProperties: - type: string - type: object - "400": - description: Invalid request - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal server error - schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: Launch a Veli game - tags: - - Veli Games /auth/login: post: consumes: @@ -3766,7 +3716,7 @@ paths: description: OK schema: items: - $ref: '#/definitions/handlers.TicketRes' + $ref: '#/definitions/domain.TicketRes' type: array "400": description: Bad Request @@ -3789,14 +3739,14 @@ paths: name: createTicket required: true schema: - $ref: '#/definitions/handlers.CreateTicketReq' + $ref: '#/definitions/domain.CreateTicketReq' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/handlers.CreateTicketRes' + $ref: '#/definitions/domain.CreateTicketRes' "400": description: Bad Request schema: @@ -3825,7 +3775,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/handlers.TicketRes' + $ref: '#/definitions/domain.TicketRes' "400": description: Bad Request schema: @@ -3837,6 +3787,27 @@ paths: summary: Get ticket by ID tags: - ticket + /top-leagues: + get: + consumes: + - application/json + description: Retrieve all top leagues + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/domain.UpcomingEvent' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Retrieve all top leagues + tags: + - prematch /transaction: get: consumes: @@ -4551,48 +4522,6 @@ paths: summary: Activate and Deactivate Wallet tags: - wallet - /webhooks/veli: - post: - consumes: - - application/json - description: Processes game round settlements from Veli - parameters: - - description: Callback payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/domain.VeliCallback' - produces: - - application/json - responses: - "200": - description: Callback processed - schema: - additionalProperties: - type: string - type: object - "400": - description: Invalid payload - schema: - additionalProperties: - type: string - type: object - "403": - description: Invalid signature - schema: - additionalProperties: - type: string - type: object - "500": - description: Processing error - schema: - additionalProperties: - type: string - type: object - summary: Veli Games webhook handler - tags: - - Veli Games securityDefinitions: Bearer: in: header diff --git a/gen/db/models.go b/gen/db/models.go index d5db539..767f121 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -324,6 +324,13 @@ type Result struct { UpdatedAt pgtype.Timestamp `json:"updated_at"` } +type Setting struct { + Key string `json:"key"` + Value string `json:"value"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` +} + type SupportedOperation struct { ID int64 `json:"id"` Name string `json:"name"` diff --git a/gen/db/settings.sql.go b/gen/db/settings.sql.go new file mode 100644 index 0000000..a7c9187 --- /dev/null +++ b/gen/db/settings.sql.go @@ -0,0 +1,65 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: settings.sql + +package dbgen + +import ( + "context" +) + +const GetSettings = `-- name: GetSettings :many +SELECT key, value, created_at, updated_at +from settings +` + +func (q *Queries) GetSettings(ctx context.Context) ([]Setting, error) { + rows, err := q.db.Query(ctx, GetSettings) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Setting + for rows.Next() { + var i Setting + if err := rows.Scan( + &i.Key, + &i.Value, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const SaveSetting = `-- name: SaveSetting :one +INSERT INTO settings (key, value, updated_at) +VALUES ($1, $2, CURRENT_TIMESTAMP) ON CONFLICT (key) DO +UPDATE +SET value = EXCLUDED.value +RETURNING key, value, created_at, updated_at +` + +type SaveSettingParams struct { + Key string `json:"key"` + Value string `json:"value"` +} + +func (q *Queries) SaveSetting(ctx context.Context, arg SaveSettingParams) (Setting, error) { + row := q.db.QueryRow(ctx, SaveSetting, arg.Key, arg.Value) + var i Setting + err := row.Scan( + &i.Key, + &i.Value, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/domain/settings.go b/internal/domain/settings.go new file mode 100644 index 0000000..083f915 --- /dev/null +++ b/internal/domain/settings.go @@ -0,0 +1,15 @@ +package domain + +import "time" + +type Setting struct { + Key string + Value string + UpdatedAt time.Time +} + +type SettingRes struct { + Key string `json:"key"` + Value string `json:"value"` + UpdatedAt string `json:"updated_at"` +} diff --git a/internal/domain/ticket.go b/internal/domain/ticket.go index e85638f..63c4d29 100644 --- a/internal/domain/ticket.go +++ b/internal/domain/ticket.go @@ -53,3 +53,31 @@ type CreateTicket struct { TotalOdds float32 IP string } + +type CreateTicketOutcomeReq struct { + // TicketID int64 `json:"ticket_id" example:"1"` + EventID int64 `json:"event_id" example:"1"` + OddID int64 `json:"odd_id" example:"1"` + MarketID int64 `json:"market_id" example:"1"` + // HomeTeamName string `json:"home_team_name" example:"Manchester"` + // AwayTeamName string `json:"away_team_name" example:"Liverpool"` + // MarketName string `json:"market_name" example:"Fulltime Result"` + // Odd float32 `json:"odd" example:"1.5"` + // OddName string `json:"odd_name" example:"1"` + // Expires time.Time `json:"expires" example:"2025-04-08T12:00:00Z"` +} + +type CreateTicketReq struct { + Outcomes []CreateTicketOutcomeReq `json:"outcomes"` + Amount float32 `json:"amount" example:"100.0"` +} +type CreateTicketRes struct { + FastCode int64 `json:"fast_code" example:"1234"` + CreatedNumber int64 `json:"created_number" example:"3"` +} +type TicketRes struct { + ID int64 `json:"id" example:"1"` + Outcomes []TicketOutcome `json:"outcomes"` + Amount float32 `json:"amount" example:"100.0"` + TotalOdds float32 `json:"total_odds" example:"4.22"` +} diff --git a/internal/repository/settings.go b/internal/repository/settings.go new file mode 100644 index 0000000..3bf0c8e --- /dev/null +++ b/internal/repository/settings.go @@ -0,0 +1,49 @@ +package repository + +import ( + "context" + + dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "go.uber.org/zap" +) + +func (s *Store) GetSettings(ctx context.Context) ([]domain.Setting, error) { + settings, err := s.queries.GetSettings(ctx) + + if err != nil { + domain.MongoDBLogger.Error("failed to get all settings", zap.Error(err)) + } + + var result []domain.Setting = make([]domain.Setting, 0, len(settings)) + for _, setting := range settings { + result = append(result, domain.Setting{ + Key: setting.Key, + Value: setting.Value, + UpdatedAt: setting.UpdatedAt.Time, + }) + } + + return result, nil +} + +func (s *Store) SaveSetting(ctx context.Context, key, value string) (domain.Setting, error) { + dbSetting, err := s.queries.SaveSetting(ctx, dbgen.SaveSettingParams{ + Key: key, + Value: value, + }) + + if err != nil { + domain.MongoDBLogger.Error("failed to update setting", zap.String("key", key), zap.String("value", value), zap.Error(err)) + + return domain.Setting{}, err + } + + setting := domain.Setting{ + Key: dbSetting.Key, + Value: dbSetting.Value, + } + + return setting, err + +} diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go index 17816db..932ae2c 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -29,6 +29,11 @@ var ( ErrGenerateRandomOutcome = errors.New("Failed to generate any random outcome for events") ErrOutcomesNotCompleted = errors.New("Some bet outcomes are still pending") ErrEventHasBeenRemoved = errors.New("Event has been removed") + + ErrEventHasNotEnded = errors.New("Event has not ended yet") + ErrRawOddInvalid = errors.New("Prematch Raw Odd is Invalid") + ErrBranchIDRequired = errors.New("Branch ID required for this role") + ErrOutcomeLimit = errors.New("Too many outcomes on a single bet") ) type Service struct { @@ -41,7 +46,15 @@ type Service struct { mongoLogger *zap.Logger } -func NewService(betStore BetStore, eventSvc event.Service, prematchSvc odds.ServiceImpl, walletSvc wallet.Service, branchSvc branch.Service, logger *slog.Logger, mongoLogger *zap.Logger) *Service { +func NewService( + betStore BetStore, + eventSvc event.Service, + prematchSvc odds.ServiceImpl, + walletSvc wallet.Service, + branchSvc branch.Service, + logger *slog.Logger, + mongoLogger *zap.Logger, +) *Service { return &Service{ betStore: betStore, eventSvc: eventSvc, @@ -53,13 +66,6 @@ func NewService(betStore BetStore, eventSvc event.Service, prematchSvc odds.Serv } } -var ( - ErrEventHasNotEnded = errors.New("Event has not ended yet") - ErrRawOddInvalid = errors.New("Prematch Raw Odd is Invalid") - ErrBranchIDRequired = errors.New("Branch ID required for this role") - ErrOutcomeLimit = errors.New("Too many outcomes on a single bet") -) - func (s *Service) GenerateCashoutID() (string, error) { const chars = "abcdefghijklmnopqrstuvwxyz0123456789" const length int = 13 diff --git a/internal/services/settings/port.go b/internal/services/settings/port.go new file mode 100644 index 0000000..587805a --- /dev/null +++ b/internal/services/settings/port.go @@ -0,0 +1,12 @@ +package settings + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type SettingStore interface { + GetSettings(ctx context.Context) ([]domain.Setting, error) + SaveSetting(ctx context.Context, key, value string) (domain.Setting, error) +} diff --git a/internal/services/settings/service.go b/internal/services/settings/service.go new file mode 100644 index 0000000..95f0083 --- /dev/null +++ b/internal/services/settings/service.go @@ -0,0 +1,25 @@ +package settings + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type Service struct { + settingStore SettingStore +} + +func NewService(settingStore SettingStore) *Service { + return &Service{ + settingStore: settingStore, + } +} + +func (s *Service) GetSettings(ctx context.Context) ([]domain.Setting, error) { + return s.settingStore.GetSettings(ctx) +} + +func (s *Service) SaveSetting(ctx context.Context, key, value string) (domain.Setting, error) { + return s.settingStore.SaveSetting(ctx, key, value) +} diff --git a/internal/services/ticket/service.go b/internal/services/ticket/service.go index 67c8a5a..69052b1 100644 --- a/internal/services/ticket/service.go +++ b/internal/services/ticket/service.go @@ -2,24 +2,231 @@ package ticket import ( "context" + "encoding/json" + "errors" + "strconv" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" + "go.uber.org/zap" +) + +var ( + // ErrGenerateRandomOutcome = errors.New("Failed to generate any random outcome for events") + // ErrOutcomesNotCompleted = errors.New("Some bet outcomes are still pending") + ErrEventHasNotEnded = errors.New("Event has not ended yet") + ErrNoEventsAvailable = errors.New("Not enough events available with the given filters") + ErrEventHasBeenRemoved = errors.New("Event has been removed") + ErrTooManyOutcomesForTicket = errors.New("Too many odds/outcomes for a single ticket") + ErrTicketAmountTooHigh = errors.New("Cannot create a ticket with an amount above limit") + ErrTicketLimitForSingleUser = errors.New("Number of Ticket Limit reached") + ErrTicketWinningTooHigh = errors.New("Total Winnings over set limit") + + ErrRawOddInvalid = errors.New("Prematch Raw Odd is Invalid") ) type Service struct { ticketStore TicketStore + eventSvc event.Service + prematchSvc odds.ServiceImpl + mongoLogger *zap.Logger } -func NewService(ticketStore TicketStore) *Service { +func NewService( + ticketStore TicketStore, + eventSvc event.Service, + prematchSvc odds.ServiceImpl, + mongoLogger *zap.Logger, +) *Service { return &Service{ ticketStore: ticketStore, + eventSvc: eventSvc, + prematchSvc: prematchSvc, + mongoLogger: mongoLogger, } } -func (s *Service) CreateTicket(ctx context.Context, ticket domain.CreateTicket) (domain.Ticket, error) { - return s.ticketStore.CreateTicket(ctx, ticket) +func (s *Service) GenerateTicketOutcome(ctx context.Context, eventID int64, marketID int64, oddID int64) (domain.CreateTicketOutcome, error) { + eventIDStr := strconv.FormatInt(eventID, 10) + marketIDStr := strconv.FormatInt(marketID, 10) + oddIDStr := strconv.FormatInt(oddID, 10) + event, err := s.eventSvc.GetUpcomingEventByID(ctx, eventIDStr) + if err != nil { + s.mongoLogger.Error("failed to fetch upcoming event by ID", + zap.Int64("event_id", eventID), + zap.Error(err), + ) + return domain.CreateTicketOutcome{}, ErrEventHasBeenRemoved + } + + // Checking to make sure the event hasn't already started + currentTime := time.Now() + if event.StartTime.Before(currentTime) { + s.mongoLogger.Error("event has already started", + zap.Int64("event_id", eventID), + zap.Time("event_start_time", event.StartTime), + zap.Time("current_time", currentTime), + ) + return domain.CreateTicketOutcome{}, ErrEventHasNotEnded + } + + odds, err := s.prematchSvc.GetRawOddsByMarketID(ctx, marketIDStr, eventIDStr) + + if err != nil { + s.mongoLogger.Error("failed to get raw odds by market ID", + zap.Int64("event_id", eventID), + zap.Int64("market_id", marketID), + zap.Error(err), + ) + // return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid market id", err, nil) + + } + type rawOddType struct { + ID string + Name string + Odds string + Header string + Handicap string + } + var selectedOdd rawOddType + var isOddFound bool = false + for _, raw := range odds.RawOdds { + var rawOdd rawOddType + rawBytes, err := json.Marshal(raw) + err = json.Unmarshal(rawBytes, &rawOdd) + if err != nil { + s.mongoLogger.Error("failed to unmarshal raw ods", + zap.Int64("event_id", eventID), + zap.String("rawOddID", rawOdd.ID), + zap.Error(err), + ) + continue + } + + if rawOdd.ID == oddIDStr { + selectedOdd = rawOdd + isOddFound = true + } + } + + if !isOddFound { + // return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid odd id", nil, nil) + s.mongoLogger.Error("Invalid Odd ID", + zap.Int64("event_id", eventID), + zap.String("oddIDStr", oddIDStr), + ) + return domain.CreateTicketOutcome{}, ErrRawOddInvalid + } + + parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) + if err != nil { + s.mongoLogger.Error("failed to parse selected odd value", + zap.String("odd", selectedOdd.Odds), + zap.Int64("odd_id", oddID), + zap.Error(err), + ) + return domain.CreateTicketOutcome{}, err + } + + newOutcome := domain.CreateTicketOutcome{ + EventID: eventID, + OddID: oddID, + MarketID: marketID, + HomeTeamName: event.HomeTeam, + AwayTeamName: event.AwayTeam, + MarketName: odds.MarketName, + Odd: float32(parsedOdd), + OddName: selectedOdd.Name, + OddHeader: selectedOdd.Header, + OddHandicap: selectedOdd.Handicap, + Expires: event.StartTime, + } + + // outcomes = append(outcomes, ) + + return newOutcome, nil + } +func (s *Service) CreateTicket(ctx context.Context, req domain.CreateTicketReq, clientIP string) (domain.Ticket, int64, error) { + // TODO Validate Outcomes Here and make sure they didn't expire + // Validation for creating tickets + if len(req.Outcomes) > 30 { + // return response.WriteJSON(c, fiber.StatusBadRequest, "Too many odds/outcomes selected", nil, nil) + return domain.Ticket{}, 0, ErrTooManyOutcomesForTicket + + } + + if req.Amount > 100000 { + // return response.WriteJSON(c, fiber.StatusBadRequest, "Cannot create a ticket with an amount above 100,000 birr", nil, nil) + return domain.Ticket{}, 0, ErrTicketAmountTooHigh + } + + count, err := s.CountTicketByIP(ctx, clientIP) + + if err != nil { + // return response.WriteJSON(c, fiber.StatusInternalServerError, "Error fetching user info", nil, nil) + return domain.Ticket{}, 0, err + } + + if count > 50 { + // return response.WriteJSON(c, fiber.StatusBadRequest, "Ticket Limit reached", nil, nil) + return domain.Ticket{}, 0, ErrTicketLimitForSingleUser + } + var outcomes []domain.CreateTicketOutcome = make([]domain.CreateTicketOutcome, 0, len(req.Outcomes)) + var totalOdds float32 = 1 + for _, outcomeReq := range req.Outcomes { + newOutcome, err := s.GenerateTicketOutcome(ctx, outcomeReq.EventID, outcomeReq.MarketID, outcomeReq.OddID) + if err != nil { + s.mongoLogger.Error("failed to generate outcome", + zap.Int64("event_id", outcomeReq.EventID), + zap.Int64("market_id", outcomeReq.MarketID), + zap.Int64("odd_id", outcomeReq.OddID), + zap.Error(err), + ) + return domain.Ticket{}, 0, err + } + totalOdds *= float32(newOutcome.Odd) + outcomes = append(outcomes, newOutcome) + } + totalWinnings := req.Amount * totalOdds + if totalWinnings > 1000000 { + s.mongoLogger.Error("Total Winnings over limit", zap.Float32("Total Odds", totalOdds), zap.Float32("amount", req.Amount)) + // return response.WriteJSON(c, fiber.StatusBadRequest, "Cannot create a ticket with 1,000,000 winnings", nil, nil) + return domain.Ticket{}, 0, ErrTicketWinningTooHigh + } + + ticket, err := s.ticketStore.CreateTicket(ctx, domain.CreateTicket{ + Amount: domain.ToCurrency(req.Amount), + TotalOdds: totalOdds, + IP: clientIP, + }) + if err != nil { + s.mongoLogger.Error("Error Creating Ticket", zap.Float32("Total Odds", totalOdds), zap.Float32("amount", req.Amount)) + return domain.Ticket{}, 0, err + } + + // Add the ticket id now that it has fetched from the database + for index := range outcomes { + outcomes[index].TicketID = ticket.ID + } + + rows, err := s.CreateTicketOutcome(ctx, outcomes) + + if err != nil { + s.mongoLogger.Error("Error Creating Ticket Outcomes", zap.Any("outcomes", outcomes)) + return domain.Ticket{}, rows, err + } + + return ticket, rows, nil +} + +// func (s *Service) CreateTicket(ctx context.Context, ticket domain.CreateTicket) (domain.Ticket, error) { +// return s.ticketStore.CreateTicket(ctx, ticket) +// } + func (s *Service) CreateTicketOutcome(ctx context.Context, outcomes []domain.CreateTicketOutcome) (int64, error) { return s.ticketStore.CreateTicketOutcome(ctx, outcomes) } diff --git a/internal/web_server/handlers/ticket_handler.go b/internal/web_server/handlers/ticket_handler.go index 9706d2a..e665162 100644 --- a/internal/web_server/handlers/ticket_handler.go +++ b/internal/web_server/handlers/ticket_handler.go @@ -1,56 +1,27 @@ package handlers import ( - "encoding/json" "strconv" - "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/gofiber/fiber/v2" ) -type CreateTicketOutcomeReq struct { - // TicketID int64 `json:"ticket_id" example:"1"` - EventID int64 `json:"event_id" example:"1"` - OddID int64 `json:"odd_id" example:"1"` - MarketID int64 `json:"market_id" example:"1"` - // HomeTeamName string `json:"home_team_name" example:"Manchester"` - // AwayTeamName string `json:"away_team_name" example:"Liverpool"` - // MarketName string `json:"market_name" example:"Fulltime Result"` - // Odd float32 `json:"odd" example:"1.5"` - // OddName string `json:"odd_name" example:"1"` - // Expires time.Time `json:"expires" example:"2025-04-08T12:00:00Z"` -} - -type CreateTicketReq struct { - Outcomes []CreateTicketOutcomeReq `json:"outcomes"` - Amount float32 `json:"amount" example:"100.0"` -} -type CreateTicketRes struct { - FastCode int64 `json:"fast_code" example:"1234"` - CreatedNumber int64 `json:"created_number" example:"3"` -} -type TicketRes struct { - ID int64 `json:"id" example:"1"` - Outcomes []domain.TicketOutcome `json:"outcomes"` - Amount float32 `json:"amount" example:"100.0"` - TotalOdds float32 `json:"total_odds" example:"4.22"` -} - // CreateTicket godoc // @Summary Create a temporary ticket // @Description Creates a temporary ticket // @Tags ticket // @Accept json // @Produce json -// @Param createTicket body CreateTicketReq true "Creates ticket" -// @Success 200 {object} CreateTicketRes +// @Param createTicket body domain.CreateTicketReq true "Creates ticket" +// @Success 200 {object} domain.CreateTicketRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /ticket [post] func (h *Handler) CreateTicket(c *fiber.Ctx) error { - var req CreateTicketReq + var req domain.CreateTicketReq if err := c.BodyParser(&req); err != nil { h.logger.Error("Failed to parse CreateTicket request", "error", err) return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") @@ -60,122 +31,17 @@ func (h *Handler) CreateTicket(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) } - // TODO Validate Outcomes Here and make sure they didn't expire - // Validation for creating tickets - if len(req.Outcomes) > 30 { - return response.WriteJSON(c, fiber.StatusBadRequest, "Too many odds/outcomes selected", nil, nil) - } - - if req.Amount > 100000 { - return response.WriteJSON(c, fiber.StatusBadRequest, "Cannot create a ticket with an amount above 100,000 birr", nil, nil) - } - - clientIP := c.IP() - count, err := h.ticketSvc.CountTicketByIP(c.Context(), clientIP) - if err != nil { - return response.WriteJSON(c, fiber.StatusInternalServerError, "Error fetching user info", nil, nil) - } - - if count > 50 { - return response.WriteJSON(c, fiber.StatusBadRequest, "Ticket Limit reached", nil, nil) - } - var outcomes []domain.CreateTicketOutcome = make([]domain.CreateTicketOutcome, 0, len(req.Outcomes)) - var totalOdds float32 = 1 - for _, outcome := range req.Outcomes { - eventIDStr := strconv.FormatInt(outcome.EventID, 10) - marketIDStr := strconv.FormatInt(outcome.MarketID, 10) - oddIDStr := strconv.FormatInt(outcome.OddID, 10) - event, err := h.eventSvc.GetUpcomingEventByID(c.Context(), eventIDStr) - if err != nil { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid event id", err, nil) - } - - // Checking to make sure the event hasn't already started - currentTime := time.Now() - if event.StartTime.Before(currentTime) { - return response.WriteJSON(c, fiber.StatusBadRequest, "The event has already expired", nil, nil) - } - - odds, err := h.prematchSvc.GetRawOddsByMarketID(c.Context(), marketIDStr, eventIDStr) - - if err != nil { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid market id", err, nil) - } - type rawOddType struct { - ID string - Name string - Odds string - Header string - Handicap string - } - var selectedOdd rawOddType - var isOddFound bool = false - for _, raw := range odds.RawOdds { - var rawOdd rawOddType - rawBytes, err := json.Marshal(raw) - err = json.Unmarshal(rawBytes, &rawOdd) - if err != nil { - h.logger.Error("Failed to unmarshal raw odd:", "error", err) - continue - } - if rawOdd.ID == oddIDStr { - selectedOdd = rawOdd - isOddFound = true - } - } - - if !isOddFound { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid odd id", nil, nil) - } - - parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) - totalOdds = totalOdds * float32(parsedOdd) - outcomes = append(outcomes, domain.CreateTicketOutcome{ - EventID: outcome.EventID, - OddID: outcome.OddID, - MarketID: outcome.MarketID, - HomeTeamName: event.HomeTeam, - AwayTeamName: event.AwayTeam, - MarketName: odds.MarketName, - Odd: float32(parsedOdd), - OddName: selectedOdd.Name, - OddHeader: selectedOdd.Header, - OddHandicap: selectedOdd.Handicap, - Expires: event.StartTime, - }) - - } - totalWinnings := req.Amount * totalOdds - if totalWinnings > 1000000 { - return response.WriteJSON(c, fiber.StatusBadRequest, "Cannot create a ticket with 1,000,000 winnings", nil, nil) - } - ticket, err := h.ticketSvc.CreateTicket(c.Context(), domain.CreateTicket{ - Amount: domain.ToCurrency(req.Amount), - TotalOdds: totalOdds, - IP: clientIP, - }) - if err != nil { - h.logger.Error("CreateTicketReq failed", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Internal server error", - }) - } - - // Add the ticket id now that it has fetched from the database - for index := range outcomes { - outcomes[index].TicketID = ticket.ID - } - - rows, err := h.ticketSvc.CreateTicketOutcome(c.Context(), outcomes) + newTicket, rows, err := h.ticketSvc.CreateTicket(c.Context(), req, c.IP()) if err != nil { - h.logger.Error("CreateTicketReq failed to create outcomes", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Internal server error", - }) + switch err { + case ticket.ErrEventHasBeenRemoved, ticket.ErrEventHasNotEnded, ticket.ErrRawOddInvalid: + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - res := CreateTicketRes{ - FastCode: ticket.ID, + res := domain.CreateTicketRes{ + FastCode: newTicket.ID, CreatedNumber: rows, } return response.WriteJSON(c, fiber.StatusOK, "Ticket Created", res, nil) @@ -189,7 +55,7 @@ func (h *Handler) CreateTicket(c *fiber.Ctx) error { // @Accept json // @Produce json // @Param id path int true "Ticket ID" -// @Success 200 {object} TicketRes +// @Success 200 {object} domain.TicketRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /ticket/{id} [get] @@ -207,7 +73,7 @@ func (h *Handler) GetTicketByID(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusNotFound, "Failed to retrieve ticket") } - res := TicketRes{ + res := domain.TicketRes{ ID: ticket.ID, Outcomes: ticket.Outcomes, Amount: ticket.Amount.Float32(), @@ -222,7 +88,7 @@ func (h *Handler) GetTicketByID(c *fiber.Ctx) error { // @Tags ticket // @Accept json // @Produce json -// @Success 200 {array} TicketRes +// @Success 200 {array} domain.TicketRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /ticket [get] @@ -234,9 +100,9 @@ func (h *Handler) GetAllTickets(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve tickets") } - res := make([]TicketRes, len(tickets)) + res := make([]domain.TicketRes, len(tickets)) for i, ticket := range tickets { - res[i] = TicketRes{ + res[i] = domain.TicketRes{ ID: ticket.ID, Outcomes: ticket.Outcomes, Amount: ticket.Amount.Float32(),