multi-currency support

This commit is contained in:
Yared Yemane 2025-06-16 17:54:42 +03:00
parent 49527cbf2a
commit a5ea52b993
54 changed files with 3211 additions and 1065 deletions

7
.env
View File

@ -27,7 +27,14 @@ POPOK_BASE_URL=https://st.pokgaming.com/ #Staging
POPOK_CALLBACK_URL=1
#Muli-currency Support
FIXER_API_KEY=3b0f1eb30d-63c875026d-sxy9pl
BASE_CURRENCY=ETB
FIXER_BASE_URL=https://api.apilayer.com/fixer
# Chapa API Configuration
CHAPA_TRANSFER_TYPE="Payout"
CHAPA_PAYMENT_TYPE="API"
CHAPA_BASE_URL="https://api.chapa.co/v1"
CHAPA_ENCRYPTION_KEY=zLdYrjnBCknMvFikmP5jBfen
CHAPA_PUBLIC_KEY=CHAPUBK_TEST-HJR0qhQRPLTkauNy9Q8UrmskPTOR31aC

View File

@ -4,6 +4,7 @@ import (
// "context"
// "context"
"context"
"fmt"
"log"
"log/slog"
@ -33,6 +34,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/chapa"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/company"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/currency"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/event"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/league"
notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication"
@ -134,13 +136,18 @@ func main() {
chapaSvc := chapa.NewService(
wallet.TransferStore(store),
wallet.WalletStore(store),
*walletSvc,
user.UserStore(store),
chapaClient,
)
// Initialize reporting components
reportRepo := repository.NewReportRepo(store)
currRepo := repository.NewCurrencyPostgresRepository(store)
fixerFertcherSvc := currency.NewFixerFetcher(
cfg.FIXER_API_KEY,
cfg.FIXER_BASE_URL,
)
reportSvc := report.NewService(
bet.BetStore(store),
@ -175,7 +182,17 @@ func main() {
logger,
5*time.Minute,
)
walletMonitorSvc.Start()
currSvc := currency.NewService(
currRepo,
cfg.BASE_CURRENCY,
fixerFertcherSvc,
)
exchangeWorker := currency.NewExchangeRateWorker(fixerFertcherSvc, logger, cfg)
exchangeWorker.Start(context.Background())
defer exchangeWorker.Stop()
go walletMonitorSvc.Start()
httpserver.StartDataFetchingCrons(eventSvc, *oddsSvc, resultSvc)
httpserver.StartTicketCrons(*ticketSvc)
@ -183,6 +200,7 @@ func main() {
// Initialize and start HTTP server
app := httpserver.NewApp(
currSvc,
cfg.Port,
v,
authSvc,

View File

@ -69,6 +69,15 @@ CREATE TABLE IF NOT EXISTS tickets (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE exchange_rates (
id SERIAL PRIMARY KEY,
from_currency VARCHAR(3) NOT NULL,
to_currency VARCHAR(3) NOT NULL,
rate DECIMAL(19, 6) NOT NULL,
valid_until TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (from_currency, to_currency)
);
CREATE TABLE IF NOT EXISTS bet_outcomes (
id BIGSERIAL PRIMARY KEY,
bet_id BIGINT NOT NULL,
@ -123,14 +132,15 @@ CREATE TABLE IF NOT EXISTS customer_wallets (
);
CREATE TABLE IF NOT EXISTS wallet_transfer (
id BIGSERIAL PRIMARY KEY,
amount BIGINT NOT NULL,
type VARCHAR(255) NOT NULL,
receiver_wallet_id BIGINT NOT NULL,
amount BIGINT,
type VARCHAR(255),
receiver_wallet_id BIGINT,
sender_wallet_id BIGINT,
cashier_id BIGINT,
verified BOOLEAN NOT NULL DEFAULT false,
reference_number VARCHAR(255) NOT NULL,
payment_method VARCHAR(255) NOT NULL,
verified BOOLEAN DEFAULT false,
reference_number VARCHAR(255),
status VARCHAR(255),
payment_method VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
@ -301,6 +311,7 @@ ALTER TABLE bets
ADD CONSTRAINT fk_bets_branches FOREIGN KEY (branch_id) REFERENCES branches(id);
ALTER TABLE wallets
ADD CONSTRAINT fk_wallets_users FOREIGN KEY (user_id) REFERENCES users(id);
ADD COLUMN currency VARCHAR(3) NOT NULL DEFAULT 'ETB';
ALTER TABLE customer_wallets
ADD CONSTRAINT fk_customer_wallets_customers FOREIGN KEY (customer_id) REFERENCES users(id),
ADD CONSTRAINT fk_customer_wallets_regular_wallet FOREIGN KEY (regular_wallet_id) REFERENCES wallets(id),

View File

@ -1,3 +1,5 @@
DROP TABLE IF EXISTS vitrual_games;
DROP TABLE IF EXISTS virtual_game_transactions;
DROP TABLE IF EXISTS virtual_game_sessions;

View File

@ -1,18 +1,18 @@
CREATE TABLE virtual_games (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
provider VARCHAR(100) NOT NULL,
category VARCHAR(100) NOT NULL,
min_bet DECIMAL(15,2) NOT NULL,
max_bet DECIMAL(15,2) NOT NULL,
volatility VARCHAR(50) NOT NULL,
rtp DECIMAL(5,2) NOT NULL,
is_featured BOOLEAN DEFAULT false,
popularity_score INTEGER DEFAULT 0,
thumbnail_url TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- CREATE TABLE virtual_games (
-- id BIGSERIAL PRIMARY KEY,
-- name VARCHAR(255) NOT NULL,
-- provider VARCHAR(100) NOT NULL,
-- category VARCHAR(100) NOT NULL,
-- min_bet DECIMAL(15,2) NOT NULL,
-- max_bet DECIMAL(15,2) NOT NULL,
-- volatility VARCHAR(50) NOT NULL,
-- rtp DECIMAL(5,2) NOT NULL,
-- is_featured BOOLEAN DEFAULT false,
-- popularity_score INTEGER DEFAULT 0,
-- thumbnail_url TEXT,
-- created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
-- updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
-- );
CREATE TABLE user_game_interactions (
id BIGSERIAL PRIMARY KEY,

View File

@ -7,9 +7,10 @@ INSERT INTO wallet_transfer (
cashier_id,
verified,
reference_number,
status,
payment_method
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *;
-- name: GetAllTransfers :many
SELECT *
@ -32,3 +33,9 @@ UPDATE wallet_transfer
SET verified = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2;
-- name: UpdateTransferStatus :exec
UPDATE wallet_transfer
SET status = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -31,39 +31,6 @@ definitions:
user_id:
type: string
type: object
domain.Bank:
properties:
acct_length:
type: integer
active:
type: integer
country_id:
type: integer
created_at:
type: string
currency:
type: string
id:
type: integer
is_24hrs:
description: nullable
type: integer
is_active:
type: integer
is_mobilemoney:
description: nullable
type: integer
is_rtgs:
type: integer
name:
type: string
slug:
type: string
swift:
type: string
updated_at:
type: string
type: object
domain.BetOutcome:
properties:
away_team_name:
@ -193,6 +160,22 @@ definitions:
tx_ref:
type: string
type: object
domain.ChapaWithdrawalRequest:
properties:
account_name:
type: string
account_number:
type: string
amount:
description: string because Chapa API uses string for monetary values
type: string
bank_code:
type: integer
currency:
type: string
reference:
type: string
type: object
domain.CreateBetOutcomeReq:
properties:
event_id:
@ -228,6 +211,81 @@ definitions:
- $ref: '#/definitions/domain.OutcomeStatus'
example: 1
type: object
domain.DashboardSummary:
properties:
active_admins:
type: integer
active_bets:
type: integer
active_branches:
type: integer
active_cashiers:
type: integer
active_companies:
type: integer
active_customers:
type: integer
active_games:
type: integer
active_managers:
type: integer
average_stake:
type: integer
branches_count:
type: integer
customer_count:
type: integer
inactive_admins:
type: integer
inactive_branches:
type: integer
inactive_cashiers:
type: integer
inactive_companies:
type: integer
inactive_customers:
type: integer
inactive_games:
type: integer
inactive_managers:
type: integer
profit:
type: integer
read_notifications:
type: integer
total_admins:
type: integer
total_bets:
type: integer
total_cashiers:
type: integer
total_companies:
type: integer
total_deposits:
type: integer
total_games:
type: integer
total_losses:
type: integer
total_managers:
type: integer
total_notifications:
type: integer
total_stakes:
type: integer
total_wallets:
type: integer
total_wins:
type: integer
total_withdrawals:
type: integer
unread_notifications:
type: integer
win_balance:
type: integer
win_rate:
type: number
type: object
domain.ErrorResponse:
properties:
error:
@ -235,6 +293,57 @@ definitions:
message:
type: string
type: object
domain.EventStatus:
enum:
- upcoming
- in_play
- to_be_fixed
- ended
- postponed
- cancelled
- walkover
- interrupted
- abandoned
- retired
- suspended
- decided_by_fa
- removed
type: string
x-enum-varnames:
- STATUS_PENDING
- STATUS_IN_PLAY
- STATUS_TO_BE_FIXED
- STATUS_ENDED
- STATUS_POSTPONED
- STATUS_CANCELLED
- STATUS_WALKOVER
- STATUS_INTERRUPTED
- STATUS_ABANDONED
- STATUS_RETIRED
- STATUS_SUSPENDED
- STATUS_DECIDED_BY_FA
- STATUS_REMOVED
domain.League:
properties:
bet365_id:
example: 1121
type: integer
cc:
example: uk
type: string
id:
example: 1
type: integer
is_active:
example: false
type: boolean
name:
example: BPL
type: string
sport_id:
example: 1
type: integer
type: object
domain.Odd:
properties:
category:
@ -404,6 +513,16 @@ definitions:
totalRewardEarned:
type: number
type: object
domain.Response:
properties:
data: {}
message:
type: string
status_code:
type: integer
success:
type: boolean
type: object
domain.Role:
enum:
- super_admin
@ -466,48 +585,52 @@ definitions:
type: object
domain.UpcomingEvent:
properties:
awayKitImage:
away_kit_image:
description: Kit or image for away team (optional)
type: string
awayTeam:
away_team:
description: Away team name (can be empty/null)
type: string
awayTeamID:
away_team_id:
description: Away team ID (can be empty/null)
type: integer
homeKitImage:
home_kit_image:
description: Kit or image for home team (optional)
type: string
homeTeam:
home_team:
description: Home team name (if available)
type: string
homeTeamID:
home_team_id:
description: Home team ID
type: integer
id:
description: Event ID
type: string
leagueCC:
league_cc:
description: League country code
type: string
leagueID:
league_id:
description: League ID
type: integer
leagueName:
league_name:
description: League name
type: string
matchName:
match_name:
description: Match or event name
type: string
source:
description: bet api provider (bet365, betfair)
type: string
sportID:
sport_id:
description: Sport ID
type: integer
startTime:
start_time:
description: Converted from "time" field in UNIX format
type: string
status:
allOf:
- $ref: '#/definitions/domain.EventStatus'
description: Match Status for event
type: object
domain.VeliCallback:
properties:
@ -550,6 +673,8 @@ definitions:
type: string
id:
type: integer
is_active:
type: boolean
is_featured:
type: boolean
max_bet:
@ -602,6 +727,9 @@ definitions:
type: object
handlers.BranchDetailRes:
properties:
balance:
example: 100.5
type: number
branch_manager_id:
example: 1
type: integer
@ -928,6 +1056,12 @@ definitions:
properties:
branch_id:
type: integer
branch_location:
type: string
branch_name:
type: string
branch_wallet:
type: integer
created_at:
type: string
email:
@ -1045,6 +1179,13 @@ definitions:
- otp
- password
type: object
handlers.ResultRes:
properties:
outcomes:
items:
$ref: '#/definitions/domain.BetOutcome'
type: array
type: object
handlers.SearchUserByNameOrPhoneReq:
properties:
query:
@ -1296,9 +1437,9 @@ definitions:
type: string
mode:
enum:
- REAL
- DEMO
example: REAL
- fun
- real
example: real
type: string
required:
- currency
@ -1395,39 +1536,6 @@ 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: {}
@ -1646,6 +1754,25 @@ paths:
summary: Launch an Alea Play virtual game
tags:
- Alea Virtual Games
/api/v1/chapa/banks:
get:
consumes:
- application/json
description: Get list of banks supported by Chapa
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Get supported banks
tags:
- Chapa
/api/v1/chapa/payments/deposit:
post:
consumes:
@ -1673,6 +1800,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
security:
- ApiKeyAuth: []
summary: Initiate a deposit
tags:
- Chapa
@ -1736,6 +1865,105 @@ paths:
summary: Chapa payment webhook callback (used by Chapa)
tags:
- Chapa
/api/v1/chapa/payments/withdraw:
post:
consumes:
- application/json
description: Initiates a withdrawal request to transfer funds to a bank account
via Chapa
parameters:
- description: Withdrawal request details
in: body
name: request
required: true
schema:
$ref: '#/definitions/domain.ChapaWithdrawalRequest'
produces:
- application/json
responses:
"201":
description: Chapa withdrawal process initiated successfully
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Invalid request body
schema:
$ref: '#/definitions/domain.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/domain.ErrorResponse'
"422":
description: Unprocessable entity
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/domain.ErrorResponse'
security:
- ApiKeyAuth: []
summary: Initiate a withdrawal
tags:
- Chapa
/api/v1/currencies:
get:
description: Returns list of supported currencies
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/domain.Response'
- properties:
data:
items:
type: integer
type: array
type: object
summary: Get supported currencies
tags:
- Multi-Currency
/api/v1/currencies/convert:
get:
description: Converts amount from one currency to another
parameters:
- description: Source currency code (e.g., USD)
in: query
name: from
required: true
type: string
- description: Target currency code (e.g., ETB)
in: query
name: to
required: true
type: string
- description: Amount to convert
in: query
name: amount
required: true
type: number
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/domain.Response'
- properties:
data:
type: number
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Convert currency
tags:
- Multi-Currency
/api/v1/reports/dashboard:
get:
consumes:
@ -1776,7 +2004,7 @@ paths:
"200":
description: OK
schema:
$ref: '#/definitions/report.DashboardSummary'
$ref: '#/definitions/domain.DashboardSummary'
"400":
description: Bad Request
schema:
@ -1997,27 +2225,6 @@ paths:
summary: Refresh token
tags:
- auth
/banks:
get:
consumes:
- application/json
description: Get list of banks supported by Chapa
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/domain.Bank'
type: array
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Get supported banks
tags:
- Chapa
/bet:
get:
consumes:
@ -2458,6 +2665,35 @@ paths:
summary: Delete the branch operation
tags:
- branch
/branchCashier:
get:
consumes:
- application/json
description: Gets branch for cahier
parameters:
- description: Branch ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.BranchDetailRes'
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.APIResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/response.APIResponse'
summary: Gets branch for cahier
tags:
- branch
/branchWallet:
get:
consumes:
@ -2516,6 +2752,39 @@ paths:
summary: Get cashier by id
tags:
- cashier
/cashierWallet:
get:
consumes:
- application/json
description: Get wallet for cashier
parameters:
- description: User ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.UserProfileRes'
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.APIResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/response.APIResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/response.APIResponse'
summary: Get wallet for cashier
tags:
- cashier
/cashiers:
get:
consumes:
@ -2800,6 +3069,138 @@ paths:
summary: Gets branches by company id
tags:
- branch
/events:
get:
consumes:
- application/json
description: Retrieve all upcoming events from the database
parameters:
- description: Page number
in: query
name: page
type: integer
- description: Page size
in: query
name: page_size
type: integer
- description: League ID Filter
in: query
name: league_id
type: string
- description: Sport ID Filter
in: query
name: sport_id
type: string
- description: Country Code Filter
in: query
name: cc
type: string
- description: Start Time
in: query
name: first_start_time
type: string
- description: End Time
in: query
name: last_start_time
type: string
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 upcoming events
tags:
- prematch
/events/{id}:
delete:
consumes:
- application/json
description: Set the event status to removed
parameters:
- description: Event ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.APIResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.APIResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/response.APIResponse'
summary: Set the event status to removed
tags:
- event
get:
consumes:
- application/json
description: Retrieve an upcoming event by ID
parameters:
- description: ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.UpcomingEvent'
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.APIResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/response.APIResponse'
summary: Retrieve an upcoming by ID
tags:
- prematch
/leagues:
get:
consumes:
- application/json
description: Gets all leagues
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/domain.League'
type: array
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.APIResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/response.APIResponse'
summary: Gets all leagues
tags:
- leagues
/manager/{id}/branch:
get:
consumes:
@ -2966,112 +3367,7 @@ paths:
summary: Update Managers
tags:
- manager
/operation:
post:
consumes:
- application/json
description: Creates a operation
parameters:
- description: Creates operation
in: body
name: createBranchOperation
required: true
schema:
$ref: '#/definitions/handlers.CreateBranchOperationReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.BranchOperationRes'
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.APIResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/response.APIResponse'
summary: Create a operation
tags:
- branch
/prematch/events:
get:
consumes:
- application/json
description: Retrieve all upcoming events from the database
parameters:
- description: Page number
in: query
name: page
type: integer
- description: Page size
in: query
name: page_size
type: integer
- description: League ID Filter
in: query
name: league_id
type: string
- description: Sport ID Filter
in: query
name: sport_id
type: string
- description: Start Time
in: query
name: first_start_time
type: string
- description: End Time
in: query
name: last_start_time
type: string
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 upcoming events
tags:
- prematch
/prematch/events/{id}:
get:
consumes:
- application/json
description: Retrieve an upcoming event by ID
parameters:
- description: ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.UpcomingEvent'
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.APIResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/response.APIResponse'
summary: Retrieve an upcoming by ID
tags:
- prematch
/prematch/odds:
/odds:
get:
consumes:
- application/json
@ -3092,38 +3388,7 @@ paths:
summary: Retrieve all prematch odds
tags:
- prematch
/prematch/odds/{event_id}:
get:
consumes:
- application/json
description: Retrieve prematch odds for a specific event by event ID
parameters:
- description: Event ID
in: path
name: event_id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/domain.Odd'
type: array
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.APIResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/response.APIResponse'
summary: Retrieve prematch odds for an event
tags:
- prematch
/prematch/odds/upcoming/{upcoming_id}:
/odds/upcoming/{upcoming_id}:
get:
consumes:
- application/json
@ -3163,7 +3428,7 @@ paths:
summary: Retrieve prematch odds by upcoming ID (FI)
tags:
- prematch
/prematch/odds/upcoming/{upcoming_id}/market/{market_id}:
/odds/upcoming/{upcoming_id}/market/{market_id}:
get:
consumes:
- application/json
@ -3199,6 +3464,36 @@ paths:
summary: Retrieve raw odds by Market ID
tags:
- prematch
/operation:
post:
consumes:
- application/json
description: Creates a operation
parameters:
- description: Creates operation
in: body
name: createBranchOperation
required: true
schema:
$ref: '#/definitions/handlers.CreateBranchOperationReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.BranchOperationRes'
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.APIResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/response.APIResponse'
summary: Create a operation
tags:
- branch
/random/bet:
post:
consumes:
@ -3336,7 +3631,7 @@ paths:
description: OK
schema:
items:
$ref: '#/definitions/domain.BetOutcome'
$ref: '#/definitions/handlers.ResultRes'
type: array
"400":
description: Bad Request

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: auth.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: bet.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: branch.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: cashier.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: company.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: copyfrom.go
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: events.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: leagues.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
package dbgen
@ -421,12 +421,13 @@ type VirtualGame struct {
ID int64 `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Category string `json:"category"`
Category pgtype.Text `json:"category"`
MinBet pgtype.Numeric `json:"min_bet"`
MaxBet pgtype.Numeric `json:"max_bet"`
Volatility string `json:"volatility"`
Volatility pgtype.Text `json:"volatility"`
IsActive bool `json:"is_active"`
Rtp pgtype.Numeric `json:"rtp"`
IsFeatured pgtype.Bool `json:"is_featured"`
IsFeatured bool `json:"is_featured"`
PopularityScore pgtype.Int4 `json:"popularity_score"`
ThumbnailUrl pgtype.Text `json:"thumbnail_url"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
@ -481,14 +482,15 @@ type WalletThresholdNotification struct {
type WalletTransfer struct {
ID int64 `json:"id"`
Amount int64 `json:"amount"`
Type string `json:"type"`
ReceiverWalletID int64 `json:"receiver_wallet_id"`
Amount pgtype.Int8 `json:"amount"`
Type pgtype.Text `json:"type"`
ReceiverWalletID pgtype.Int8 `json:"receiver_wallet_id"`
SenderWalletID pgtype.Int8 `json:"sender_wallet_id"`
CashierID pgtype.Int8 `json:"cashier_id"`
Verified bool `json:"verified"`
ReferenceNumber string `json:"reference_number"`
PaymentMethod string `json:"payment_method"`
Verified pgtype.Bool `json:"verified"`
ReferenceNumber pgtype.Text `json:"reference_number"`
Status pgtype.Text `json:"status"`
PaymentMethod pgtype.Text `json:"payment_method"`
CreatedAt pgtype.Timestamp `json:"created_at"`
UpdatedAt pgtype.Timestamp `json:"updated_at"`
}

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: monitor.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: notification.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: odds.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: otp.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: referal.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: result.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: ticket.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: transactions.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: transfer.sql
package dbgen
@ -20,21 +20,23 @@ INSERT INTO wallet_transfer (
cashier_id,
verified,
reference_number,
status,
payment_method
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, payment_method, created_at, updated_at
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, status, payment_method, created_at, updated_at
`
type CreateTransferParams struct {
Amount int64 `json:"amount"`
Type string `json:"type"`
ReceiverWalletID int64 `json:"receiver_wallet_id"`
Amount pgtype.Int8 `json:"amount"`
Type pgtype.Text `json:"type"`
ReceiverWalletID pgtype.Int8 `json:"receiver_wallet_id"`
SenderWalletID pgtype.Int8 `json:"sender_wallet_id"`
CashierID pgtype.Int8 `json:"cashier_id"`
Verified bool `json:"verified"`
ReferenceNumber string `json:"reference_number"`
PaymentMethod string `json:"payment_method"`
Verified pgtype.Bool `json:"verified"`
ReferenceNumber pgtype.Text `json:"reference_number"`
Status pgtype.Text `json:"status"`
PaymentMethod pgtype.Text `json:"payment_method"`
}
func (q *Queries) CreateTransfer(ctx context.Context, arg CreateTransferParams) (WalletTransfer, error) {
@ -46,6 +48,7 @@ func (q *Queries) CreateTransfer(ctx context.Context, arg CreateTransferParams)
arg.CashierID,
arg.Verified,
arg.ReferenceNumber,
arg.Status,
arg.PaymentMethod,
)
var i WalletTransfer
@ -58,6 +61,7 @@ func (q *Queries) CreateTransfer(ctx context.Context, arg CreateTransferParams)
&i.CashierID,
&i.Verified,
&i.ReferenceNumber,
&i.Status,
&i.PaymentMethod,
&i.CreatedAt,
&i.UpdatedAt,
@ -66,7 +70,7 @@ func (q *Queries) CreateTransfer(ctx context.Context, arg CreateTransferParams)
}
const GetAllTransfers = `-- name: GetAllTransfers :many
SELECT id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, payment_method, created_at, updated_at
SELECT id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, status, payment_method, created_at, updated_at
FROM wallet_transfer
`
@ -88,6 +92,7 @@ func (q *Queries) GetAllTransfers(ctx context.Context) ([]WalletTransfer, error)
&i.CashierID,
&i.Verified,
&i.ReferenceNumber,
&i.Status,
&i.PaymentMethod,
&i.CreatedAt,
&i.UpdatedAt,
@ -103,7 +108,7 @@ func (q *Queries) GetAllTransfers(ctx context.Context) ([]WalletTransfer, error)
}
const GetTransferByID = `-- name: GetTransferByID :one
SELECT id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, payment_method, created_at, updated_at
SELECT id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, status, payment_method, created_at, updated_at
FROM wallet_transfer
WHERE id = $1
`
@ -120,6 +125,7 @@ func (q *Queries) GetTransferByID(ctx context.Context, id int64) (WalletTransfer
&i.CashierID,
&i.Verified,
&i.ReferenceNumber,
&i.Status,
&i.PaymentMethod,
&i.CreatedAt,
&i.UpdatedAt,
@ -128,12 +134,12 @@ func (q *Queries) GetTransferByID(ctx context.Context, id int64) (WalletTransfer
}
const GetTransferByReference = `-- name: GetTransferByReference :one
SELECT id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, payment_method, created_at, updated_at
SELECT id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, status, payment_method, created_at, updated_at
FROM wallet_transfer
WHERE reference_number = $1
`
func (q *Queries) GetTransferByReference(ctx context.Context, referenceNumber string) (WalletTransfer, error) {
func (q *Queries) GetTransferByReference(ctx context.Context, referenceNumber pgtype.Text) (WalletTransfer, error) {
row := q.db.QueryRow(ctx, GetTransferByReference, referenceNumber)
var i WalletTransfer
err := row.Scan(
@ -145,6 +151,7 @@ func (q *Queries) GetTransferByReference(ctx context.Context, referenceNumber st
&i.CashierID,
&i.Verified,
&i.ReferenceNumber,
&i.Status,
&i.PaymentMethod,
&i.CreatedAt,
&i.UpdatedAt,
@ -153,13 +160,13 @@ func (q *Queries) GetTransferByReference(ctx context.Context, referenceNumber st
}
const GetTransfersByWallet = `-- name: GetTransfersByWallet :many
SELECT id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, payment_method, created_at, updated_at
SELECT id, amount, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, status, payment_method, created_at, updated_at
FROM wallet_transfer
WHERE receiver_wallet_id = $1
OR sender_wallet_id = $1
`
func (q *Queries) GetTransfersByWallet(ctx context.Context, receiverWalletID int64) ([]WalletTransfer, error) {
func (q *Queries) GetTransfersByWallet(ctx context.Context, receiverWalletID pgtype.Int8) ([]WalletTransfer, error) {
rows, err := q.db.Query(ctx, GetTransfersByWallet, receiverWalletID)
if err != nil {
return nil, err
@ -177,6 +184,7 @@ func (q *Queries) GetTransfersByWallet(ctx context.Context, receiverWalletID int
&i.CashierID,
&i.Verified,
&i.ReferenceNumber,
&i.Status,
&i.PaymentMethod,
&i.CreatedAt,
&i.UpdatedAt,
@ -191,6 +199,23 @@ func (q *Queries) GetTransfersByWallet(ctx context.Context, receiverWalletID int
return items, nil
}
const UpdateTransferStatus = `-- name: UpdateTransferStatus :exec
UPDATE wallet_transfer
SET status = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
`
type UpdateTransferStatusParams struct {
Status pgtype.Text `json:"status"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateTransferStatus(ctx context.Context, arg UpdateTransferStatusParams) error {
_, err := q.db.Exec(ctx, UpdateTransferStatus, arg.Status, arg.ID)
return err
}
const UpdateTransferVerification = `-- name: UpdateTransferVerification :exec
UPDATE wallet_transfer
SET verified = $1,
@ -199,7 +224,7 @@ WHERE id = $2
`
type UpdateTransferVerificationParams struct {
Verified bool `json:"verified"`
Verified pgtype.Bool `json:"verified"`
ID int64 `json:"id"`
}

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: user.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: virtual_games.sql
package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: wallet.sql
package dbgen

View File

@ -56,6 +56,9 @@ type VeliGamesConfig struct {
}
type Config struct {
FIXER_API_KEY string
FIXER_BASE_URL string
BASE_CURRENCY domain.IntCurrency
Port int
DbUrl string
RefreshExpiry int
@ -68,6 +71,8 @@ type Config struct {
AFRO_SMS_SENDER_NAME string
AFRO_SMS_RECEIVER_PHONE_NUMBER string
ADRO_SMS_HOST_URL string
CHAPA_TRANSFER_TYPE string
CHAPA_PAYMENT_TYPE string
CHAPA_SECRET_KEY string
CHAPA_PUBLIC_KEY string
CHAPA_BASE_URL string
@ -104,6 +109,13 @@ func (c *Config) loadEnv() error {
c.ReportExportPath = os.Getenv("REPORT_EXPORT_PATH")
c.CHAPA_TRANSFER_TYPE = os.Getenv("CHAPA_TRANSFER_TYPE")
c.CHAPA_PAYMENT_TYPE = os.Getenv("CHAPA_PAYMENT_TYPE")
c.FIXER_API_KEY = os.Getenv("FIXER_API_KEY")
c.BASE_CURRENCY = domain.IntCurrency(os.Getenv("BASE_CURRENCY"))
c.FIXER_BASE_URL = os.Getenv("FIXER_BASE_URL")
portStr := os.Getenv("PORT")
if portStr == "" {
return ErrInvalidPort

View File

@ -1,9 +1,27 @@
package domain
import "time"
import (
"errors"
"time"
)
var (
ErrInsufficientBalance = errors.New("insufficient balance")
ErrInvalidWithdrawalAmount = errors.New("invalid withdrawal amount")
ErrWithdrawalNotFound = errors.New("withdrawal not found")
)
type PaymentStatus string
type WithdrawalStatus string
const (
WithdrawalStatusPending WithdrawalStatus = "pending"
WithdrawalStatusProcessing WithdrawalStatus = "processing"
WithdrawalStatusCompleted WithdrawalStatus = "completed"
WithdrawalStatusFailed WithdrawalStatus = "failed"
)
const (
PaymentStatusPending PaymentStatus = "pending"
PaymentStatusCompleted PaymentStatus = "completed"
@ -91,3 +109,86 @@ type BankData struct {
UpdatedAt time.Time `json:"updated_at"`
Currency string `json:"currency"`
}
type ChapaWithdrawal struct {
ID string
UserID int64
Amount Currency
AccountNumber string
BankCode string
Status WithdrawalStatus
Reference string
CreatedAt time.Time
UpdatedAt time.Time
}
type ChapaWithdrawalRequest struct {
AccountName string `json:"account_name"`
AccountNumber string `json:"account_number"`
Amount string `json:"amount"` // string because Chapa API uses string for monetary values
Currency string `json:"currency"`
Reference string `json:"reference"`
BankCode int `json:"bank_code"`
}
// type ChapaWithdrawalRequest struct {
// AccountName string `json:"account_name"`
// AccountNumber string `json:"account_number"`
// Amount Currency `json:"amount"`
// Currency string `json:"currency"`
// BeneficiaryName string `json:"beneficiary_name"`
// BankCode string `json:"bank_code"`
// PhoneNumber string `json:"phone_number"`
// }
type ChapaWithdrawalResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data struct {
Reference string `json:"reference"`
} `json:"data"`
}
type ChapaTransactionType struct {
Type string `json:"type"`
}
type ChapaWebHookTransfer struct {
AccountName string `json:"account_name"`
AccountNumber string `json:"account_number"`
BankId string `json:"bank_id"`
BankName string `json:"bank_name"`
Currency string `json:"currency"`
Amount string `json:"amount"`
Type string `json:"type"`
Status string `json:"status"`
Reference string `json:"reference"`
TxRef string `json:"tx_ref"`
ChapaReference string `json:"chapa_reference"`
CreatedAt time.Time `json:"created_at"`
}
type ChapaWebHookPayment struct {
Event string `json:"event"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Mobile interface{} `json:"mobile"`
Currency string `json:"currency"`
Amount string `json:"amount"`
Charge string `json:"charge"`
Status string `json:"status"`
Mode string `json:"mode"`
Reference string `json:"reference"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Type string `json:"type"`
TxRef string `json:"tx_ref"`
PaymentMethod string `json:"payment_method"`
Customization struct {
Title interface{} `json:"title"`
Description interface{} `json:"description"`
Logo interface{} `json:"logo"`
} `json:"customization"`
Meta string `json:"meta"`
}

View File

@ -0,0 +1,88 @@
package domain
import (
"errors"
"fmt"
"time"
)
type IntCurrency string
const (
ETB IntCurrency = "ETB" // Ethiopian Birr
NGN IntCurrency = "NGN" // Nigerian Naira
ZAR IntCurrency = "ZAR" // South African Rand
EGP IntCurrency = "EGP" // Egyptian Pound
KES IntCurrency = "KES" // Kenyan Shilling
UGX IntCurrency = "UGX" // Ugandan Shilling
TZS IntCurrency = "TZS" // Tanzanian Shilling
RWF IntCurrency = "RWF" // Rwandan Franc
BIF IntCurrency = "BIF" // Burundian Franc
XOF IntCurrency = "XOF" // West African CFA Franc (BCEAO)
XAF IntCurrency = "XAF" // Central African CFA Franc (BEAC)
GHS IntCurrency = "GHS" // Ghanaian Cedi
SDG IntCurrency = "SDG" // Sudanese Pound
SSP IntCurrency = "SSP" // South Sudanese Pound
DZD IntCurrency = "DZD" // Algerian Dinar
MAD IntCurrency = "MAD" // Moroccan Dirham
TND IntCurrency = "TND" // Tunisian Dinar
LYD IntCurrency = "LYD" // Libyan Dinar
MZN IntCurrency = "MZN" // Mozambican Metical
AOA IntCurrency = "AOA" // Angolan Kwanza
BWP IntCurrency = "BWP" // Botswana Pula
ZMW IntCurrency = "ZMW" // Zambian Kwacha
MWK IntCurrency = "MWK" // Malawian Kwacha
LSL IntCurrency = "LSL" // Lesotho Loti
NAD IntCurrency = "NAD" // Namibian Dollar
SZL IntCurrency = "SZL" // Swazi Lilangeni
CVE IntCurrency = "CVE" // Cape Verdean Escudo
GMD IntCurrency = "GMD" // Gambian Dalasi
SLL IntCurrency = "SLL" // Sierra Leonean Leone
LRD IntCurrency = "LRD" // Liberian Dollar
GNF IntCurrency = "GNF" // Guinean Franc
XCD IntCurrency = "XCD" // Eastern Caribbean Dollar (used in Saint Lucia)
MRU IntCurrency = "MRU" // Mauritanian Ouguiya
KMF IntCurrency = "KMF" // Comorian Franc
DJF IntCurrency = "DJF" // Djiboutian Franc
SOS IntCurrency = "SOS" // Somali Shilling
ERN IntCurrency = "ERN" // Eritrean Nakfa
MGA IntCurrency = "MGA" // Malagasy Ariary
SCR IntCurrency = "SCR" // Seychellois Rupee
MUR IntCurrency = "MUR" // Mauritian Rupee
// International currencies (already listed)
USD IntCurrency = "USD" // US Dollar
EUR IntCurrency = "EUR" // Euro
GBP IntCurrency = "GBP" // British Pound
)
var (
ErrUnsupportedIntCurrency = errors.New("unsupported IntCurrency")
ErrIntCurrencyConversion = errors.New("IntCurrency conversion failed")
)
// IntCurrencyRate represents exchange rate between two currencies
type IntCurrencyRate struct {
From IntCurrency
To IntCurrency
Rate float64
ValidUntil time.Time
}
// Convert converts amount from one IntCurrency to another
func (cr IntCurrencyRate) Convert(amount float64) (float64, error) {
if time.Now().After(cr.ValidUntil) {
return 0, fmt.Errorf("%w: rate expired", ErrIntCurrencyConversion)
}
return amount * cr.Rate, nil
}
// ValidateIntCurrency checks if IntCurrency is supported
func ValidateIntCurrency(c IntCurrency) error {
switch c {
case ETB, USD, EUR, GBP:
return nil
default:
return fmt.Errorf("%w: %s", ErrUnsupportedIntCurrency, c)
}
}

View File

@ -14,6 +14,14 @@ func UnProcessableEntityResponse(c *fiber.Ctx) error {
})
}
func UnExpectedErrorResponse(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(Response{
Message: "Unexpected internal error",
StatusCode: fiber.StatusInternalServerError,
Success: false,
})
}
func FiberErrorResponse(c *fiber.Ctx, err error) error {
var statusCode int
var message string

View File

@ -33,6 +33,7 @@ type Transfer struct {
ReceiverWalletID int64
SenderWalletID int64
ReferenceNumber string
Status string
CashierID ValidInt64
CreatedAt time.Time
UpdatedAt time.Time
@ -42,6 +43,7 @@ type CreateTransfer struct {
Amount Currency
Verified bool
ReferenceNumber string
Status string
ReceiverWalletID int64
SenderWalletID int64
CashierID ValidInt64

View File

@ -5,6 +5,7 @@ import "time"
type Wallet struct {
ID int64
Balance Currency
Currency IntCurrency
IsWithdraw bool
IsBettable bool
IsTransferable bool

View File

@ -0,0 +1,96 @@
package repository
import (
"context"
"database/sql"
"fmt"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
)
type CurrencyRepository interface {
GetExchangeRate(ctx context.Context, from, to domain.IntCurrency) (domain.IntCurrencyRate, error)
StoreExchangeRate(ctx context.Context, rate domain.IntCurrencyRate) error
GetSupportedCurrencies(ctx context.Context) ([]domain.IntCurrency, error)
}
type CurrencyPostgresRepository struct {
store *Store
}
func NewCurrencyPostgresRepository(store *Store) *CurrencyPostgresRepository {
return &CurrencyPostgresRepository{store: store}
}
func (r *CurrencyPostgresRepository) GetExchangeRate(ctx context.Context, from, to domain.IntCurrency) (domain.IntCurrencyRate, error) {
const query = `
SELECT from_currency, to_currency, rate, precision, valid_until
FROM exchange_rates
WHERE from_currency = $1 AND to_currency = $2 AND valid_until > NOW()
ORDER BY created_at DESC
LIMIT 1`
var rate domain.IntCurrencyRate
err := r.store.conn.QueryRow(ctx, query, from, to).Scan(
&rate.From,
&rate.To,
&rate.Rate,
&rate.ValidUntil,
)
if err != nil {
if err == sql.ErrNoRows {
return domain.IntCurrencyRate{}, fmt.Errorf("%w: no rate found for %s to %s",
domain.ErrIntCurrencyConversion, from, to)
}
return domain.IntCurrencyRate{}, fmt.Errorf("failed to get exchange rate: %w", err)
}
return rate, nil
}
func (r *CurrencyPostgresRepository) StoreExchangeRate(ctx context.Context, rate domain.IntCurrencyRate) error {
const query = `
INSERT INTO exchange_rates (from_currency, to_currency, rate, precision, valid_until)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (from_currency, to_currency)
DO UPDATE SET
rate = EXCLUDED.rate,
precision = EXCLUDED.precision,
valid_until = EXCLUDED.valid_until,
created_at = NOW()`
_, err := r.store.conn.Exec(ctx, query,
rate.From,
rate.To,
rate.Rate,
rate.ValidUntil)
if err != nil {
return fmt.Errorf("failed to store exchange rate: %w", err)
}
return nil
}
func (r *CurrencyPostgresRepository) GetSupportedCurrencies(ctx context.Context) ([]domain.IntCurrency, error) {
const query = `SELECT DISTINCT currency FROM supported_currencies ORDER BY currency`
var currencies []domain.IntCurrency
rows, err := r.store.conn.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to get supported currencies: %w", err)
}
defer rows.Close()
for rows.Next() {
var currency domain.IntCurrency
if err := rows.Scan(&currency); err != nil {
return nil, fmt.Errorf("failed to scan currency: %w", err)
}
currencies = append(currencies, currency)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("row iteration error: %w", err)
}
return currencies, nil
}

View File

@ -11,24 +11,27 @@ import (
func convertDBTransfer(transfer dbgen.WalletTransfer) domain.Transfer {
return domain.Transfer{
ID: transfer.ID,
Amount: domain.Currency(transfer.Amount),
Type: domain.TransferType(transfer.Type),
Verified: transfer.Verified,
ReceiverWalletID: transfer.ReceiverWalletID,
Amount: domain.Currency(transfer.Amount.Int64),
Type: domain.TransferType(transfer.Type.String),
Verified: transfer.Verified.Bool,
ReceiverWalletID: transfer.ReceiverWalletID.Int64,
SenderWalletID: transfer.SenderWalletID.Int64,
CashierID: domain.ValidInt64{
Value: transfer.CashierID.Int64,
Valid: transfer.CashierID.Valid,
},
PaymentMethod: domain.PaymentMethod(transfer.PaymentMethod),
PaymentMethod: domain.PaymentMethod(transfer.PaymentMethod.String),
}
}
func convertCreateTransfer(transfer domain.CreateTransfer) dbgen.CreateTransferParams {
return dbgen.CreateTransferParams{
Amount: int64(transfer.Amount),
Type: string(transfer.Type),
ReceiverWalletID: transfer.ReceiverWalletID,
Amount: pgtype.Int8{Int64: int64(transfer.Amount), Valid: true},
Type: pgtype.Text{String: string(transfer.Type), Valid: true},
ReceiverWalletID: pgtype.Int8{
Int64: transfer.ReceiverWalletID,
Valid: true,
},
SenderWalletID: pgtype.Int8{
Int64: transfer.SenderWalletID,
Valid: true,
@ -37,7 +40,7 @@ func convertCreateTransfer(transfer domain.CreateTransfer) dbgen.CreateTransferP
Int64: transfer.CashierID.Value,
Valid: transfer.CashierID.Valid,
},
PaymentMethod: string(transfer.PaymentMethod),
PaymentMethod: pgtype.Text{String: string(transfer.PaymentMethod), Valid: true},
}
}
@ -62,7 +65,7 @@ func (s *Store) GetAllTransfers(ctx context.Context) ([]domain.Transfer, error)
return result, nil
}
func (s *Store) GetTransfersByWallet(ctx context.Context, walletID int64) ([]domain.Transfer, error) {
transfers, err := s.queries.GetTransfersByWallet(ctx, walletID)
transfers, err := s.queries.GetTransfersByWallet(ctx, pgtype.Int8{Int64: walletID, Valid: true})
if err != nil {
return nil, err
}
@ -76,7 +79,7 @@ func (s *Store) GetTransfersByWallet(ctx context.Context, walletID int64) ([]dom
}
func (s *Store) GetTransferByReference(ctx context.Context, reference string) (domain.Transfer, error) {
transfer, err := s.queries.GetTransferByReference(ctx, reference)
transfer, err := s.queries.GetTransferByReference(ctx, pgtype.Text{String: reference, Valid: true})
if err != nil {
return domain.Transfer{}, nil
}
@ -94,7 +97,16 @@ func (s *Store) GetTransferByID(ctx context.Context, id int64) (domain.Transfer,
func (s *Store) UpdateTransferVerification(ctx context.Context, id int64, verified bool) error {
err := s.queries.UpdateTransferVerification(ctx, dbgen.UpdateTransferVerificationParams{
ID: id,
Verified: verified,
Verified: pgtype.Bool{Bool: verified, Valid: true},
})
return err
}
func (s *Store) UpdateTransferStatus(ctx context.Context, id int64, status string) error {
err := s.queries.UpdateTransferStatus(ctx, dbgen.UpdateTransferStatusParams{
ID: id,
Status: pgtype.Text{String: status, Valid: true},
})
return err

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
@ -211,24 +212,78 @@ func (c *Client) FetchSupportedBanks(ctx context.Context) ([]domain.Bank, error)
return banks, nil
}
// Helper method to generate account regex based on bank type
// func GetAccountRegex(bank domain.Bank) string {
// if bank.IsMobileMoney != nil && bank.IsMobileMoney == 1 {
// return `^09[0-9]{8}$` // Ethiopian mobile money pattern
// }
// return fmt.Sprintf(`^[0-9]{%d}$`, bank.AcctLength)
// }
func (c *Client) InitiateTransfer(ctx context.Context, req domain.ChapaWithdrawalRequest) (bool, error) {
// base, err := url.Parse(c.baseURL)
// if err != nil {
// return false, fmt.Errorf("invalid base URL: %w", err)
// }
endpoint := c.baseURL + "/transfers"
fmt.Printf("\n\nChapa withdrawal URL is %v\n\n", endpoint)
// // Helper method to generate example account number
// func GetExampleAccount(bank domain.Bank) string {
// if bank.IsMobileMoney != nil && *bank.IsMobileMoney {
// return "0912345678" // Ethiopian mobile number example
// }
reqBody, err := json.Marshal(req)
if err != nil {
return false, fmt.Errorf("failed to marshal request: %w", err)
}
// // Generate example based on length
// example := "1"
// for i := 1; i < bank.AcctLength; i++ {
// example += fmt.Sprintf("%d", i%10)
// }
// return example
// }
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(reqBody))
if err != nil {
return false, fmt.Errorf("failed to create request: %w", err)
}
c.setHeaders(httpReq)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return false, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("chapa api returned status: %d", resp.StatusCode)
}
var response domain.ChapaWithdrawalResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return false, fmt.Errorf("failed to decode response: %w", err)
}
return response.Status == string(domain.WithdrawalStatusProcessing), nil
}
func (c *Client) VerifyTransfer(ctx context.Context, reference string) (*domain.ChapaVerificationResponse, error) {
base, err := url.Parse(c.baseURL)
if err != nil {
return nil, fmt.Errorf("invalid base URL: %w", err)
}
endpoint := base.ResolveReference(&url.URL{Path: fmt.Sprintf("/v1/transfers/%s/verify", reference)})
httpReq, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
c.setHeaders(httpReq)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("chapa api returned status: %d", resp.StatusCode)
}
var verification domain.ChapaVerificationResponse
if err := json.NewDecoder(resp.Body).Decode(&verification); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &verification, nil
}
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.secretKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
}

View File

@ -16,7 +16,10 @@ import (
type ChapaStore interface {
InitializePayment(request domain.ChapaDepositRequest) (domain.ChapaDepositResponse, error)
VerifyPayment(reference string) (domain.ChapaDepositVerification, error)
ManualVerifyPayment(ctx context.Context, txRef string) (*domain.ChapaVerificationResponse, error)
// VerifyPayment(reference string) (domain.ChapaDepositVerification, error)
ManualVerifTransaction(ctx context.Context, txRef string) (*domain.ChapaVerificationResponse, error)
FetchSupportedBanks(ctx context.Context) ([]domain.Bank, error)
CreateWithdrawal(userID string, amount float64, accountNumber, bankCode string) (*domain.ChapaWithdrawal, error)
HandleVerifyDepositWebhook(ctx context.Context, transfer domain.ChapaWebHookTransfer) error
HandleVerifyWithdrawWebhook(ctx context.Context, payment domain.ChapaWebHookPayment) error
}

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strconv"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
@ -20,7 +21,7 @@ var (
type Service struct {
transferStore wallet.TransferStore
walletStore wallet.WalletStore
walletStore wallet.Service
userStore user.UserStore
cfg *config.Config
chapaClient *Client
@ -28,7 +29,7 @@ type Service struct {
func NewService(
transferStore wallet.TransferStore,
walletStore wallet.WalletStore,
walletStore wallet.Service,
userStore user.UserStore,
chapaClient *Client,
@ -110,42 +111,100 @@ func (s *Service) InitiateDeposit(ctx context.Context, userID int64, amount doma
return response.CheckoutURL, nil
}
func (s *Service) InitiateWithdrawal(ctx context.Context, userID int64, req domain.ChapaWithdrawalRequest) (*domain.Transfer, error) {
// Parse and validate amount
amount, err := strconv.ParseInt(req.Amount, 10, 64)
if err != nil || amount <= 0 {
return nil, domain.ErrInvalidWithdrawalAmount
}
// VerifyDeposit handles payment verification from webhook
func (s *Service) VerifyDeposit(ctx context.Context, reference string) error {
// Find payment by reference
payment, err := s.transferStore.GetTransferByReference(ctx, reference)
// Get user details
// user, err := s.userStore.GetUserByID(ctx, userID)
// if err != nil {
// return nil, fmt.Errorf("failed to get user: %w", err)
// }
// Get user's wallet
wallets, err := s.walletStore.GetWalletsByUser(ctx, userID)
if err != nil {
return ErrPaymentNotFound
return nil, fmt.Errorf("failed to get user wallets: %w", err)
}
// Skip if already completed
if payment.Verified {
return nil
var withdrawWallet domain.Wallet
for _, wallet := range wallets {
if wallet.IsWithdraw {
withdrawWallet = wallet
break
}
}
// Verify payment with Chapa
verification, err := s.chapaClient.VerifyPayment(ctx, reference)
if withdrawWallet.ID == 0 {
return nil, errors.New("withdrawal wallet not found")
}
// Check balance
if withdrawWallet.Balance < domain.Currency(amount) {
return nil, domain.ErrInsufficientBalance
}
// Generate unique reference
reference := uuid.New().String()
createTransfer := domain.CreateTransfer{
Amount: domain.Currency(amount),
Type: domain.WITHDRAW,
ReceiverWalletID: 1,
SenderWalletID: withdrawWallet.ID,
Status: string(domain.PaymentStatusPending),
Verified: false,
ReferenceNumber: reference,
PaymentMethod: domain.TRANSFER_CHAPA,
}
transfer, err := s.transferStore.CreateTransfer(ctx, createTransfer)
if err != nil {
return fmt.Errorf("failed to verify payment: %w", err)
return nil, fmt.Errorf("failed to create transfer record: %w", err)
}
// Initiate transfer with Chapa
transferReq := domain.ChapaWithdrawalRequest{
AccountName: req.AccountName,
AccountNumber: req.AccountNumber,
Amount: fmt.Sprintf("%d", amount),
Currency: req.Currency,
Reference: reference,
// BeneficiaryName: fmt.Sprintf("%s %s", user.FirstName, user.LastName),
BankCode: req.BankCode,
}
// Update payment status
if err := s.transferStore.UpdateTransferVerification(ctx, payment.ID, true); err != nil {
return fmt.Errorf("failed to update payment status: %w", err)
success, err := s.chapaClient.InitiateTransfer(ctx, transferReq)
if err != nil || !success {
// Update withdrawal status to failed
_ = s.transferStore.UpdateTransferStatus(ctx, transfer.ID, string(domain.WithdrawalStatusFailed))
return nil, fmt.Errorf("failed to initiate transfer: %w", err)
}
// If payment is completed, credit user's wallet
if verification.Status == domain.PaymentStatusCompleted {
if err := s.walletStore.UpdateBalance(ctx, payment.SenderWalletID, payment.Amount); err != nil {
return fmt.Errorf("failed to credit user wallet: %w", err)
// Update withdrawal status to processing
if err := s.transferStore.UpdateTransferStatus(ctx, transfer.ID, string(domain.WithdrawalStatusProcessing)); err != nil {
return nil, fmt.Errorf("failed to update withdrawal status: %w", err)
}
// Deduct from wallet (or wait for webhook confirmation depending on your flow)
newBalance := withdrawWallet.Balance - domain.Currency(amount)
if err := s.walletStore.UpdateBalance(ctx, withdrawWallet.ID, newBalance); err != nil {
return nil, fmt.Errorf("failed to update wallet balance: %w", err)
}
return nil
return &transfer, nil
}
func (s *Service) ManualVerifyPayment(ctx context.Context, txRef string) (*domain.ChapaVerificationResponse, error) {
func (s *Service) GetSupportedBanks(ctx context.Context) ([]domain.Bank, error) {
banks, err := s.chapaClient.FetchSupportedBanks(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch banks: %w", err)
}
return banks, nil
}
func (s *Service) ManualVerifTransaction(ctx context.Context, txRef string) (*domain.ChapaVerificationResponse, error) {
// First check if we already have a verified record
transfer, err := s.transferStore.GetTransferByReference(ctx, txRef)
if err == nil && transfer.Verified {
@ -183,10 +242,76 @@ func (s *Service) ManualVerifyPayment(ctx context.Context, txRef string) (*domai
}, nil
}
func (s *Service) GetSupportedBanks(ctx context.Context) ([]domain.Bank, error) {
banks, err := s.chapaClient.FetchSupportedBanks(ctx)
func (s *Service) HandleVerifyDepositWebhook(ctx context.Context, transfer domain.ChapaWebHookTransfer) error {
// Find payment by reference
payment, err := s.transferStore.GetTransferByReference(ctx, transfer.Reference)
if err != nil {
return nil, fmt.Errorf("failed to fetch banks: %w", err)
return ErrPaymentNotFound
}
return banks, nil
if payment.Verified {
return nil
}
// Verify payment with Chapa
// verification, err := s.chapaClient.VerifyPayment(ctx, transfer.Reference)
// if err != nil {
// return fmt.Errorf("failed to verify payment: %w", err)
// }
// Update payment status
// verified := false
// if transfer.Status == string(domain.PaymentStatusCompleted) {
// verified = true
// }
if err := s.transferStore.UpdateTransferVerification(ctx, payment.ID, true); err != nil {
return fmt.Errorf("failed to update payment status: %w", err)
}
// If payment is completed, credit user's wallet
if transfer.Status == string(domain.PaymentStatusCompleted) {
if err := s.walletStore.AddToWallet(ctx, payment.SenderWalletID, payment.Amount); err != nil {
return fmt.Errorf("failed to credit user wallet: %w", err)
}
}
return nil
}
func (s *Service) HandleVerifyWithdrawWebhook(ctx context.Context, payment domain.ChapaWebHookPayment) error {
// Find payment by reference
transfer, err := s.transferStore.GetTransferByReference(ctx, payment.Reference)
if err != nil {
return ErrPaymentNotFound
}
if transfer.Verified {
return nil
}
// Verify payment with Chapa
// verification, err := s.chapaClient.VerifyPayment(ctx, payment.Reference)
// if err != nil {
// return fmt.Errorf("failed to verify payment: %w", err)
// }
// Update payment status
// verified := false
// if transfer.Status == string(domain.PaymentStatusCompleted) {
// verified = true
// }
if err := s.transferStore.UpdateTransferVerification(ctx, transfer.ID, true); err != nil {
return fmt.Errorf("failed to update payment status: %w", err)
}
// If payment is completed, credit user's wallet
if payment.Status == string(domain.PaymentStatusFailed) {
if err := s.walletStore.AddToWallet(ctx, transfer.SenderWalletID, transfer.Amount); err != nil {
return fmt.Errorf("failed to credit user wallet: %w", err)
}
}
return nil
}

View File

@ -0,0 +1,69 @@
package currency
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
)
type FixerFetcher struct {
apiKey string
baseURL string
httpClient *http.Client
}
func NewFixerFetcher(apiKey string, baseURL string) *FixerFetcher {
return &FixerFetcher{
apiKey: apiKey,
baseURL: baseURL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
type fixerResponse struct {
Success bool `json:"success"`
Base string `json:"base"`
Date string `json:"date"`
Rates map[string]float64 `json:"rates"`
}
func (f *FixerFetcher) FetchLatestRates(ctx context.Context, baseCurrency domain.IntCurrency) (map[domain.IntCurrency]float64, error) {
url := fmt.Sprintf("%s/latest?base=%s", f.baseURL, baseCurrency)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("apikey", f.apiKey)
resp, err := f.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch rates: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var result fixerResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if !result.Success {
return nil, fmt.Errorf("api returned unsuccessful response")
}
rates := make(map[domain.IntCurrency]float64)
for currency, rate := range result.Rates {
rates[domain.IntCurrency(currency)] = rate
}
return rates, nil
}

View File

@ -0,0 +1,125 @@
package currency
import (
"context"
"fmt"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/repository"
)
type Service struct {
repo repository.CurrencyRepository
baseCurrency domain.IntCurrency
fixerFetcher *FixerFetcher
}
func NewService(repo repository.CurrencyRepository, baseCurrency domain.IntCurrency, fixerFetcher *FixerFetcher) *Service {
return &Service{repo: repo}
}
func (s *Service) Convert(ctx context.Context, amount float64, from, to domain.IntCurrency) (float64, error) {
if from == to {
return amount, nil
}
rate, err := s.repo.GetExchangeRate(ctx, from, to)
if err != nil {
return 0, err
}
return rate.Convert(amount)
}
func (s *Service) GetSupportedCurrencies(ctx context.Context) ([]domain.IntCurrency, error) {
return s.repo.GetSupportedCurrencies(ctx)
}
func (s *Service) UpdateRates(ctx context.Context) error {
// Implement fetching from external API (e.g., Fixer, Open Exchange Rates)
rates := map[domain.IntCurrency]map[domain.IntCurrency]float64{
domain.ETB: {
domain.USD: 0.018,
domain.EUR: 0.016,
domain.GBP: 0.014,
},
// Add other currencies...
}
for from, toRates := range rates {
for to, rate := range toRates {
err := s.repo.StoreExchangeRate(ctx, domain.IntCurrencyRate{
From: from,
To: to,
Rate: rate,
ValidUntil: time.Now().Add(24 * time.Hour), // Refresh daily
})
if err != nil {
return err
}
}
}
return nil
}
func (s *Service) FetchAndStoreRates(ctx context.Context) error {
// s.logger.Info("Starting exchange rate update")
rates, err := s.fixerFetcher.FetchLatestRates(ctx, s.baseCurrency)
if err != nil {
// s.logger.Error("Failed to fetch rates", "error", err)
return fmt.Errorf("failed to fetch rates: %w", err)
}
// Convert to integer rates with precision
const precision = 6 // 1.000000
for currency, rate := range rates {
if currency == s.baseCurrency {
continue
}
intRate := domain.IntCurrencyRate{
From: s.baseCurrency,
To: currency,
Rate: rate * float64(pow10(precision)),
ValidUntil: time.Now().Add(24 * time.Hour), // Rates valid for 24 hours
}
if err := s.repo.StoreExchangeRate(ctx, intRate); err != nil {
// s.logger.Error("Failed to store rate",
// "from", s.baseCurrency,
// "to", currency,
// "error", err)
continue // Try to store other rates even if one fails
}
// Also store the inverse rate
inverseRate := domain.IntCurrencyRate{
From: currency,
To: s.baseCurrency,
Rate: (1 / rate) * float64(pow10(precision)),
ValidUntil: time.Now().Add(24 * time.Hour),
}
if err := s.repo.StoreExchangeRate(ctx, inverseRate); err != nil {
// s.logger.Error("Failed to store inverse rate",
// "from", currency,
// "to", s.baseCurrency,
// "error", err)
return fmt.Errorf("Error storing exchange rates")
}
}
// s.logger.Info("Exchange rates updated successfully")
return nil
}
func pow10(n int) int64 {
result := int64(1)
for i := 0; i < n; i++ {
result *= 10
}
return result
}

View File

@ -0,0 +1,55 @@
package currency
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
"github.com/go-co-op/gocron"
)
type ExchangeRateWorker struct {
fetcherService *FixerFetcher
scheduler *gocron.Scheduler
logger *slog.Logger
cfg *config.Config
}
func NewExchangeRateWorker(
fetcherService *FixerFetcher, logger *slog.Logger, cfg *config.Config,
) *ExchangeRateWorker {
return &ExchangeRateWorker{
fetcherService: fetcherService,
scheduler: gocron.NewScheduler(time.UTC),
logger: logger,
cfg: cfg,
}
}
func (w *ExchangeRateWorker) Start(ctx context.Context) {
_, err := w.scheduler.Every(6).Hours().Do(w.RunUpdate)
if err != nil {
return
}
// Run immediately on startup
go w.RunUpdate()
w.scheduler.StartAsync()
}
func (w *ExchangeRateWorker) RunUpdate() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := w.fetcherService.FetchLatestRates(ctx, w.cfg.BASE_CURRENCY); err != nil {
fmt.Println("Exchange rate update failed", "error", err)
}
}
func (w *ExchangeRateWorker) Stop() {
w.scheduler.Stop()
w.logger.Info("Exchange rate worker stopped")
}

View File

@ -28,4 +28,5 @@ type TransferStore interface {
GetTransferByReference(ctx context.Context, reference string) (domain.Transfer, error)
GetTransferByID(ctx context.Context, id int64) (domain.Transfer, error)
UpdateTransferVerification(ctx context.Context, id int64, verified bool) error
UpdateTransferStatus(ctx context.Context, id int64, status string) error
}

View File

@ -116,6 +116,10 @@ func (s *Service) UpdateTransferVerification(ctx context.Context, id int64, veri
return s.transferStore.UpdateTransferVerification(ctx, id, verified)
}
func (s *Service) UpdateTransferStatus(ctx context.Context, id int64, status string) error {
return s.transferStore.UpdateTransferStatus(ctx, id, status)
}
func (s *Service) RefillWallet(ctx context.Context, transfer domain.CreateTransfer) (domain.Transfer, error) {
receiverWallet, err := s.GetWalletByID(ctx, transfer.ReceiverWalletID)
if err != nil {

View File

@ -10,6 +10,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/chapa"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/company"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/currency"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/event"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/league"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds"
@ -34,6 +35,7 @@ import (
)
type App struct {
currSvc *currency.Service
fiber *fiber.App
aleaVirtualGameService alea.AleaVirtualGameService
veliVirtualGameService veli.VeliVirtualGameService
@ -64,6 +66,7 @@ type App struct {
}
func NewApp(
currSvc *currency.Service,
port int, validator *customvalidator.CustomValidator,
authSvc *authentication.Service,
logger *slog.Logger,
@ -104,6 +107,7 @@ func NewApp(
}))
s := &App{
currSvc: currSvc,
fiber: app,
port: port,
authSvc: authSvc,

View File

@ -13,6 +13,7 @@ import (
// @Tags Chapa
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param request body domain.ChapaDepositRequestPayload true "Deposit request"
// @Success 200 {object} domain.ChapaDepositResponse
// @Failure 400 {object} domain.ErrorResponse
@ -65,39 +66,60 @@ func (h *Handler) InitiateDeposit(c *fiber.Ctx) error {
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/chapa/payments/webhook/verify [post]
func (h *Handler) WebhookCallback(c *fiber.Ctx) error {
// Verify webhook signature first
// signature := c.Get("Chapa-Signature")
// if !verifySignature(signature, c.Body()) {
// return c.Status(fiber.StatusUnauthorized).JSON(ErrorResponse{
// Error: "invalid signature",
// })
// }
var payload struct {
TxRef string `json:"tx_ref"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Status string `json:"status"`
chapaTransactionType := new(domain.ChapaTransactionType)
if parseTypeErr := c.BodyParser(chapaTransactionType); parseTypeErr != nil {
return domain.UnProcessableEntityResponse(c)
}
if err := c.BodyParser(&payload); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
})
switch chapaTransactionType.Type {
case h.Cfg.CHAPA_TRANSFER_TYPE:
chapaTransferVerificationRequest := new(domain.ChapaWebHookTransfer)
if err := c.BodyParser(chapaTransferVerificationRequest); err != nil {
return domain.UnProcessableEntityResponse(c)
}
if err := h.chapaSvc.VerifyDeposit(c.Context(), payload.TxRef); err != nil {
err := h.chapaSvc.HandleVerifyDepositWebhook(c.Context(), *chapaTransferVerificationRequest)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to verify Chapa depposit",
Error: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
StatusCode: 200,
Message: "payment verified successfully",
Data: payload.TxRef,
Message: "Chapa deposit transaction verified successfully",
Data: chapaTransferVerificationRequest,
Success: true,
})
case h.Cfg.CHAPA_PAYMENT_TYPE:
chapaPaymentVerificationRequest := new(domain.ChapaWebHookPayment)
if err := c.BodyParser(chapaPaymentVerificationRequest); err != nil {
return domain.UnProcessableEntityResponse(c)
}
err := h.chapaSvc.HandleVerifyWithdrawWebhook(c.Context(), *chapaPaymentVerificationRequest)
if err != nil {
return domain.UnExpectedErrorResponse(c)
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
StatusCode: 200,
Message: "Chapa withdrawal transaction verified successfully",
Data: chapaPaymentVerificationRequest,
Success: true,
})
}
// Return a 400 Bad Request if the type does not match any known case
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid Chapa webhook type",
Error: "Unknown transaction type",
})
}
// VerifyPayment godoc
@ -111,7 +133,7 @@ func (h *Handler) WebhookCallback(c *fiber.Ctx) error {
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/chapa/payments/manual/verify/{tx_ref} [get]
func (h *Handler) ManualVerifyPayment(c *fiber.Ctx) error {
func (h *Handler) ManualVerifyTransaction(c *fiber.Ctx) error {
txRef := c.Params("tx_ref")
if txRef == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
@ -120,7 +142,7 @@ func (h *Handler) ManualVerifyPayment(c *fiber.Ctx) error {
})
}
verification, err := h.chapaSvc.ManualVerifyPayment(c.Context(), txRef)
verification, err := h.chapaSvc.ManualVerifTransaction(c.Context(), txRef)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to verify Chapa transaction",
@ -142,9 +164,9 @@ func (h *Handler) ManualVerifyPayment(c *fiber.Ctx) error {
// @Tags Chapa
// @Accept json
// @Produce json
// @Success 200 {array} domain.Bank
// @Success 200 {object} domain.Response
// @Failure 500 {object} domain.ErrorResponse
// @Router /banks [get]
// @Router /api/v1/chapa/banks [get]
func (h *Handler) GetSupportedBanks(c *fiber.Ctx) error {
banks, err := h.chapaSvc.GetSupportedBanks(c.Context())
if err != nil {
@ -161,3 +183,44 @@ func (h *Handler) GetSupportedBanks(c *fiber.Ctx) error {
Data: banks,
})
}
// InitiateWithdrawal initiates a withdrawal request via Chapa payment gateway
// @Summary Initiate a withdrawal
// @Description Initiates a withdrawal request to transfer funds to a bank account via Chapa
// @Tags Chapa
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param request body domain.ChapaWithdrawalRequest true "Withdrawal request details"
// @Success 201 {object} domain.Response "Chapa withdrawal process initiated successfully"
// @Failure 400 {object} domain.ErrorResponse "Invalid request body"
// @Failure 401 {object} domain.ErrorResponse "Unauthorized"
// @Failure 422 {object} domain.ErrorResponse "Unprocessable entity"
// @Failure 500 {object} domain.ErrorResponse "Internal server error"
// @Router /api/v1/chapa/payments/withdraw [post]
func (h *Handler) InitiateWithdrawal(c *fiber.Ctx) error {
userID, ok := c.Locals("user_id").(int64)
if !ok {
return domain.UnProcessableEntityResponse(c)
}
var req domain.ChapaWithdrawalRequest
if err := c.BodyParser(&req); err != nil {
return domain.UnProcessableEntityResponse(c)
}
withdrawal, err := h.chapaSvc.InitiateWithdrawal(c.Context(), userID, req)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to initiate Chapa withdrawal",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Chapa withdrawal process initiated successfully",
StatusCode: 201,
Success: true,
Data: withdrawal,
})
}

View File

@ -0,0 +1,57 @@
package handlers
import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/gofiber/fiber/v2"
)
// @Summary Get supported currencies
// @Description Returns list of supported currencies
// @Tags Multi-Currency
// @Produce json
// @Success 200 {object} domain.Response{data=[]domain.Currency}
// @Router /api/v1/currencies [get]
func (h *Handler) GetSupportedCurrencies(c *fiber.Ctx) error {
currencies, err := h.currSvc.GetSupportedCurrencies(c.Context())
if err != nil {
return domain.UnExpectedErrorResponse(c)
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Success: true,
Message: "Supported currencies retrieved successfully",
Data: currencies,
StatusCode: fiber.StatusOK,
})
}
// @Summary Convert currency
// @Description Converts amount from one currency to another
// @Tags Multi-Currency
// @Produce json
// @Param from query string true "Source currency code (e.g., USD)"
// @Param to query string true "Target currency code (e.g., ETB)"
// @Param amount query number true "Amount to convert"
// @Success 200 {object} domain.Response{data=float64}
// @Failure 400 {object} domain.ErrorResponse
// @Router /api/v1/currencies/convert [get]
func (h *Handler) ConvertCurrency(c *fiber.Ctx) error {
from := domain.IntCurrency(c.Query("from"))
to := domain.IntCurrency(c.Query("to"))
amount := c.QueryFloat("amount", 0)
// if err != nil {
// return domain.BadRequestResponse(c)
// }
converted, err := h.currSvc.Convert(c.Context(), amount, from, to)
if err != nil {
return domain.UnExpectedErrorResponse(c)
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Success: true,
Message: "Currency converted successfully",
Data: converted,
StatusCode: fiber.StatusOK,
})
}

View File

@ -9,14 +9,15 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/chapa"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/company"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/currency"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/event"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/league"
notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication"
"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/result"
"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"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/user"
@ -29,6 +30,7 @@ import (
)
type Handler struct {
currSvc *currency.Service
logger *slog.Logger
notificationSvc *notificationservice.Service
userSvc *user.Service
@ -56,6 +58,7 @@ type Handler struct {
}
func New(
currSvc *currency.Service,
logger *slog.Logger,
notificationSvc *notificationservice.Service,
validator *customvalidator.CustomValidator,
@ -82,6 +85,7 @@ func New(
cfg *config.Config,
) *Handler {
return &Handler{
currSvc: currSvc,
logger: logger,
notificationSvc: notificationSvc,
reportSvc: reportSvc,

View File

@ -24,7 +24,7 @@ import (
// @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
// @Success 200 {object} domain.DashboardSummary
// @Failure 400 {object} domain.ErrorResponse
// @Failure 401 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse

View File

@ -9,7 +9,7 @@ import (
)
type ResultRes struct {
ResultData json.RawMessage `json:"result_data"`
ResultData json.RawMessage `json:"result_data" swaggerignore:"true"`
Outcomes []domain.BetOutcome `json:"outcomes"`
}

View File

@ -20,6 +20,7 @@ import (
func (a *App) initAppRoutes() {
h := handlers.New(
a.currSvc,
a.logger,
a.NotidicationStore,
a.validator,
@ -199,10 +200,15 @@ func (a *App) initAppRoutes() {
//Chapa Routes
group.Post("/chapa/payments/webhook/verify", h.WebhookCallback)
group.Get("/chapa/payments/manual/verify/:tx_ref", h.ManualVerifyPayment)
group.Get("/chapa/payments/manual/verify/:tx_ref", h.ManualVerifyTransaction)
group.Post("/chapa/payments/deposit", a.authMiddleware, h.InitiateDeposit)
group.Post("/chapa/payments/withdraw", a.authMiddleware, h.InitiateWithdrawal)
group.Get("/chapa/banks", h.GetSupportedBanks)
// Currencies
group.Get("/currencies", h.GetSupportedCurrencies)
group.Get("/currencies/convert", h.ConvertCurrency)
//Report Routes
group.Get("/reports/dashboard", h.GetDashboardReport)