Merge branch 'main' into ticket-bet

This commit is contained in:
Samuel Tariku 2025-08-08 14:26:27 +03:00
commit 696b713699
25 changed files with 1660 additions and 18 deletions

View File

@ -14,4 +14,4 @@ COPY .env .
COPY --from=builder /app/bin/web /app/bin/web COPY --from=builder /app/bin/web /app/bin/web
RUN apk add --no-cache ca-certificates RUN apk add --no-cache ca-certificates
EXPOSE ${PORT} EXPOSE ${PORT}
CMD ["/app/bin/web"] CMD ["/app/bin/web"]

View File

@ -49,7 +49,9 @@ import (
referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/report" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/report"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/result" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/result"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/santimpay"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/settings" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/settings"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/telebirr"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user"
@ -126,6 +128,7 @@ func main() {
walletSvc := wallet.NewService( walletSvc := wallet.NewService(
wallet.WalletStore(store), wallet.WalletStore(store),
wallet.TransferStore(store), wallet.TransferStore(store),
wallet.DirectDepositStore(store),
notificatioStore, notificatioStore,
notificationSvc, notificationSvc,
userSvc, userSvc,
@ -227,13 +230,21 @@ func main() {
issueReportingSvc := issuereporting.New(issueReportingRepo) issueReportingSvc := issuereporting.New(issueReportingRepo)
transferStore := wallet.TransferStore(store) transferStore := wallet.TransferStore(store)
// walletStore := wallet.WalletStore(store)
arifpaySvc := arifpay.NewArifpayService(cfg, transferStore, &http.Client{ arifpaySvc := arifpay.NewArifpayService(cfg, transferStore, &http.Client{
Timeout: 30 * time.Second}) Timeout: 30 * time.Second})
santimpayClient := santimpay.NewSantimPayClient(cfg)
santimpaySvc := santimpay.NewSantimPayService(santimpayClient, cfg, transferStore)
telebirrSvc := telebirr.NewTelebirrService(cfg, transferStore, walletSvc)
// Initialize and start HTTP server // Initialize and start HTTP server
app := httpserver.NewApp( app := httpserver.NewApp(
telebirrSvc,
arifpaySvc, arifpaySvc,
santimpaySvc,
issueReportingSvc, issueReportingSvc,
instSvc, instSvc,
currSvc, currSvc,

View File

@ -1,3 +1,21 @@
CREATE TABLE direct_deposits (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES users(id),
wallet_id BIGINT NOT NULL REFERENCES wallets(id),
amount NUMERIC(15, 2) NOT NULL,
bank_reference TEXT NOT NULL,
sender_account TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'rejected')),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
verified_by BIGINT REFERENCES users(id),
verification_notes TEXT,
verified_at TIMESTAMP
);
CREATE INDEX idx_direct_deposits_status ON direct_deposits(status);
CREATE INDEX idx_direct_deposits_customer ON direct_deposits(customer_id);
CREATE INDEX idx_direct_deposits_reference ON direct_deposits(bank_reference);
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
first_name VARCHAR(255) NOT NULL, first_name VARCHAR(255) NOT NULL,

View File

@ -1,11 +1,20 @@
-- Settings Initial Data -- Settings Initial Data
INSERT INTO settings (key, value) INSERT INTO settings (key, value)
VALUES ('sms_provider', '30'), VALUES ('sms_provider', '30'),
<<<<<<< HEAD
('max_number_of_outcomes', '30'),
('bet_amount_limit', '100000'),
=======
('max_number_of_outcomes', '30'), ('max_number_of_outcomes', '30'),
('bet_amount_limit', '10000000'), ('bet_amount_limit', '10000000'),
>>>>>>> 7d8d824a94381bd82c40398654c3bd78218c5950
('daily_ticket_limit', '50'), ('daily_ticket_limit', '50'),
('total_winnings_limit', '1000000'), ('total_winnings_limit', '1000000'),
('amount_for_bet_referral', '1000000'), ('amount_for_bet_referral', '1000000'),
('cashback_amount_cap', '1000') ON CONFLICT (key) DO ('cashback_amount_cap', '1000') ON CONFLICT (key) DO
UPDATE UPDATE
SET value = EXCLUDED.value; <<<<<<< HEAD
SET value = EXCLUDED.value;
=======
SET value = EXCLUDED.value;
>>>>>>> 7d8d824a94381bd82c40398654c3bd78218c5950

View File

@ -0,0 +1,30 @@
-- name: CreateDirectDeposit :one
INSERT INTO direct_deposits (
customer_id,
wallet_id,
amount,
bank_reference,
sender_account,
status
) VALUES (
$1, $2, $3, $4, $5, $6
) RETURNING *;
-- name: GetDirectDeposit :one
SELECT * FROM direct_deposits WHERE id = $1;
-- name: UpdateDirectDeposit :one
UPDATE direct_deposits
SET
status = $2,
verified_by = $3,
verification_notes = $4,
verified_at = $5
WHERE id = $1
RETURNING *;
-- name: GetDirectDepositsByStatus :many
SELECT * FROM direct_deposits WHERE status = $1 ORDER BY created_at DESC;
-- name: GetCustomerDirectDeposits :many
SELECT * FROM direct_deposits WHERE customer_id = $1 ORDER BY created_at DESC;

View File

@ -0,0 +1,199 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: direct_deposit.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateDirectDeposit = `-- name: CreateDirectDeposit :one
INSERT INTO direct_deposits (
customer_id,
wallet_id,
amount,
bank_reference,
sender_account,
status
) VALUES (
$1, $2, $3, $4, $5, $6
) RETURNING id, customer_id, wallet_id, amount, bank_reference, sender_account, status, created_at, verified_by, verification_notes, verified_at
`
type CreateDirectDepositParams struct {
CustomerID int64 `json:"customer_id"`
WalletID int64 `json:"wallet_id"`
Amount pgtype.Numeric `json:"amount"`
BankReference string `json:"bank_reference"`
SenderAccount string `json:"sender_account"`
Status string `json:"status"`
}
func (q *Queries) CreateDirectDeposit(ctx context.Context, arg CreateDirectDepositParams) (DirectDeposit, error) {
row := q.db.QueryRow(ctx, CreateDirectDeposit,
arg.CustomerID,
arg.WalletID,
arg.Amount,
arg.BankReference,
arg.SenderAccount,
arg.Status,
)
var i DirectDeposit
err := row.Scan(
&i.ID,
&i.CustomerID,
&i.WalletID,
&i.Amount,
&i.BankReference,
&i.SenderAccount,
&i.Status,
&i.CreatedAt,
&i.VerifiedBy,
&i.VerificationNotes,
&i.VerifiedAt,
)
return i, err
}
const GetCustomerDirectDeposits = `-- name: GetCustomerDirectDeposits :many
SELECT id, customer_id, wallet_id, amount, bank_reference, sender_account, status, created_at, verified_by, verification_notes, verified_at FROM direct_deposits WHERE customer_id = $1 ORDER BY created_at DESC
`
func (q *Queries) GetCustomerDirectDeposits(ctx context.Context, customerID int64) ([]DirectDeposit, error) {
rows, err := q.db.Query(ctx, GetCustomerDirectDeposits, customerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DirectDeposit
for rows.Next() {
var i DirectDeposit
if err := rows.Scan(
&i.ID,
&i.CustomerID,
&i.WalletID,
&i.Amount,
&i.BankReference,
&i.SenderAccount,
&i.Status,
&i.CreatedAt,
&i.VerifiedBy,
&i.VerificationNotes,
&i.VerifiedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetDirectDeposit = `-- name: GetDirectDeposit :one
SELECT id, customer_id, wallet_id, amount, bank_reference, sender_account, status, created_at, verified_by, verification_notes, verified_at FROM direct_deposits WHERE id = $1
`
func (q *Queries) GetDirectDeposit(ctx context.Context, id int64) (DirectDeposit, error) {
row := q.db.QueryRow(ctx, GetDirectDeposit, id)
var i DirectDeposit
err := row.Scan(
&i.ID,
&i.CustomerID,
&i.WalletID,
&i.Amount,
&i.BankReference,
&i.SenderAccount,
&i.Status,
&i.CreatedAt,
&i.VerifiedBy,
&i.VerificationNotes,
&i.VerifiedAt,
)
return i, err
}
const GetDirectDepositsByStatus = `-- name: GetDirectDepositsByStatus :many
SELECT id, customer_id, wallet_id, amount, bank_reference, sender_account, status, created_at, verified_by, verification_notes, verified_at FROM direct_deposits WHERE status = $1 ORDER BY created_at DESC
`
func (q *Queries) GetDirectDepositsByStatus(ctx context.Context, status string) ([]DirectDeposit, error) {
rows, err := q.db.Query(ctx, GetDirectDepositsByStatus, status)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DirectDeposit
for rows.Next() {
var i DirectDeposit
if err := rows.Scan(
&i.ID,
&i.CustomerID,
&i.WalletID,
&i.Amount,
&i.BankReference,
&i.SenderAccount,
&i.Status,
&i.CreatedAt,
&i.VerifiedBy,
&i.VerificationNotes,
&i.VerifiedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateDirectDeposit = `-- name: UpdateDirectDeposit :one
UPDATE direct_deposits
SET
status = $2,
verified_by = $3,
verification_notes = $4,
verified_at = $5
WHERE id = $1
RETURNING id, customer_id, wallet_id, amount, bank_reference, sender_account, status, created_at, verified_by, verification_notes, verified_at
`
type UpdateDirectDepositParams struct {
ID int64 `json:"id"`
Status string `json:"status"`
VerifiedBy pgtype.Int8 `json:"verified_by"`
VerificationNotes pgtype.Text `json:"verification_notes"`
VerifiedAt pgtype.Timestamp `json:"verified_at"`
}
func (q *Queries) UpdateDirectDeposit(ctx context.Context, arg UpdateDirectDepositParams) (DirectDeposit, error) {
row := q.db.QueryRow(ctx, UpdateDirectDeposit,
arg.ID,
arg.Status,
arg.VerifiedBy,
arg.VerificationNotes,
arg.VerifiedAt,
)
var i DirectDeposit
err := row.Scan(
&i.ID,
&i.CustomerID,
&i.WalletID,
&i.Amount,
&i.BankReference,
&i.SenderAccount,
&i.Status,
&i.CreatedAt,
&i.VerifiedBy,
&i.VerificationNotes,
&i.VerifiedAt,
)
return i, err
}

View File

@ -234,6 +234,20 @@ type CustomerWalletDetail struct {
PhoneNumber pgtype.Text `json:"phone_number"` PhoneNumber pgtype.Text `json:"phone_number"`
} }
type DirectDeposit struct {
ID int64 `json:"id"`
CustomerID int64 `json:"customer_id"`
WalletID int64 `json:"wallet_id"`
Amount pgtype.Numeric `json:"amount"`
BankReference string `json:"bank_reference"`
SenderAccount string `json:"sender_account"`
Status string `json:"status"`
CreatedAt pgtype.Timestamp `json:"created_at"`
VerifiedBy pgtype.Int8 `json:"verified_by"`
VerificationNotes pgtype.Text `json:"verification_notes"`
VerifiedAt pgtype.Timestamp `json:"verified_at"`
}
type Event struct { type Event struct {
ID string `json:"id"` ID string `json:"id"`
SportID pgtype.Int4 `json:"sport_id"` SportID pgtype.Int4 `json:"sport_id"`

View File

@ -66,6 +66,20 @@ type ARIFPAYConfig struct {
SuccessUrl string `mapstructure:"VELI_BRAND_ID"` SuccessUrl string `mapstructure:"VELI_BRAND_ID"`
} }
type SANTIMPAYConfig struct {
SecretKey string `mapstructure:"secret_key"`
MerchantID string `mapstructure:"merchant_id"`
BaseURL string `mapstructure:"base_url"`
}
type TELEBIRRConfig struct {
TelebirrFabricAppID string `mapstructure:"fabric_app_id"`
TelebirrAppSecret string `mapstructure:"appSecret"`
TelebirrBaseURL string `mapstructure:"base_url"`
TelebirrMerchantCode string `mapstructure:"merchant_code"`
TelebirrCallbackURL string `mapstructure:"callback_url"`
}
type Config struct { type Config struct {
FIXER_API_KEY string FIXER_API_KEY string
FIXER_BASE_URL string FIXER_BASE_URL string
@ -93,9 +107,11 @@ type Config struct {
CHAPA_RETURN_URL string CHAPA_RETURN_URL string
Bet365Token string Bet365Token string
PopOK domain.PopOKConfig PopOK domain.PopOKConfig
AleaPlay AleaPlayConfig `mapstructure:"alea_play"` AleaPlay AleaPlayConfig `mapstructure:"alea_play"`
VeliGames VeliConfig `mapstructure:"veli_games"` VeliGames VeliConfig `mapstructure:"veli_games"`
ARIFPAY ARIFPAYConfig `mapstructure:"arifpay_config"` ARIFPAY ARIFPAYConfig `mapstructure:"arifpay_config"`
SANTIMPAY SANTIMPAYConfig `mapstructure:"santimpay_config"`
TELEBIRR TELEBIRRConfig `mapstructure:"telebirr_config"`
ResendApiKey string ResendApiKey string
ResendSenderEmail string ResendSenderEmail string
TwilioAccountSid string TwilioAccountSid string
@ -187,6 +203,13 @@ func (c *Config) loadEnv() error {
return ErrInvalidLevel return ErrInvalidLevel
} }
//Telebirr
c.TELEBIRR.TelebirrBaseURL = os.Getenv("TELEBIRR_BASE_URL")
c.TELEBIRR.TelebirrAppSecret = os.Getenv("TELEBIRR_APP_SECRET")
c.TELEBIRR.TelebirrAppSecret = os.Getenv("TELEBIRR_FABRIC_APP_ID")
c.TELEBIRR.TelebirrMerchantCode = os.Getenv("TELEBIRR_MERCHANT_CODE")
c.TELEBIRR.TelebirrCallbackURL = os.Getenv("TELEBIRR_CALLBACK_URL")
//Chapa //Chapa
c.CHAPA_SECRET_KEY = os.Getenv("CHAPA_SECRET_KEY") c.CHAPA_SECRET_KEY = os.Getenv("CHAPA_SECRET_KEY")
c.CHAPA_PUBLIC_KEY = os.Getenv("CHAPA_PUBLIC_KEY") c.CHAPA_PUBLIC_KEY = os.Getenv("CHAPA_PUBLIC_KEY")
@ -204,6 +227,10 @@ func (c *Config) loadEnv() error {
c.ARIFPAY.NotifyUrl = os.Getenv("ARIFPAY_NOTIFY_URL") c.ARIFPAY.NotifyUrl = os.Getenv("ARIFPAY_NOTIFY_URL")
c.ARIFPAY.SuccessUrl = os.Getenv("ARIFPAY_SUCCESS_URL") c.ARIFPAY.SuccessUrl = os.Getenv("ARIFPAY_SUCCESS_URL")
c.SANTIMPAY.SecretKey = os.Getenv("SANTIMPAY_SECRET_KEY")
c.SANTIMPAY.MerchantID = os.Getenv("SANTIMPAY_MERCHANT_ID")
c.SANTIMPAY.BaseURL = os.Getenv("SANTIMPAY_Base_URL")
//Alea Play //Alea Play
aleaEnabled := os.Getenv("ALEA_ENABLED") aleaEnabled := os.Getenv("ALEA_ENABLED")
if aleaEnabled == "" { if aleaEnabled == "" {

View File

@ -14,6 +14,8 @@ type NotificationDeliveryStatus string
type DeliveryChannel string type DeliveryChannel string
const ( const (
NotificationTypeDepositResult NotificationType = "deposit_result"
NotificationTypeDepositVerification NotificationType = "deposit_verification"
NotificationTypeCashOutSuccess NotificationType = "cash_out_success" NotificationTypeCashOutSuccess NotificationType = "cash_out_success"
NotificationTypeDepositSuccess NotificationType = "deposit_success" NotificationTypeDepositSuccess NotificationType = "deposit_success"
NotificationTypeWithdrawSuccess NotificationType = "withdraw_success" NotificationTypeWithdrawSuccess NotificationType = "withdraw_success"

View File

@ -0,0 +1,25 @@
package domain
type GeneratePaymentURLInput struct {
ID string
Amount int
Reason string
PhoneNumber string
// SuccessRedirectURL string
// FailureRedirectURL string
// CancelRedirectURL string
// NotifyURL string
}
type InitiatePaymentPayload struct {
ID string `json:"id"`
Amount int `json:"amount"`
Reason string `json:"paymentReason"`
MerchantID string `json:"merchantId"`
SignedToken string `json:"signedToken"`
SuccessRedirectURL string `json:"successRedirectUrl"`
FailureRedirectURL string `json:"failureRedirectUrl"`
NotifyURL string `json:"notifyUrl"`
CancelRedirectURL string `json:"cancelRedirectUrl"`
PhoneNumber string `json:"phoneNumber"`
}

View File

@ -0,0 +1,60 @@
package domain
type TelebirrFabricTokenResponse struct {
Token string `json:"token"`
EffectiveDate string `json:"effectiveDate"`
ExpirationDate string `json:"expirationDate"`
}
type TelebirrBizContent struct {
NotifyURL string `json:"notify_url"`
AppID string `json:"appid"`
MerchCode string `json:"merch_code"`
MerchOrderID string `json:"merch_order_id"`
TradeType string `json:"trade_type"`
Title string `json:"title"`
TotalAmount string `json:"total_amount"`
TransCurrency string `json:"trans_currency"`
TimeoutExpress string `json:"timeout_express"`
BusinessType string `json:"business_type"`
PayeeIdentifier string `json:"payee_identifier"`
PayeeIdentifierType string `json:"payee_identifier_type"`
PayeeType string `json:"payee_type"`
RedirectURL string `json:"redirect_url"`
CallbackInfo string `json:"callback_info"`
}
type TelebirrPreOrderRequestPayload struct {
Timestamp string `json:"timestamp"`
NonceStr string `json:"nonce_str"`
Method string `json:"method"`
Version string `json:"version"`
BizContent TelebirrBizContent `json:"biz_content"`
SignType string `json:"sign_type"`
Sign string `json:"sign"`
}
type TelebirrCheckoutParams struct {
AppID string `json:"appid"`
MerchCode string `json:"merch_code"`
NonceStr string `json:"nonce_str"`
PrepayID string `json:"prepay_id"`
Timestamp string `json:"timestamp"`
}
type TelebirrPaymentCallbackPayload struct {
NotifyURL string `json:"notify_url"` // Optional callback URL
AppID string `json:"appid"` // App ID provided by Telebirr
NotifyTime string `json:"notify_time"` // Notification timestamp (UTC, in seconds)
MerchCode string `json:"merch_code"` // Merchant short code
MerchOrderID string `json:"merch_order_id"` // Order ID from merchant system
PaymentOrderID string `json:"payment_order_id"` // Order ID from Telebirr system
TotalAmount string `json:"total_amount"` // Payment amount
TransID string `json:"trans_id"` // Transaction ID
TransCurrency string `json:"trans_currency"` // Currency type (e.g., ETB)
TradeStatus string `json:"trade_status"` // Payment status (e.g., Completed, Failure)
TransEndTime string `json:"trans_end_time"` // Transaction end time (UTC seconds)
CallbackInfo string `json:"callback_info"` // Optional merchant-defined callback data
Sign string `json:"sign"` // Signature of the payload
SignType string `json:"sign_type"` // Signature type, e.g., SHA256WithRSA
}

View File

@ -80,3 +80,58 @@ const (
BranchWalletType WalletType = "branch_wallet" BranchWalletType WalletType = "branch_wallet"
CompanyWalletType WalletType = "company_wallet" CompanyWalletType WalletType = "company_wallet"
) )
// domain/wallet.go
type DirectDepositStatus string
const (
DepositStatusPending DirectDepositStatus = "pending"
DepositStatusCompleted DirectDepositStatus = "completed"
DepositStatusRejected DirectDepositStatus = "rejected"
)
type DirectDeposit struct {
ID int64
CustomerID int64
WalletID int64
Wallet Wallet // Joined data
Amount Currency
BankReference string
SenderAccount string
Status DirectDepositStatus
CreatedAt time.Time
VerifiedBy *int64 // Nullable
VerificationNotes string
VerifiedAt *time.Time // Nullable
}
type CreateDirectDeposit struct {
CustomerID int64
WalletID int64
Amount Currency
BankReference string
SenderAccount string
Status DirectDepositStatus
}
type UpdateDirectDeposit struct {
ID int64
Status DirectDepositStatus
VerifiedBy int64
VerificationNotes string
VerifiedAt time.Time
}
type DirectDepositRequest struct {
CustomerID int64 `json:"customer_id" binding:"required"`
Amount Currency `json:"amount" binding:"required,gt=0"`
BankReference string `json:"bank_reference" binding:"required"`
SenderAccount string `json:"sender_account" binding:"required"`
}
type VerifyDirectDepositRequest struct {
DepositID int64 `json:"deposit_id" binding:"required"`
IsVerified bool `json:"is_verified" binding:"required"`
Notes string `json:"notes"`
}

View File

@ -0,0 +1,112 @@
package repository
import (
"context"
"math/big"
"time"
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/jackc/pgx/v5/pgtype"
)
func convertDBDirectDeposit(deposit dbgen.DirectDeposit) domain.DirectDeposit {
return domain.DirectDeposit{
ID: deposit.ID,
CustomerID: deposit.CustomerID,
WalletID: deposit.WalletID,
Amount: domain.Currency(deposit.Amount.Int.Int64()),
BankReference: deposit.BankReference,
SenderAccount: deposit.SenderAccount,
Status: domain.DirectDepositStatus(deposit.Status),
CreatedAt: deposit.CreatedAt.Time,
VerifiedBy: convertPgInt64ToPtr(deposit.VerifiedBy),
VerificationNotes: deposit.VerificationNotes.String,
VerifiedAt: convertPgTimeToPtr(deposit.VerifiedAt),
}
}
func convertCreateDirectDeposit(deposit domain.CreateDirectDeposit) dbgen.CreateDirectDepositParams {
return dbgen.CreateDirectDepositParams{
CustomerID: deposit.CustomerID,
WalletID: deposit.WalletID,
Amount: pgtype.Numeric{Int: big.NewInt(int64(deposit.Amount)), Valid: true},
BankReference: deposit.BankReference,
SenderAccount: deposit.SenderAccount,
Status: string(deposit.Status),
}
}
func convertUpdateDirectDeposit(deposit domain.UpdateDirectDeposit) dbgen.UpdateDirectDepositParams {
return dbgen.UpdateDirectDepositParams{
ID: deposit.ID,
Status: string(deposit.Status),
VerifiedBy: pgtype.Int8{Int64: deposit.VerifiedBy, Valid: true},
VerificationNotes: pgtype.Text{String: deposit.VerificationNotes, Valid: deposit.VerificationNotes != ""},
VerifiedAt: pgtype.Timestamp{Time: deposit.VerifiedAt, Valid: true},
}
}
func convertPgInt64ToPtr(i pgtype.Int8) *int64 {
if i.Valid {
return &i.Int64
}
return nil
}
func convertPgTimeToPtr(t pgtype.Timestamp) *time.Time {
if t.Valid {
return &t.Time
}
return nil
}
func (s *Store) CreateDirectDeposit(ctx context.Context, deposit domain.CreateDirectDeposit) (domain.DirectDeposit, error) {
newDeposit, err := s.queries.CreateDirectDeposit(ctx, convertCreateDirectDeposit(deposit))
if err != nil {
return domain.DirectDeposit{}, err
}
return convertDBDirectDeposit(newDeposit), nil
}
func (s *Store) GetDirectDeposit(ctx context.Context, id int64) (domain.DirectDeposit, error) {
deposit, err := s.queries.GetDirectDeposit(ctx, id)
if err != nil {
return domain.DirectDeposit{}, err
}
return convertDBDirectDeposit(deposit), nil
}
func (s *Store) UpdateDirectDeposit(ctx context.Context, deposit domain.UpdateDirectDeposit) (domain.DirectDeposit, error) {
updatedDeposit, err := s.queries.UpdateDirectDeposit(ctx, convertUpdateDirectDeposit(deposit))
if err != nil {
return domain.DirectDeposit{}, err
}
return convertDBDirectDeposit(updatedDeposit), nil
}
func (s *Store) GetDirectDepositsByStatus(ctx context.Context, status domain.DirectDepositStatus) ([]domain.DirectDeposit, error) {
deposits, err := s.queries.GetDirectDepositsByStatus(ctx, string(status))
if err != nil {
return nil, err
}
result := make([]domain.DirectDeposit, 0, len(deposits))
for _, deposit := range deposits {
result = append(result, convertDBDirectDeposit(deposit))
}
return result, nil
}
func (s *Store) GetCustomerDirectDeposits(ctx context.Context, customerID int64) ([]domain.DirectDeposit, error) {
deposits, err := s.queries.GetCustomerDirectDeposits(ctx, customerID)
if err != nil {
return nil, err
}
result := make([]domain.DirectDeposit, 0, len(deposits))
for _, deposit := range deposits {
result = append(result, convertDBDirectDeposit(deposit))
}
return result, nil
}

View File

@ -0,0 +1,53 @@
package santimpay
import (
"fmt"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
"github.com/golang-jwt/jwt/v5"
)
type SantimPayClient interface {
GenerateSignedToken(amount int, reason string) (string, error)
CheckTransactionStatus(id string)
}
type santimClient struct {
cfg *config.Config
}
func NewSantimPayClient(cfg *config.Config) SantimPayClient {
return &santimClient{
cfg: cfg,
}
}
func (c *santimClient) GenerateSignedToken(amount int, reason string) (string, error) {
now := time.Now().Unix()
claims := jwt.MapClaims{
"amount": amount,
"paymentReason": reason,
"merchantId": c.cfg.SANTIMPAY.MerchantID,
"generated": now,
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
privateKey, err := jwt.ParseECPrivateKeyFromPEM([]byte(c.cfg.SANTIMPAY.SecretKey))
if err != nil {
return "", fmt.Errorf("invalid private key: %w", err)
}
signedToken, err := token.SignedString(privateKey)
if err != nil {
return "", fmt.Errorf("signing failed: %w", err)
}
return signedToken, nil
}
func (c *santimClient) CheckTransactionStatus(id string) {
// optional async checker — can log or poll transaction status
fmt.Println("Checking transaction status for:", id)
}

View File

@ -0,0 +1,92 @@
package santimpay
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
"github.com/google/uuid"
)
// type SantimPayService interface {
// GeneratePaymentURL(input domain.GeneratePaymentURLInput) (map[string]string, error)
// }
type SantimPayService struct {
client SantimPayClient
cfg *config.Config
transferStore wallet.TransferStore
}
func NewSantimPayService(client SantimPayClient, cfg *config.Config, transferStore wallet.TransferStore) *SantimPayService {
return &SantimPayService{
client: client,
cfg: cfg,
transferStore: transferStore,
}
}
func (s *SantimPayService) GeneratePaymentURL(input domain.GeneratePaymentURLInput) (map[string]string, error) {
paymentID := uuid.NewString()
token, err := s.client.GenerateSignedToken(input.Amount, input.Reason)
if err != nil {
return nil, fmt.Errorf("token generation failed: %w", err)
}
payload := domain.InitiatePaymentPayload{
ID: paymentID,
Amount: input.Amount,
Reason: input.Reason,
MerchantID: s.cfg.SANTIMPAY.MerchantID,
SignedToken: token,
SuccessRedirectURL: s.cfg.ARIFPAY.SuccessUrl,
FailureRedirectURL: s.cfg.ARIFPAY.ErrorUrl,
NotifyURL: s.cfg.ARIFPAY.NotifyUrl,
CancelRedirectURL: s.cfg.ARIFPAY.CancelUrl,
PhoneNumber: input.PhoneNumber,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal payload: %w", err)
}
resp, err := http.Post(s.cfg.SANTIMPAY.BaseURL+"/initiate-payment", "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to send HTTP request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("non-200 status code received: %d", resp.StatusCode)
}
var responseBody map[string]string
if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
// Save transfer
transfer := domain.CreateTransfer{
Amount: domain.Currency(input.Amount),
Verified: false,
Type: domain.DEPOSIT,
ReferenceNumber: paymentID,
Status: string(domain.PaymentStatusPending),
}
if _, err := s.transferStore.CreateTransfer(context.Background(), transfer); err != nil {
return nil, fmt.Errorf("failed to create transfer: %w", err)
}
// Optionally check transaction status in a goroutine
go s.client.CheckTransactionStatus(paymentID)
return responseBody, nil
}

View File

@ -0,0 +1,406 @@
package telebirr
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
)
// TokenResponse is the expected response from Telebirr
type TelebirrService struct {
// client TelebirrClient
cfg *config.Config
transferStore wallet.TransferStore
walletSvc *wallet.Service
}
func NewTelebirrService(cfg *config.Config, transferStore wallet.TransferStore, walletSvc *wallet.Service) *TelebirrService {
return &TelebirrService{
cfg: cfg,
transferStore: transferStore,
walletSvc: walletSvc,
}
}
// GetFabricToken fetches the fabric token from Telebirr
func GetTelebirrFabricToken(s *TelebirrService) (*domain.TelebirrFabricTokenResponse, error) {
// Prepare the request body
bodyMap := map[string]string{
"appSecret": s.cfg.TELEBIRR.TelebirrAppSecret,
}
bodyBytes, err := json.Marshal(bodyMap)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %v", err)
}
// Prepare the HTTP request
req, err := http.NewRequest("POST", s.cfg.TELEBIRR.TelebirrBaseURL+"/payment/v1/token", bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, fmt.Errorf("failed to create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-APP-Key", s.cfg.TELEBIRR.TelebirrFabricAppID)
// Perform the request
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to perform request: %v", err)
}
defer resp.Body.Close()
// Read and parse the response
respBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %v", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("non-200 response: %d, body: %s", resp.StatusCode, string(respBody))
}
var tokenResp domain.TelebirrFabricTokenResponse
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
}
return &tokenResp, nil
}
func (s *TelebirrService) CreateTelebirrOrder(title string, amount float32, userID int64) (string, error) {
// Step 1: Get Fabric Token
tokenResp, err := GetTelebirrFabricToken(s)
if err != nil {
return "", fmt.Errorf("failed to get token: %v", err)
}
fabricToken := tokenResp.Token
// Step 2: Create request object
orderID := fmt.Sprintf("%d", time.Now().UnixNano())
bizContent := domain.TelebirrBizContent{
NotifyURL: s.cfg.TELEBIRR.TelebirrCallbackURL, // Replace with actual
AppID: s.cfg.TELEBIRR.TelebirrFabricAppID,
MerchCode: s.cfg.TELEBIRR.TelebirrMerchantCode,
MerchOrderID: orderID,
TradeType: "Checkout",
Title: title,
TotalAmount: fmt.Sprintf("%.2f", amount),
TransCurrency: "ETB",
TimeoutExpress: "120m",
BusinessType: "WalletRefill",
PayeeIdentifier: s.cfg.TELEBIRR.TelebirrMerchantCode,
PayeeIdentifierType: "04",
PayeeType: "5000",
RedirectURL: s.cfg.ARIFPAY.SuccessUrl, // Replace with actual
CallbackInfo: "From web",
}
requestPayload := domain.TelebirrPreOrderRequestPayload{
Timestamp: fmt.Sprintf("%d", time.Now().Unix()),
NonceStr: generateNonce(),
Method: "payment.preorder",
Version: "1.0",
BizContent: bizContent,
SignType: "SHA256WithRSA",
}
// Sign the request
signStr := canonicalSignString(preOrderPayloadToMap(requestPayload))
signature, err := signSHA256WithRSA(signStr, s.cfg.TELEBIRR.TelebirrAppSecret)
if err != nil {
return "", fmt.Errorf("failed to sign request: %v", err)
}
requestPayload.Sign = signature
// Marshal to JSON
bodyBytes, _ := json.Marshal(requestPayload)
// Step 3: Make the request
req, _ := http.NewRequest("POST", s.cfg.TELEBIRR.TelebirrBaseURL+"/payment/v1/merchant/preOrder", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-APP-Key", s.cfg.TELEBIRR.TelebirrFabricAppID)
req.Header.Set("Authorization", fabricToken)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("telebirr preOrder request failed: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("telebirr preOrder failed: %s", string(body))
}
var response map[string]interface{}
if err := json.Unmarshal(body, &response); err != nil {
return "", fmt.Errorf("telebirr response parse error: %v", err)
}
biz := response["biz_content"].(map[string]interface{})
prepayID := biz["prepay_id"].(string)
// Step 4: Build checkout URL
checkoutURL, err := s.BuildTelebirrCheckoutURL(prepayID)
if err != nil {
return "", err
}
SenderWallets, err := s.walletSvc.GetWalletsByUser(req.Context(), userID)
if err != nil {
return "", fmt.Errorf("failed to get user wallets: %v", err)
}
s.transferStore.CreateTransfer(req.Context(), domain.CreateTransfer{
Amount: domain.Currency(amount),
Verified: false,
Type: domain.DEPOSIT,
ReferenceNumber: orderID,
Status: string(domain.PaymentStatusPending),
SenderWalletID: domain.ValidInt64{
Value: SenderWallets[0].ID,
Valid: true,
},
Message: fmt.Sprintf("Telebirr order created with ID: %s and amount: %f", orderID, amount),
})
return checkoutURL, nil
}
func (s *TelebirrService) BuildTelebirrCheckoutURL(prepayID string) (string, error) {
// Convert params struct to map[string]string for signing
params := map[string]string{
"app_id": s.cfg.TELEBIRR.TelebirrFabricAppID,
"merch_code": s.cfg.TELEBIRR.TelebirrMerchantCode,
"nonce_str": generateNonce(),
"prepay_id": prepayID,
"timestamp": fmt.Sprintf("%d", time.Now().Unix()),
}
signStr := canonicalSignString(params)
signature, err := signSHA256WithRSA(signStr, s.cfg.TELEBIRR.TelebirrAppSecret)
if err != nil {
return "", fmt.Errorf("failed to sign checkout URL: %v", err)
}
query := url.Values{}
for k, v := range params {
query.Set(k, v)
}
query.Set("sign", signature)
query.Set("sign_type", "SHA256WithRSA")
query.Set("version", "1.0")
query.Set("trade_type", "Checkout")
// Step 4: Build final URL
return s.cfg.TELEBIRR.TelebirrBaseURL + query.Encode(), nil
}
func (s *TelebirrService) HandleTelebirrPaymentCallback(ctx context.Context, payload *domain.TelebirrPaymentCallbackPayload) error {
transfer, err := s.transferStore.GetTransferByReference(ctx, payload.PaymentOrderID)
if err != nil {
return fmt.Errorf("failed to fetch transfer by reference: %w", err)
}
if transfer.Status != string(domain.PaymentStatusPending) {
return fmt.Errorf("payment not pending, status: %s", transfer.Status)
} else if transfer.Verified == true {
return fmt.Errorf("payment already verified")
}
if payload.TradeStatus != "Completed" {
return fmt.Errorf("payment not completed, status: %s", payload.TradeStatus)
}
// 1. Validate the signature
// if err := s.VerifyCallbackSignature(payload); err != nil {
// return fmt.Errorf("invalid callback signature: %w", err)
// }
if err := s.transferStore.UpdateTransferStatus(ctx, transfer.ID, string(domain.PaymentStatusCompleted)); err != nil {
return fmt.Errorf("failed to update transfer status: %w", err)
}
// 4. Parse amount
amount, err := strconv.ParseFloat(payload.TotalAmount, 64)
if err != nil {
return fmt.Errorf("invalid amount format: %s", payload.TotalAmount)
}
_, err = s.walletSvc.AddToWallet(ctx, transfer.SenderWalletID.Value, domain.Currency(amount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, fmt.Sprintf("Added %v to wallet for Telebirr payment", amount))
if err != nil {
return fmt.Errorf("failed to add amount to wallet: %w", err)
}
if err := s.transferStore.UpdateTransferVerification(ctx, transfer.ID, true); err != nil {
return fmt.Errorf("failed to update transfer verification: %w", err)
}
return nil
}
// Verifies the RSA-SHA256 signature of the payload
// func (s *TelebirrService) VerifyCallbackSignature(payload *domain.TelebirrPaymentCallbackPayload) error {
// // 1. Extract the signature from the payload
// signatureBase64 := payload.Sign
// signType := payload.SignType
// if signType != "SHA256WithRSA" {
// return fmt.Errorf("unsupported sign_type: %s", signType)
// }
// // 2. Convert the payload to map (excluding 'sign' and 'sign_type')
// payloadMap := map[string]string{
// "notify_url": payload.NotifyURL,
// "appid": payload.AppID,
// "notify_time": payload.NotifyTime,
// "merch_code": payload.MerchCode,
// "merch_order_id": payload.MerchOrderID,
// "payment_order_id": payload.PaymentOrderID,
// "total_amount": payload.TotalAmount,
// "trans_id": payload.TransID,
// "trans_currency": payload.TransCurrency,
// "trade_status": payload.TradeStatus,
// "trans_end_time": payload.TransEndTime,
// }
// // 3. Sort the keys and build the canonical string
// var keys []string
// for k := range payloadMap {
// keys = append(keys, k)
// }
// sort.Strings(keys)
// var canonicalParts []string
// for _, k := range keys {
// canonicalParts = append(canonicalParts, fmt.Sprintf("%s=%s", k, payloadMap[k]))
// }
// canonicalString := strings.Join(canonicalParts, "&")
// // 4. Hash the canonical string
// hashed := sha256.Sum256([]byte(canonicalString))
// // 5. Decode the base64 signature
// signature, err := base64.StdEncoding.DecodeString(signatureBase64)
// if err != nil {
// return fmt.Errorf("failed to decode signature: %w", err)
// }
// // 6. Load the RSA public key (PEM format)
// pubKeyPEM := []byte(s.cfg.TELEBIRR.PublicKey) // Must be full PEM string
// block, _ := pem.Decode(pubKeyPEM)
// if block == nil || block.Type != "PUBLIC KEY" {
// return errors.New("invalid public key PEM block")
// }
// pubKeyInterface, err := x509.ParsePKIXPublicKey(block.Bytes)
// if err != nil {
// return fmt.Errorf("failed to parse RSA public key: %w", err)
// }
// rsaPubKey, ok := pubKeyInterface.(*rsa.PublicKey)
// if !ok {
// return errors.New("not a valid RSA public key")
// }
// // 7. Verify the signature
// err = rsa.VerifyPKCS1v15(rsaPubKey, crypto.SHA256, hashed[:], signature)
// if err != nil {
// return fmt.Errorf("RSA signature verification failed: %w", err)
// }
// return nil
// }
func generateNonce() string {
return fmt.Sprintf("telebirr%x", time.Now().UnixNano())
}
func canonicalSignString(data map[string]string) string {
keys := make([]string, 0, len(data))
for k := range data {
if k != "sign" && k != "sign_type" {
keys = append(keys, k)
}
}
sort.Strings(keys)
var b strings.Builder
for i, k := range keys {
value := data[k]
var valStr string
if k == "biz_content" {
jsonVal, _ := json.Marshal(value)
valStr = string(jsonVal)
} else {
valStr = fmt.Sprintf("%v", value)
}
b.WriteString(fmt.Sprintf("%s=%s", k, valStr))
if i < len(keys)-1 {
b.WriteString("&")
}
}
return b.String()
}
func signSHA256WithRSA(signStr, privateKeyPEM string) (string, error) {
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return "", fmt.Errorf("invalid PEM private key")
}
priv, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("unable to parse private key: %v", err)
}
hashed := sha256.Sum256([]byte(signStr))
sig, err := rsa.SignPKCS1v15(rand.Reader, priv.(*rsa.PrivateKey), crypto.SHA256, hashed[:])
if err != nil {
return "", fmt.Errorf("signing failed: %v", err)
}
return base64.StdEncoding.EncodeToString(sig), nil
}
// Helper function to convert TelebirrPreOrderRequestPayload to map[string]string for signing
func preOrderPayloadToMap(payload domain.TelebirrPreOrderRequestPayload) map[string]string {
m := map[string]string{
"timestamp": payload.Timestamp,
"nonce_str": payload.NonceStr,
"method": payload.Method,
"version": payload.Version,
"sign_type": payload.SignType,
}
// BizContent needs to be marshaled as JSON string
bizContentBytes, _ := json.Marshal(payload.BizContent)
m["biz_content"] = string(bizContentBytes)
return m
}

View File

@ -0,0 +1,217 @@
package wallet
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/event"
)
// InitiateDirectDeposit creates a pending deposit request
func (s *Service) InitiateDirectDeposit(
ctx context.Context,
customerID int64,
amount domain.Currency,
bankRef string, // Mobile banking transaction reference
senderAccount string, // Customer's account number
) (domain.DirectDeposit, error) {
// Get customer's betting wallet
customerWallet, err := s.GetCustomerWallet(ctx, customerID)
if err != nil {
return domain.DirectDeposit{}, fmt.Errorf("failed to get customer wallet: %w", err)
}
// Create pending deposit record
deposit, err := s.directDepositStore.CreateDirectDeposit(ctx, domain.CreateDirectDeposit{
CustomerID: customerID,
WalletID: customerWallet.ID,
Amount: amount,
BankReference: bankRef,
SenderAccount: senderAccount,
Status: domain.DepositStatusPending,
})
if err != nil {
return domain.DirectDeposit{}, fmt.Errorf("failed to create deposit record: %w", err)
}
// Notify cashiers for manual verification
go s.notifyCashiersForVerification(ctx, deposit.ID, customerID, amount)
return deposit, nil
}
// VerifyDirectDeposit verifies and processes the deposit
func (s *Service) VerifyDirectDeposit(
ctx context.Context,
depositID int64,
cashierID int64,
isVerified bool,
verificationNotes string,
) (domain.DirectDeposit, error) {
// Get the deposit record
deposit, err := s.directDepositStore.GetDirectDeposit(ctx, depositID)
if err != nil {
return domain.DirectDeposit{}, fmt.Errorf("failed to get deposit: %w", err)
}
// Validate deposit status
if deposit.Status != domain.DepositStatusPending {
return domain.DirectDeposit{}, errors.New("only pending deposits can be verified")
}
// Update based on verification result
if isVerified {
// Credit the wallet
err = s.walletStore.UpdateBalance(ctx, deposit.WalletID,
deposit.Wallet.Balance+deposit.Amount)
if err != nil {
return domain.DirectDeposit{}, fmt.Errorf("failed to update wallet balance: %w", err)
}
// Publish wallet update event
go s.publishWalletUpdate(ctx, deposit.WalletID, deposit.Wallet.UserID,
deposit.Wallet.Balance+deposit.Amount, "direct_deposit_verified")
// Update deposit status
deposit.Status = domain.DepositStatusCompleted
} else {
deposit.Status = domain.DepositStatusRejected
}
// Update deposit record
updatedDeposit, err := s.directDepositStore.UpdateDirectDeposit(ctx, domain.UpdateDirectDeposit{
ID: depositID,
Status: deposit.Status,
VerifiedBy: cashierID,
VerificationNotes: verificationNotes,
VerifiedAt: time.Now(),
})
if err != nil {
return domain.DirectDeposit{}, fmt.Errorf("failed to update deposit: %w", err)
}
// Notify customer of verification result
go s.notifyCustomerVerificationResult(ctx, updatedDeposit)
return updatedDeposit, nil
}
// GetPendingDirectDeposits returns deposits needing verification
func (s *Service) GetPendingDirectDeposits(ctx context.Context) ([]domain.DirectDeposit, error) {
return s.directDepositStore.GetDirectDepositsByStatus(ctx, domain.DepositStatusPending)
}
// Helper functions
func (s *Service) notifyCashiersForVerification(ctx context.Context, depositID, customerID int64, amount domain.Currency) {
cashiers, _, err := s.userSvc.GetAllCashiers(ctx, domain.UserFilter{Role: string(domain.RoleCashier)})
if err != nil {
s.logger.Error("failed to get cashiers for notification",
"error", err,
"deposit_id", depositID)
return
}
customer, err := s.userSvc.GetUserByID(ctx, customerID)
if err != nil {
s.logger.Error("failed to get customer details",
"error", err,
"customer_id", customerID)
return
}
for _, cashier := range cashiers {
metadataMap := map[string]interface{}{
"deposit_id": depositID,
"customer_id": customerID,
"amount": amount.Float32(),
}
metadataJSON, err := json.Marshal(metadataMap)
if err != nil {
s.logger.Error("failed to marshal notification metadata",
"error", err,
"deposit_id", depositID)
continue
}
notification := &domain.Notification{
RecipientID: cashier.ID,
Type: domain.NotificationTypeDepositVerification,
Level: domain.NotificationLevelInfo,
DeliveryChannel: domain.DeliveryChannelInApp,
Payload: domain.NotificationPayload{
Headline: "Direct Deposit Requires Verification",
Message: fmt.Sprintf("Customer %s deposited %.2f - please verify", customer.FirstName+" "+customer.LastName, amount.Float32()),
},
Metadata: metadataJSON,
}
if err := s.notificationStore.SendNotification(ctx, notification); err != nil {
s.logger.Error("failed to send verification notification",
"cashier_id", cashier.ID,
"error", err)
}
}
}
func (s *Service) notifyCustomerVerificationResult(ctx context.Context, deposit domain.DirectDeposit) {
var (
headline string
message string
level domain.NotificationLevel
)
if deposit.Status == domain.DepositStatusCompleted {
headline = "Deposit Verified"
message = fmt.Sprintf("Your deposit of %.2f has been credited to your wallet", deposit.Amount.Float32())
level = domain.NotificationLevelSuccess
} else {
headline = "Deposit Rejected"
message = fmt.Sprintf("Your deposit of %.2f was not verified. Reason: %s",
deposit.Amount.Float32(), deposit.VerificationNotes)
level = domain.NotificationLevelError
}
metadataMap := map[string]interface{}{
"deposit_id": deposit.ID,
"amount": deposit.Amount.Float32(),
"status": string(deposit.Status),
}
metadataJSON, err := json.Marshal(metadataMap)
if err != nil {
s.logger.Error("failed to marshal notification metadata",
"error", err,
"deposit_id", deposit.ID)
return
}
notification := &domain.Notification{
RecipientID: deposit.CustomerID,
Type: domain.NotificationTypeDepositResult,
Level: level,
DeliveryChannel: domain.DeliveryChannelInApp,
Payload: domain.NotificationPayload{
Headline: headline,
Message: message,
},
Metadata: metadataJSON,
}
if err := s.notificationStore.SendNotification(ctx, notification); err != nil {
s.logger.Error("failed to send deposit result notification",
"customer_id", deposit.CustomerID,
"error", err)
}
}
func (s *Service) publishWalletUpdate(ctx context.Context, walletID, userID int64, newBalance domain.Currency, trigger string) {
s.kafkaProducer.Publish(ctx, fmt.Sprint(walletID), event.WalletEvent{
EventType: event.WalletBalanceUpdated,
WalletID: walletID,
UserID: userID,
Balance: newBalance,
Trigger: trigger,
})
}

View File

@ -45,3 +45,11 @@ type ApprovalStore interface {
GetApprovalsByTransfer(ctx context.Context, transferID int64) ([]domain.TransactionApproval, error) GetApprovalsByTransfer(ctx context.Context, transferID int64) ([]domain.TransactionApproval, error)
GetPendingApprovals(ctx context.Context) ([]domain.TransferDetail, error) GetPendingApprovals(ctx context.Context) ([]domain.TransferDetail, error)
} }
type DirectDepositStore interface {
CreateDirectDeposit(ctx context.Context, deposit domain.CreateDirectDeposit) (domain.DirectDeposit, error)
GetDirectDeposit(ctx context.Context, id int64) (domain.DirectDeposit, error)
UpdateDirectDeposit(ctx context.Context, deposit domain.UpdateDirectDeposit) (domain.DirectDeposit, error)
GetDirectDepositsByStatus(ctx context.Context, status domain.DirectDepositStatus) ([]domain.DirectDeposit, error)
GetCustomerDirectDeposits(ctx context.Context, customerID int64) ([]domain.DirectDeposit, error)
}

View File

@ -11,19 +11,21 @@ import (
type Service struct { type Service struct {
// approvalStore ApprovalStore // approvalStore ApprovalStore
walletStore WalletStore walletStore WalletStore
transferStore TransferStore transferStore TransferStore
notificationStore notificationservice.NotificationStore directDepositStore DirectDepositStore
notificationSvc *notificationservice.Service notificationStore notificationservice.NotificationStore
userSvc *user.Service notificationSvc *notificationservice.Service
mongoLogger *zap.Logger userSvc *user.Service
logger *slog.Logger mongoLogger *zap.Logger
kafkaProducer *kafka.Producer logger *slog.Logger
kafkaProducer *kafka.Producer
} }
func NewService( func NewService(
walletStore WalletStore, walletStore WalletStore,
transferStore TransferStore, transferStore TransferStore,
directDepositStore DirectDepositStore,
notificationStore notificationservice.NotificationStore, notificationStore notificationservice.NotificationStore,
notificationSvc *notificationservice.Service, notificationSvc *notificationservice.Service,
userSvc *user.Service, userSvc *user.Service,
@ -32,14 +34,15 @@ func NewService(
kafkaProducer *kafka.Producer, kafkaProducer *kafka.Producer,
) *Service { ) *Service {
return &Service{ return &Service{
walletStore: walletStore, walletStore: walletStore,
transferStore: transferStore, transferStore: transferStore,
directDepositStore: directDepositStore,
// approvalStore: approvalStore, // approvalStore: approvalStore,
notificationStore: notificationStore, notificationStore: notificationStore,
notificationSvc: notificationSvc, notificationSvc: notificationSvc,
userSvc: userSvc, userSvc: userSvc,
mongoLogger: mongoLogger, mongoLogger: mongoLogger,
logger: logger, logger: logger,
kafkaProducer: kafkaProducer, kafkaProducer: kafkaProducer,
} }
} }

View File

@ -22,7 +22,9 @@ import (
referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/report" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/report"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/result" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/result"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/santimpay"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/settings" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/settings"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/telebirr"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user"
@ -41,7 +43,9 @@ import (
) )
type App struct { type App struct {
telebirrSvc *telebirr.TelebirrService
arifpaySvc *arifpay.ArifpayService arifpaySvc *arifpay.ArifpayService
santimpaySvc *santimpay.SantimPayService
issueReportingSvc *issuereporting.Service issueReportingSvc *issuereporting.Service
instSvc *institutions.Service instSvc *institutions.Service
currSvc *currency.Service currSvc *currency.Service
@ -78,7 +82,9 @@ type App struct {
} }
func NewApp( func NewApp(
telebirrSvc *telebirr.TelebirrService,
arifpaySvc *arifpay.ArifpayService, arifpaySvc *arifpay.ArifpayService,
santimpaySvc *santimpay.SantimPayService,
issueReportingSvc *issuereporting.Service, issueReportingSvc *issuereporting.Service,
instSvc *institutions.Service, instSvc *institutions.Service,
currSvc *currency.Service, currSvc *currency.Service,
@ -125,7 +131,9 @@ func NewApp(
})) }))
s := &App{ s := &App{
telebirrSvc: telebirrSvc,
arifpaySvc: arifpaySvc, arifpaySvc: arifpaySvc,
santimpaySvc: santimpaySvc,
issueReportingSvc: issueReportingSvc, issueReportingSvc: issueReportingSvc,
instSvc: instSvc, instSvc: instSvc,
currSvc: currSvc, currSvc: currSvc,

View File

@ -0,0 +1,124 @@
package handlers
import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/gofiber/fiber/v2"
)
// InitiateDirectDeposit godoc
// @Summary Initiate a direct deposit
// @Description Customer initiates a direct deposit from mobile banking
// @Tags Direct Deposits
// @Accept json
// @Produce json
// @Param request body domain.DirectDepositRequest true "Deposit details"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/direct_deposit [post]
func (h *Handler) InitiateDirectDeposit(c *fiber.Ctx) error {
var req domain.DirectDepositRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Invalid request payload",
})
}
deposit, err := h.walletSvc.InitiateDirectDeposit(
c.Context(),
req.CustomerID,
req.Amount,
req.BankReference,
req.SenderAccount,
)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Failed to initiate direct deposit",
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Direct deposit initiated successfully",
Data: deposit,
Success: true,
StatusCode: fiber.StatusCreated,
})
}
// VerifyDirectDeposit godoc
// @Summary Verify a direct deposit
// @Description Cashier verifies a direct deposit transaction
// @Tags Direct Deposits
// @Accept json
// @Produce json
// @Param request body domain.VerifyDepositRequest true "Verification details"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 401 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/direct_deposit/verify [post]
func (h *Handler) VerifyDirectDeposit(c *fiber.Ctx) error {
var req domain.VerifyDirectDepositRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Invalid verification request",
})
}
cashierID := c.Locals("user_id")
if cashierID == nil {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Error: "missing user_id in context",
Message: "Unauthorized access",
})
}
deposit, err := h.walletSvc.VerifyDirectDeposit(
c.Context(),
req.DepositID,
cashierID.(int64),
req.IsVerified,
req.Notes,
)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Failed to verify deposit",
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Deposit verification processed successfully",
Data: deposit,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetPendingDeposits godoc
// @Summary Get pending direct deposits
// @Description Get list of direct deposits needing verification
// @Tags Direct Deposits
// @Produce json
// @Success 200 {object} domain.Response
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/direct_deposit/pending [get]
func (h *Handler) GetPendingDirectDeposits(c *fiber.Ctx) error {
deposits, err := h.walletSvc.GetPendingDirectDeposits(c.Context())
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Failed to retrieve pending deposits",
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Pending deposits retrieved successfully",
Data: deposits,
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -22,7 +22,9 @@ import (
referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/report" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/report"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/result" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/result"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/santimpay"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/settings" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/settings"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/telebirr"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user"
@ -36,7 +38,9 @@ import (
) )
type Handler struct { type Handler struct {
telebirrSvc *telebirr.TelebirrService
arifpaySvc *arifpay.ArifpayService arifpaySvc *arifpay.ArifpayService
santimpaySvc *santimpay.SantimPayService
issueReportingSvc *issuereporting.Service issueReportingSvc *issuereporting.Service
instSvc *institutions.Service instSvc *institutions.Service
currSvc *currency.Service currSvc *currency.Service
@ -70,7 +74,9 @@ type Handler struct {
} }
func New( func New(
telebirrSvc *telebirr.TelebirrService,
arifpaySvc *arifpay.ArifpayService, arifpaySvc *arifpay.ArifpayService,
santimpaySvc *santimpay.SantimPayService,
issueReportingSvc *issuereporting.Service, issueReportingSvc *issuereporting.Service,
instSvc *institutions.Service, instSvc *institutions.Service,
currSvc *currency.Service, currSvc *currency.Service,
@ -103,7 +109,9 @@ func New(
mongoLoggerSvc *zap.Logger, mongoLoggerSvc *zap.Logger,
) *Handler { ) *Handler {
return &Handler{ return &Handler{
telebirrSvc: telebirrSvc,
arifpaySvc: arifpaySvc, arifpaySvc: arifpaySvc,
santimpaySvc: santimpaySvc,
issueReportingSvc: issueReportingSvc, issueReportingSvc: issueReportingSvc,
instSvc: instSvc, instSvc: instSvc,
currSvc: currSvc, currSvc: currSvc,

View File

@ -0,0 +1,43 @@
package handlers
import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/gofiber/fiber/v2"
)
// CreateSantimPayPaymentHandler initializes a payment session with SantimPay.
//
// @Summary Create SantimPay Payment Session
// @Description Generates a payment URL using SantimPay and returns it to the client.
// @Tags SantimPay
// @Accept json
// @Produce json
// @Param request body domain.GeneratePaymentURLInput true "SantimPay payment request payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/santimpay/payment [post]
func (h *Handler) CreateSantimPayPaymentHandler(c *fiber.Ctx) error {
var req domain.GeneratePaymentURLInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Failed to process your request",
})
}
paymentURL, err := h.santimpaySvc.GeneratePaymentURL(req)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Failed to initiate SantimPay payment session",
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "SantimPay payment URL generated successfully",
Data: paymentURL,
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -0,0 +1,98 @@
package handlers
import (
"strconv"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/gofiber/fiber/v2"
)
// CreateTelebirrPaymentHandler initializes a payment session with Telebirr.
//
// @Summary Create Telebirr Payment Session
// @Description Generates a payment URL using Telebirr and returns it to the client.
// @Tags Telebirr
// @Accept json
// @Produce json
// @Param request body domain.GeneratePaymentURLInput true "Telebirr payment request payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/telebirr/payment [post]
func (h *Handler) CreateTelebirrPaymentHandler(c *fiber.Ctx) error {
var req domain.TelebirrPreOrderRequestPayload
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Invalid request payload",
})
}
totalAmount, err := strconv.ParseFloat(req.BizContent.TotalAmount, 32)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "TotalAmount must be a valid number",
})
}
userID, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: "Invalid user_id type",
Message: "user_id must be an int64",
})
}
paymentURL, err := h.telebirrSvc.CreateTelebirrOrder(req.BizContent.Title, float32(totalAmount), userID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Failed to create Telebirr payment session",
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Telebirr payment URL generated successfully",
Data: paymentURL,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// HandleTelebirrCallbackHandler handles the Telebirr payment callback.
//
// @Summary Handle Telebirr Payment Callback
// @Description Processes the Telebirr payment result and updates wallet balance.
// @Tags Telebirr
// @Accept json
// @Produce json
// @Param payload body domain.TelebirrPaymentCallbackPayload true "Callback payload from Telebirr"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/telebirr/callback [post]
func (h *Handler) HandleTelebirrCallback(c *fiber.Ctx) error {
var payload domain.TelebirrPaymentCallbackPayload
if err := c.BodyParser(&payload); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Invalid callback payload",
})
}
ctx := c.Context()
err := h.telebirrSvc.HandleTelebirrPaymentCallback(ctx, &payload)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Failed to handle Telebirr payment callback",
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Telebirr payment processed successfully",
Data: nil,
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -20,7 +20,9 @@ import (
func (a *App) initAppRoutes() { func (a *App) initAppRoutes() {
h := handlers.New( h := handlers.New(
a.telebirrSvc,
a.arifpaySvc, a.arifpaySvc,
a.santimpaySvc,
a.issueReportingSvc, a.issueReportingSvc,
a.instSvc, a.instSvc,
a.currSvc, a.currSvc,
@ -60,10 +62,18 @@ func (a *App) initAppRoutes() {
}) })
}) })
groupV1 := a.fiber.Group("/api/v1")
//Direct_deposit
groupV1.Post("/direct_deposit", a.authMiddleware, h.InitiateDirectDeposit)
groupV1.Post("/direct_deposit/verify", a.authMiddleware, h.VerifyDirectDeposit)
groupV1.Get("/direct_deposit/pending", a.authMiddleware, h.GetPendingDirectDeposits)
groupV1.Post("/auth/admin-login", h.LoginAdmin)
groupV1.Post("/auth/refresh", h.RefreshToken)
// Swagger // Swagger
a.fiber.Get("/swagger/*", fiberSwagger.FiberWrapHandler()) a.fiber.Get("/swagger/*", fiberSwagger.FiberWrapHandler())
groupV1 := a.fiber.Group("/api/v1")
groupV1.Get("/", func(c *fiber.Ctx) error { groupV1.Get("/", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"message": "FortuneBet API V1 pre-alpha", "message": "FortuneBet API V1 pre-alpha",
@ -107,7 +117,17 @@ func (a *App) initAppRoutes() {
groupV1.Post("/arifpay/b2c/transfer", a.authMiddleware, h.B2CTransferHandler) groupV1.Post("/arifpay/b2c/transfer", a.authMiddleware, h.B2CTransferHandler)
groupV1.Post("/arifpay/transaction-id/verify-transaction", a.authMiddleware, h.ArifpayVerifyByTransactionIDHandler) groupV1.Post("/arifpay/transaction-id/verify-transaction", a.authMiddleware, h.ArifpayVerifyByTransactionIDHandler)
groupV1.Get("/arifpay/session-id/verify-transaction/:session_id", a.authMiddleware, h.ArifpayVerifyBySessionIDHandler) groupV1.Get("/arifpay/session-id/verify-transaction/:session_id", a.authMiddleware, h.ArifpayVerifyBySessionIDHandler)
//Telebirr
groupV1.Post("/telebirr/init-payment", a.authMiddleware, h.CreateTelebirrPaymentHandler)
groupV1.Post("/telebirr/callback", h.HandleTelebirrCallback)
//Santimpay
groupV1.Post("/santimpay/init-payment", h.CreateSantimPayPaymentHandler)
// groupV1.Post("/arifpay/b2c/transfer", a.authMiddleware, h.B2CTransferHandler)
// groupV1.Post("/arifpay/transaction-id/verify-transaction", a.authMiddleware, h.ArifpayVerifyByTransactionIDHandler)
// groupV1.Get("/arifpay/session-id/verify-transaction/:session_id", a.authMiddleware, h.ArifpayVerifyBySessionIDHandler)
// User Routes // User Routes
groupV1.Post("/user/resetPassword", h.ResetPassword) groupV1.Post("/user/resetPassword", h.ResetPassword)
groupV1.Post("/user/sendResetCode", h.SendResetCode) groupV1.Post("/user/sendResetCode", h.SendResetCode)