Merge branch 'main' into ticket-bet

This commit is contained in:
Samuel Tariku 2025-08-18 18:49:58 +03:00
commit 437780f3e9
29 changed files with 6225 additions and 817 deletions

View File

@ -153,7 +153,7 @@ func main() {
virtualGameSvc := virtualgameservice.New(vitualGameRepo, *walletSvc, store, cfg, logger) virtualGameSvc := virtualgameservice.New(vitualGameRepo, *walletSvc, store, cfg, logger)
aleaService := alea.NewAleaPlayService(vitualGameRepo, *walletSvc, cfg, logger) aleaService := alea.NewAleaPlayService(vitualGameRepo, *walletSvc, cfg, logger)
veliCLient := veli.NewClient(cfg, walletSvc) veliCLient := veli.NewClient(cfg, walletSvc)
veliVirtualGameService := veli.New(veliCLient) veliVirtualGameService := veli.New(veliCLient, walletSvc, cfg)
recommendationSvc := recommendation.NewService(recommendationRepo) recommendationSvc := recommendation.NewService(recommendationRepo)
chapaClient := chapa.NewClient(cfg.CHAPA_BASE_URL, cfg.CHAPA_SECRET_KEY) chapaClient := chapa.NewClient(cfg.CHAPA_BASE_URL, cfg.CHAPA_SECRET_KEY)
@ -234,12 +234,12 @@ func main() {
transferStore := wallet.TransferStore(store) transferStore := wallet.TransferStore(store)
// walletStore := wallet.WalletStore(store) // walletStore := wallet.WalletStore(store)
arifpaySvc := arifpay.NewArifpayService(cfg, transferStore, &http.Client{ arifpaySvc := arifpay.NewArifpayService(cfg, transferStore, walletSvc, &http.Client{
Timeout: 30 * time.Second}) Timeout: 30 * time.Second})
santimpayClient := santimpay.NewSantimPayClient(cfg) santimpayClient := santimpay.NewSantimPayClient(cfg)
santimpaySvc := santimpay.NewSantimPayService(santimpayClient, cfg, transferStore) santimpaySvc := santimpay.NewSantimPayService(santimpayClient, cfg, transferStore, walletSvc)
telebirrSvc := telebirr.NewTelebirrService(cfg, transferStore, walletSvc) telebirrSvc := telebirr.NewTelebirrService(cfg, transferStore, walletSvc)
// Initialize and start HTTP server // Initialize and start HTTP server

View File

@ -168,6 +168,7 @@ CREATE TABLE IF NOT EXISTS wallet_transfer (
cashier_id BIGINT, cashier_id BIGINT,
verified BOOLEAN DEFAULT false, verified BOOLEAN DEFAULT false,
reference_number VARCHAR(255) NOT NULL, reference_number VARCHAR(255) NOT NULL,
session_id VARCHAR(255),
status VARCHAR(255), status VARCHAR(255),
payment_method VARCHAR(255), payment_method VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

View File

@ -8,10 +8,11 @@ INSERT INTO wallet_transfer (
cashier_id, cashier_id,
verified, verified,
reference_number, reference_number,
session_id,
status, status,
payment_method payment_method
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *; RETURNING *;
-- name: GetAllTransfers :many -- name: GetAllTransfers :many
SELECT * SELECT *

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -769,6 +769,7 @@ type WalletTransfer struct {
CashierID pgtype.Int8 `json:"cashier_id"` CashierID pgtype.Int8 `json:"cashier_id"`
Verified pgtype.Bool `json:"verified"` Verified pgtype.Bool `json:"verified"`
ReferenceNumber string `json:"reference_number"` ReferenceNumber string `json:"reference_number"`
SessionID pgtype.Text `json:"session_id"`
Status pgtype.Text `json:"status"` Status pgtype.Text `json:"status"`
PaymentMethod pgtype.Text `json:"payment_method"` PaymentMethod pgtype.Text `json:"payment_method"`
CreatedAt pgtype.Timestamp `json:"created_at"` CreatedAt pgtype.Timestamp `json:"created_at"`
@ -785,6 +786,7 @@ type WalletTransferDetail struct {
CashierID pgtype.Int8 `json:"cashier_id"` CashierID pgtype.Int8 `json:"cashier_id"`
Verified pgtype.Bool `json:"verified"` Verified pgtype.Bool `json:"verified"`
ReferenceNumber string `json:"reference_number"` ReferenceNumber string `json:"reference_number"`
SessionID pgtype.Text `json:"session_id"`
Status pgtype.Text `json:"status"` Status pgtype.Text `json:"status"`
PaymentMethod pgtype.Text `json:"payment_method"` PaymentMethod pgtype.Text `json:"payment_method"`
CreatedAt pgtype.Timestamp `json:"created_at"` CreatedAt pgtype.Timestamp `json:"created_at"`

View File

@ -21,11 +21,12 @@ INSERT INTO wallet_transfer (
cashier_id, cashier_id,
verified, verified,
reference_number, reference_number,
session_id,
status, status,
payment_method payment_method
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, amount, message, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, status, payment_method, created_at, updated_at RETURNING id, amount, message, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, session_id, status, payment_method, created_at, updated_at
` `
type CreateTransferParams struct { type CreateTransferParams struct {
@ -37,6 +38,7 @@ type CreateTransferParams struct {
CashierID pgtype.Int8 `json:"cashier_id"` CashierID pgtype.Int8 `json:"cashier_id"`
Verified pgtype.Bool `json:"verified"` Verified pgtype.Bool `json:"verified"`
ReferenceNumber string `json:"reference_number"` ReferenceNumber string `json:"reference_number"`
SessionID pgtype.Text `json:"session_id"`
Status pgtype.Text `json:"status"` Status pgtype.Text `json:"status"`
PaymentMethod pgtype.Text `json:"payment_method"` PaymentMethod pgtype.Text `json:"payment_method"`
} }
@ -51,6 +53,7 @@ func (q *Queries) CreateTransfer(ctx context.Context, arg CreateTransferParams)
arg.CashierID, arg.CashierID,
arg.Verified, arg.Verified,
arg.ReferenceNumber, arg.ReferenceNumber,
arg.SessionID,
arg.Status, arg.Status,
arg.PaymentMethod, arg.PaymentMethod,
) )
@ -65,6 +68,7 @@ func (q *Queries) CreateTransfer(ctx context.Context, arg CreateTransferParams)
&i.CashierID, &i.CashierID,
&i.Verified, &i.Verified,
&i.ReferenceNumber, &i.ReferenceNumber,
&i.SessionID,
&i.Status, &i.Status,
&i.PaymentMethod, &i.PaymentMethod,
&i.CreatedAt, &i.CreatedAt,
@ -74,7 +78,7 @@ func (q *Queries) CreateTransfer(ctx context.Context, arg CreateTransferParams)
} }
const GetAllTransfers = `-- name: GetAllTransfers :many const GetAllTransfers = `-- name: GetAllTransfers :many
SELECT id, amount, message, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, status, payment_method, created_at, updated_at, first_name, last_name, phone_number SELECT id, amount, message, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, session_id, status, payment_method, created_at, updated_at, first_name, last_name, phone_number
FROM wallet_transfer_details FROM wallet_transfer_details
` `
@ -97,6 +101,7 @@ func (q *Queries) GetAllTransfers(ctx context.Context) ([]WalletTransferDetail,
&i.CashierID, &i.CashierID,
&i.Verified, &i.Verified,
&i.ReferenceNumber, &i.ReferenceNumber,
&i.SessionID,
&i.Status, &i.Status,
&i.PaymentMethod, &i.PaymentMethod,
&i.CreatedAt, &i.CreatedAt,
@ -116,7 +121,7 @@ func (q *Queries) GetAllTransfers(ctx context.Context) ([]WalletTransferDetail,
} }
const GetTransferByID = `-- name: GetTransferByID :one const GetTransferByID = `-- name: GetTransferByID :one
SELECT id, amount, message, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, status, payment_method, created_at, updated_at, first_name, last_name, phone_number SELECT id, amount, message, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, session_id, status, payment_method, created_at, updated_at, first_name, last_name, phone_number
FROM wallet_transfer_details FROM wallet_transfer_details
WHERE id = $1 WHERE id = $1
` `
@ -134,6 +139,7 @@ func (q *Queries) GetTransferByID(ctx context.Context, id int64) (WalletTransfer
&i.CashierID, &i.CashierID,
&i.Verified, &i.Verified,
&i.ReferenceNumber, &i.ReferenceNumber,
&i.SessionID,
&i.Status, &i.Status,
&i.PaymentMethod, &i.PaymentMethod,
&i.CreatedAt, &i.CreatedAt,
@ -146,7 +152,7 @@ func (q *Queries) GetTransferByID(ctx context.Context, id int64) (WalletTransfer
} }
const GetTransferByReference = `-- name: GetTransferByReference :one const GetTransferByReference = `-- name: GetTransferByReference :one
SELECT id, amount, message, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, status, payment_method, created_at, updated_at, first_name, last_name, phone_number SELECT id, amount, message, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, session_id, status, payment_method, created_at, updated_at, first_name, last_name, phone_number
FROM wallet_transfer_details FROM wallet_transfer_details
WHERE reference_number = $1 WHERE reference_number = $1
` `
@ -164,6 +170,7 @@ func (q *Queries) GetTransferByReference(ctx context.Context, referenceNumber st
&i.CashierID, &i.CashierID,
&i.Verified, &i.Verified,
&i.ReferenceNumber, &i.ReferenceNumber,
&i.SessionID,
&i.Status, &i.Status,
&i.PaymentMethod, &i.PaymentMethod,
&i.CreatedAt, &i.CreatedAt,
@ -176,7 +183,7 @@ func (q *Queries) GetTransferByReference(ctx context.Context, referenceNumber st
} }
const GetTransfersByWallet = `-- name: GetTransfersByWallet :many const GetTransfersByWallet = `-- name: GetTransfersByWallet :many
SELECT id, amount, message, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, status, payment_method, created_at, updated_at, first_name, last_name, phone_number SELECT id, amount, message, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, session_id, status, payment_method, created_at, updated_at, first_name, last_name, phone_number
FROM wallet_transfer_details FROM wallet_transfer_details
WHERE receiver_wallet_id = $1 WHERE receiver_wallet_id = $1
OR sender_wallet_id = $1 OR sender_wallet_id = $1
@ -201,6 +208,7 @@ func (q *Queries) GetTransfersByWallet(ctx context.Context, receiverWalletID pgt
&i.CashierID, &i.CashierID,
&i.Verified, &i.Verified,
&i.ReferenceNumber, &i.ReferenceNumber,
&i.SessionID,
&i.Status, &i.Status,
&i.PaymentMethod, &i.PaymentMethod,
&i.CreatedAt, &i.CreatedAt,

2
go.mod
View File

@ -94,6 +94,6 @@ require (
github.com/segmentio/kafka-go v0.4.48 // direct github.com/segmentio/kafka-go v0.4.48 // direct
) )
require github.com/AnaniyaBelew/ArifpayGoPlugin v0.0.0-20231127130208-54b9bc51118f // require github.com/AnaniyaBelew/ArifpayGoPlugin v0.0.0-20231127130208-54b9bc51118f
// require github.com/AnaniyaBelew/ArifpayGoPlugin v0.0.0-20231127130208-54b9bc51118f // direct // require github.com/AnaniyaBelew/ArifpayGoPlugin v0.0.0-20231127130208-54b9bc51118f // direct

View File

@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"strconv" "strconv"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger"
@ -59,17 +60,34 @@ type VeliConfig struct {
} }
type ARIFPAYConfig struct { type ARIFPAYConfig struct {
APIKey string `mapstructure:"ARIFPAYAPI_KEY"` APIKey string `mapstructure:"ARIFPAY_API_KEY"`
CancelUrl string `mapstructure:"ARIFPAY_BASE_URL"` BaseURL string `mapstructure:"ARIFPAY_BASE_URL"`
ErrorUrl string `mapstructure:"ARIFPAY_SECRET_KEY"`
NotifyUrl string `mapstructure:"ARIFPAY_OPERATOR_ID"` // Default URLs
SuccessUrl string `mapstructure:"ARIFPAY_BRAND_ID"` CancelUrl string `mapstructure:"cancelUrl"`
SuccessUrl string `mapstructure:"successUrl"`
ErrorUrl string `mapstructure:"errorUrl"`
B2CNotifyUrl string `mapstructure:"notifyUrl"`
C2BNotifyUrl string `mapstructure:"notifyUrl"`
// Default Payment Configs
PaymentMethods []string `mapstructure:"paymentMethods"`
ExpireDate string `mapstructure:"expireDate"`
ItemName string `mapstructure:"name"`
Quantity int `mapstructure:"quantity"`
Description string `mapstructure:"description"`
BeneficiaryAccountNumber string `mapstructure:"accountNumber"`
Bank string `mapstructure:"bank"`
Lang string `mapstructure:"amount"`
} }
type SANTIMPAYConfig struct { type SANTIMPAYConfig struct {
SecretKey string `mapstructure:"secret_key"` SecretKey string `mapstructure:"secret_key"`
MerchantID string `mapstructure:"merchant_id"` MerchantID string `mapstructure:"merchantId"`
BaseURL string `mapstructure:"base_url"` BaseURL string `mapstructure:"base_url"`
NotifyURL string `mapstructure:"notifyUrl"`
CancelUrl string `mapstructure:"cancelUrl"`
SuccessUrl string `mapstructure:"successUrl"`
} }
type TELEBIRRConfig struct { type TELEBIRRConfig struct {
@ -224,12 +242,24 @@ func (c *Config) loadEnv() error {
c.ARIFPAY.APIKey = os.Getenv("ARIFPAY_API_KEY") c.ARIFPAY.APIKey = os.Getenv("ARIFPAY_API_KEY")
c.ARIFPAY.CancelUrl = os.Getenv("ARIFPAY_CANCEL_URL") c.ARIFPAY.CancelUrl = os.Getenv("ARIFPAY_CANCEL_URL")
c.ARIFPAY.ErrorUrl = os.Getenv("ARIFPAY_ERROR_URL") c.ARIFPAY.ErrorUrl = os.Getenv("ARIFPAY_ERROR_URL")
c.ARIFPAY.NotifyUrl = os.Getenv("ARIFPAY_NOTIFY_URL") c.ARIFPAY.C2BNotifyUrl = os.Getenv("ARIFPAY_C2B_NOTIFY_URL")
c.ARIFPAY.B2CNotifyUrl = os.Getenv("ARIFPAY_B2C_NOTIFY_URL")
c.ARIFPAY.SuccessUrl = os.Getenv("ARIFPAY_SUCCESS_URL") c.ARIFPAY.SuccessUrl = os.Getenv("ARIFPAY_SUCCESS_URL")
c.ARIFPAY.BaseURL = os.Getenv("ARIFPAY_BASE_URL")
c.ARIFPAY.Bank = os.Getenv("ARIFPAY_BANK")
c.ARIFPAY.BeneficiaryAccountNumber = os.Getenv("ARIFPAY_BENEFICIARY_ACCOUNT_NUMBER")
c.ARIFPAY.Description = os.Getenv("ARIFPAY_DESCRIPTION")
c.ARIFPAY.ExpireDate = time.Now().Add(time.Hour * 3).Format("2006-01-02")
c.ARIFPAY.ItemName = os.Getenv("ARIFPAY_ITEM_NAME")
c.ARIFPAY.Lang = "EN"
c.ARIFPAY.Quantity = 1
c.ARIFPAY.PaymentMethods = []string{"TELEBIRR", "AWAASH", "AWAASH_WALLET", "PSS", "CBE", "AMOLE", "BOA", "KACHA", "TELEBIRR", "HELLOCASH", "MPESSA"}
c.SANTIMPAY.SecretKey = os.Getenv("SANTIMPAY_SECRET_KEY") c.SANTIMPAY.SecretKey = os.Getenv("SANTIMPAY_SECRET_KEY")
c.SANTIMPAY.MerchantID = os.Getenv("SANTIMPAY_MERCHANT_ID") c.SANTIMPAY.MerchantID = os.Getenv("SANTIMPAY_MERCHANT_ID")
c.SANTIMPAY.BaseURL = os.Getenv("SANTIMPAY_Base_URL") c.SANTIMPAY.BaseURL = os.Getenv("SANTIMPAY_BASE_URL")
c.SANTIMPAY.NotifyURL = os.Getenv("SANTIMPAY_NOTIFY_URL")
c.SANTIMPAY.CancelUrl = os.Getenv("SANTIMPAY_CANCEL_URL")
//Alea Play //Alea Play
aleaEnabled := os.Getenv("ALEA_ENABLED") aleaEnabled := os.Getenv("ALEA_ENABLED")

View File

@ -1,37 +1,39 @@
package domain package domain
import "time" type CheckoutSessionRequest struct {
CancelURL string `json:"cancelUrl"`
Phone string `json:"phone"`
Email string `json:"email"`
Nonce string `json:"nonce"`
SuccessURL string `json:"successUrl"`
ErrorURL string `json:"errorUrl"`
NotifyURL string `json:"notifyUrl"`
PaymentMethods []string `json:"paymentMethods"`
ExpireDate string `json:"expireDate"` // could also use time.Time if you parse it
type Item struct { Items []struct {
Name string `json:"name"` Name string `json:"name"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
Price float64 `json:"price"` Price float64 `json:"price"`
Description string `json:"description"` Description string `json:"description"`
Image string `json:"image"` } `json:"items"`
Beneficiaries []struct {
AccountNumber string `json:"accountNumber"`
Bank string `json:"bank"`
Amount float64 `json:"amount"`
} `json:"beneficiaries"`
Lang string `json:"lang"`
} }
type Beneficiary struct { type CheckoutSessionClientRequest struct {
AccountNumber string `json:"accountNumber"` Amount float64 `json:"amount" binding:"required"`
Bank string `json:"bank"` CustomerEmail string `json:"customerEmail" binding:"required"`
Amount float64 `json:"amount"` CustomerPhone string `json:"customerPhone" binding:"required"`
} }
type CreateCheckoutSessionRequest struct { type CancelCheckoutSessionResponse struct {
CancelUrl string `json:"cancelUrl"`
Phone string `json:"phone"`
Email string `json:"email"`
Nonce string `json:"nonce"`
ErrorUrl string `json:"errorUrl"`
NotifyUrl string `json:"notifyUrl"`
SuccessUrl string `json:"successUrl"`
PaymentMethods []string `json:"paymentMethods"`
ExpireDate time.Time `json:"expireDate"`
Items []Item `json:"items"`
Beneficiaries []Beneficiary `json:"beneficiaries"`
Lang string `json:"lang"`
}
type ArifPayCheckoutResponse struct {
Error bool `json:"error"` Error bool `json:"error"`
Msg string `json:"msg"` Msg string `json:"msg"`
Data struct { Data struct {
@ -42,12 +44,34 @@ type ArifPayCheckoutResponse struct {
} `json:"data"` } `json:"data"`
} }
type ArifPayB2CRequest struct { type WebhookRequest struct {
SessionID string `json:"Sessionid"` UUID string `json:"uuid"`
PhoneNumber string `json:"Phonenumber"` Nonce string `json:"nonce"`
Phone string `json:"phone"`
PaymentMethod string `json:"paymentMethod"`
TotalAmount int64 `json:"totalAmount"`
TransactionStatus string `json:"transactionStatus"`
Transaction struct {
TransactionID string `json:"transactionId"`
TransactionStatus string `json:"transactionStatus"`
} `json:"transaction"`
NotificationURL string `json:"notificationUrl"`
SessionID string `json:"sessionId"`
} }
type ArifpayVerifyByTransactionIDRequest struct { type ArifpayB2CRequest struct{
TransactionID string `json:"transactionId"` PhoneNumber string `json:"Phonenumber"`
PaymentType int `json:"paymentType"` Amount float64 `json:"amount" binding:"required"`
CustomerEmail string `json:"customerEmail" binding:"required"`
CustomerPhone string `json:"customerPhone" binding:"required"`
}
type ArifpayVerifyByTransactionIDRequest struct{
TransactionId string `json:"transactionId"`
PaymentType int `json:"paymentType"`
}
type ARIFPAYPaymentMethod struct {
ID int
Name string
} }

View File

@ -1,17 +1,13 @@
package domain package domain
type GeneratePaymentURLInput struct { type GeneratePaymentURLRequest struct {
ID string Amount int `json:"amount"`
Amount int Reason string `json:"paymentReason"`
Reason string PhoneNumber string `json:"phoneNumber"`
PhoneNumber string PaymentMethod string `json:"paymentMethod,omitempty"`
// SuccessRedirectURL string
// FailureRedirectURL string
// CancelRedirectURL string
// NotifyURL string
} }
type InitiatePaymentPayload struct { type InitiatePaymentRequest struct {
ID string `json:"id"` ID string `json:"id"`
Amount int `json:"amount"` Amount int `json:"amount"`
Reason string `json:"paymentReason"` Reason string `json:"paymentReason"`
@ -22,4 +18,64 @@ type InitiatePaymentPayload struct {
NotifyURL string `json:"notifyUrl"` NotifyURL string `json:"notifyUrl"`
CancelRedirectURL string `json:"cancelRedirectUrl"` CancelRedirectURL string `json:"cancelRedirectUrl"`
PhoneNumber string `json:"phoneNumber"` PhoneNumber string `json:"phoneNumber"`
} PaymentMethod string `json:"paymentMethod,omitempty"`
}
type SantimPayCallbackPayload struct {
TxnId string `json:"txnId"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ThirdPartyId string `json:"thirdPartyId"`
MerId string `json:"merId"`
MerName string `json:"merName"`
Address string `json:"address"`
Amount string `json:"amount"`
Currency string `json:"currency"`
Reason string `json:"reason"`
Msisdn string `json:"msisdn"`
AccountNumber string `json:"accountNumber"`
PaymentVia string `json:"paymentVia"`
RefId string `json:"refId"`
SuccessRedirectUrl string `json:"successRedirectUrl"`
FailureRedirectUrl string `json:"failureRedirectUrl"`
Message string `json:"message"`
Status string `json:"status"`
ReceiverWalletID string `json:"receiverWalletID"`
}
type SantimTokenPayload struct {
Amount int `json:"amount"`
Reason string `json:"paymentReason"`
PaymentMethod string `json:"paymentMethod"`
PhoneNumber string `json:"phoneNumber"`
ID string `json:"id,omitempty"`
}
type Partner struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Input string `json:"input"`
}
// B2CPartnersResponse is the top-level response
type B2CPartnersResponse struct {
Partners []Partner `json:"partners"`
}
type SantimpayB2CWithdrawalRequest struct {
ID string `json:"id"`
ClientReference string `json:"clientReference"`
Amount float64 `json:"amount"`
Reason string `json:"reason"`
MerchantID string `json:"merchantId"`
SignedToken string `json:"signedToken"`
ReceiverAccountNumber string `json:"receiverAccountNumber"`
NotifyURL string `json:"notifyUrl"`
PaymentMethod string `json:"paymentMethod"`
}
type TransactionStatusRequest struct {
TransactionID string `json:"id"`
FullParams *bool `json:"fullParams,omitempty"`
}

View File

@ -85,7 +85,7 @@ type ShopBetRes struct {
BetID int64 `json:"bet_id" example:"1"` BetID int64 `json:"bet_id" example:"1"`
NumberOfOutcomes int64 `json:"number_of_outcomes" example:"1"` NumberOfOutcomes int64 `json:"number_of_outcomes" example:"1"`
Status OutcomeStatus `json:"status" example:"1"` Status OutcomeStatus `json:"status" example:"1"`
Amount Currency `json:"amount" example:"100.0"` Amount Currency `json:"amount"`
Outcomes []BetOutcome `json:"outcomes"` Outcomes []BetOutcome `json:"outcomes"`
TransactionVerified bool `json:"transaction_verified" example:"true"` TransactionVerified bool `json:"transaction_verified" example:"true"`
UpdatedAt time.Time `json:"updated_at" example:"2025-04-08T12:00:00Z"` UpdatedAt time.Time `json:"updated_at" example:"2025-04-08T12:00:00Z"`

View File

@ -66,6 +66,7 @@ type Transfer struct {
ReceiverWalletID ValidInt64 `json:"receiver_wallet_id"` ReceiverWalletID ValidInt64 `json:"receiver_wallet_id"`
SenderWalletID ValidInt64 `json:"sender_wallet_id"` SenderWalletID ValidInt64 `json:"sender_wallet_id"`
ReferenceNumber string `json:"reference_number"` // <-- needed ReferenceNumber string `json:"reference_number"` // <-- needed
SessionID string `json:"session_id"`
Status string `json:"status"` Status string `json:"status"`
DepositorID ValidInt64 `json:"depositor_id"` DepositorID ValidInt64 `json:"depositor_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@ -81,6 +82,7 @@ type TransferDetail struct {
ReceiverWalletID ValidInt64 `json:"receiver_wallet_id"` ReceiverWalletID ValidInt64 `json:"receiver_wallet_id"`
SenderWalletID ValidInt64 `json:"sender_wallet_id"` SenderWalletID ValidInt64 `json:"sender_wallet_id"`
ReferenceNumber string `json:"reference_number"` // <-- needed ReferenceNumber string `json:"reference_number"` // <-- needed
SessionID string `json:"session_id"`
Status string `json:"status"` Status string `json:"status"`
DepositorID ValidInt64 `json:"depositor_id"` DepositorID ValidInt64 `json:"depositor_id"`
DepositorFirstName string `json:"depositor_first_name"` DepositorFirstName string `json:"depositor_first_name"`
@ -99,6 +101,7 @@ type CreateTransfer struct {
ReceiverWalletID ValidInt64 `json:"receiver_wallet_id"` ReceiverWalletID ValidInt64 `json:"receiver_wallet_id"`
SenderWalletID ValidInt64 `json:"sender_wallet_id"` SenderWalletID ValidInt64 `json:"sender_wallet_id"`
ReferenceNumber string `json:"reference_number"` // <-- needed ReferenceNumber string `json:"reference_number"` // <-- needed
SessionID string `json:"session_id"`
Status string `json:"status"` Status string `json:"status"`
CashierID ValidInt64 `json:"cashier_id"` CashierID ValidInt64 `json:"cashier_id"`
} }

View File

@ -38,7 +38,7 @@ type GameStartRequest struct {
ProviderID string `json:"providerId"` ProviderID string `json:"providerId"`
GameID string `json:"gameId"` GameID string `json:"gameId"`
Language string `json:"language"` Language string `json:"language"`
PlayerID string `json:"playerId"` PlayerID string `json:"playerId,omitempty"`
Currency string `json:"currency"` Currency string `json:"currency"`
DeviceType string `json:"deviceType"` DeviceType string `json:"deviceType"`
Country string `json:"country"` Country string `json:"country"`
@ -69,9 +69,9 @@ type BalanceRequest struct {
SessionID string `json:"sessionId"` SessionID string `json:"sessionId"`
ProviderID string `json:"providerId"` ProviderID string `json:"providerId"`
PlayerID string `json:"playerId"` PlayerID string `json:"playerId"`
Currency string `json:"currency"`
BrandID string `json:"brandId"` BrandID string `json:"brandId"`
GameID string `json:"gameId,omitempty"` GameID string `json:"gameId,omitempty"`
Currency string `json:"currency"`
} }
type BalanceResponse struct { type BalanceResponse struct {
@ -170,6 +170,7 @@ type BalanceDetail struct {
Currency string `json:"currency"` Currency string `json:"currency"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
} }
// Request // Request
type GamingActivityRequest struct { type GamingActivityRequest struct {
FromDate string `json:"fromDate"` // YYYY-MM-DD FromDate string `json:"fromDate"` // YYYY-MM-DD
@ -181,7 +182,6 @@ type GamingActivityRequest struct {
Page int `json:"page,omitempty"` // Optional, default 1 Page int `json:"page,omitempty"` // Optional, default 1
Size int `json:"size,omitempty"` // Optional, default 100 Size int `json:"size,omitempty"` // Optional, default 100
PlayerIDs []string `json:"playerIds,omitempty"` // Optional PlayerIDs []string `json:"playerIds,omitempty"` // Optional
BrandID string `json:"brandId"` // Required
} }
// Response // Response
@ -212,9 +212,44 @@ type GamingActivityItem struct {
} }
type PaginationMeta struct { type PaginationMeta struct {
TotalItems int `json:"totalItems"` TotalItems int `json:"totalItems"`
ItemCount int `json:"itemCount"` ItemCount int `json:"itemCount"`
ItemsPerPage int `json:"itemsPerPage"` ItemsPerPage int `json:"itemsPerPage"`
TotalPages int `json:"totalPages"` TotalPages int `json:"totalPages"`
CurrentPage int `json:"currentPage"` CurrentPage int `json:"currentPage"`
}
type HugeWinsRequest struct {
FromDate string `json:"fromDate"`
ToDate string `json:"toDate"`
ProviderID string `json:"providerId,omitempty"`
Currencies []string `json:"currencies,omitempty"`
BrandID string `json:"brandId"`
GameIDs []string `json:"gameIds,omitempty"`
Page int `json:"page,omitempty"`
Size int `json:"size,omitempty"`
}
type HugeWinsResponse struct {
Items []HugeWinItem `json:"items"`
Meta PaginationMeta `json:"meta"`
}
type HugeWinItem struct {
BetTransactionID string `json:"betTransactionId"`
WinTransactionID string `json:"winTransactionId"`
Currency string `json:"currency"`
GameID string `json:"gameId"`
BetAmount float64 `json:"betAmount"`
WinAmount float64 `json:"winAmount"`
BetAmountUsd float64 `json:"betAmountUsd"`
WinAmountUsd float64 `json:"winAmountUsd"`
ProviderID string `json:"providerId"`
PlayerID string `json:"playerId"`
RoundID string `json:"roundId"`
CorrelationID string `json:"correlationId"`
BrandID string `json:"brandId"`
OperatorID string `json:"operatorId"`
CreatedAt string `json:"createdAt"`
Reason string `json:"reason"`
} }

View File

@ -32,6 +32,7 @@ func convertDBTransferDetail(transfer dbgen.WalletTransferDetail) domain.Transfe
DepositorPhoneNumber: transfer.PhoneNumber.String, DepositorPhoneNumber: transfer.PhoneNumber.String,
PaymentMethod: domain.PaymentMethod(transfer.PaymentMethod.String), PaymentMethod: domain.PaymentMethod(transfer.PaymentMethod.String),
ReferenceNumber: transfer.ReferenceNumber, ReferenceNumber: transfer.ReferenceNumber,
SessionID: transfer.SessionID.String,
Status: transfer.Status.String, Status: transfer.Status.String,
CreatedAt: transfer.CreatedAt.Time, CreatedAt: transfer.CreatedAt.Time,
UpdatedAt: transfer.UpdatedAt.Time, UpdatedAt: transfer.UpdatedAt.Time,
@ -58,6 +59,7 @@ func convertDBTransfer(transfer dbgen.WalletTransfer) domain.Transfer {
}, },
PaymentMethod: domain.PaymentMethod(transfer.PaymentMethod.String), PaymentMethod: domain.PaymentMethod(transfer.PaymentMethod.String),
ReferenceNumber: transfer.ReferenceNumber, ReferenceNumber: transfer.ReferenceNumber,
SessionID: transfer.SessionID.String,
Status: transfer.Status.String, Status: transfer.Status.String,
CreatedAt: transfer.CreatedAt.Time, CreatedAt: transfer.CreatedAt.Time,
UpdatedAt: transfer.UpdatedAt.Time, UpdatedAt: transfer.UpdatedAt.Time,
@ -82,6 +84,10 @@ func convertCreateTransfer(transfer domain.CreateTransfer) dbgen.CreateTransferP
Valid: transfer.CashierID.Valid, Valid: transfer.CashierID.Valid,
}, },
ReferenceNumber: string(transfer.ReferenceNumber), ReferenceNumber: string(transfer.ReferenceNumber),
SessionID: pgtype.Text{
String: transfer.SessionID,
Valid: true,
},
PaymentMethod: pgtype.Text{String: string(transfer.PaymentMethod), Valid: true}, PaymentMethod: pgtype.Text{String: string(transfer.PaymentMethod), Valid: true},
Verified: pgtype.Bool{ Verified: pgtype.Bool{

View File

@ -4,12 +4,11 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"time"
"github.com/AnaniyaBelew/ArifpayGoPlugin"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/config"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
@ -19,174 +18,567 @@ import (
type ArifpayService struct { type ArifpayService struct {
cfg *config.Config cfg *config.Config
transferStore wallet.TransferStore transferStore wallet.TransferStore
walletSvc *wallet.Service
httpClient *http.Client httpClient *http.Client
} }
func NewArifpayService(cfg *config.Config, transferStore wallet.TransferStore, httpClient *http.Client) *ArifpayService { func NewArifpayService(cfg *config.Config, transferStore wallet.TransferStore, walletSvc *wallet.Service, httpClient *http.Client) *ArifpayService {
return &ArifpayService{ return &ArifpayService{
cfg: cfg, cfg: cfg,
transferStore: transferStore, transferStore: transferStore,
walletSvc: walletSvc,
httpClient: httpClient, httpClient: httpClient,
} }
} }
func (s *ArifpayService) CreateCheckoutSession(req domain.CreateCheckoutSessionRequest) (string, error) { func (s *ArifpayService) CreateCheckoutSession(req domain.CheckoutSessionClientRequest, isDeposit bool) (map[string]any, error) {
// Create SDK-compatible payload // Generate unique nonce
paymentPayload := ArifpayGoPlugin.PaymentRequest{ nonce := uuid.NewString()
CancelUrl: s.cfg.ARIFPAY.CancelUrl,
Phone: req.Phone, var NotifyURL string
Email: req.Email,
Nonce: req.Nonce, if isDeposit{
ErrorUrl: s.cfg.ARIFPAY.ErrorUrl, NotifyURL = s.cfg.ARIFPAY.C2BNotifyUrl
NotifyUrl: s.cfg.ARIFPAY.NotifyUrl, }else{
SuccessUrl: s.cfg.ARIFPAY.SuccessUrl, NotifyURL = s.cfg.ARIFPAY.B2CNotifyUrl
PaymentMethods: req.PaymentMethods,
Lang: req.Lang,
} }
// Convert items // Construct full checkout request
for _, item := range req.Items { checkoutReq := domain.CheckoutSessionRequest{
paymentPayload.Items = append(paymentPayload.Items, domain.Item{ CancelURL: s.cfg.ARIFPAY.CancelUrl,
Name: item.Name, Phone: req.CustomerPhone, // must be in format 2519...
Quantity: item.Quantity, Email: req.CustomerEmail,
Price: item.Price, Nonce: nonce,
Description: item.Description, SuccessURL: s.cfg.ARIFPAY.SuccessUrl,
Image: item.Image, ErrorURL: s.cfg.ARIFPAY.ErrorUrl,
}) NotifyURL: NotifyURL,
PaymentMethods: s.cfg.ARIFPAY.PaymentMethods,
ExpireDate: s.cfg.ARIFPAY.ExpireDate,
Items: []struct {
Name string `json:"name"`
Quantity int `json:"quantity"`
Price float64 `json:"price"`
Description string `json:"description"`
}{
{
Name: s.cfg.ARIFPAY.ItemName,
Quantity: s.cfg.ARIFPAY.Quantity,
Price: req.Amount,
Description: s.cfg.ARIFPAY.Description,
},
},
Beneficiaries: []struct {
AccountNumber string `json:"accountNumber"`
Bank string `json:"bank"`
Amount float64 `json:"amount"`
}{
{
AccountNumber: s.cfg.ARIFPAY.BeneficiaryAccountNumber,
Bank: s.cfg.ARIFPAY.Bank,
Amount: req.Amount,
},
},
Lang: s.cfg.ARIFPAY.Lang,
} }
// Convert beneficiaries // Marshal to JSON
for _, b := range req.Beneficiaries { payload, err := json.Marshal(checkoutReq)
paymentPayload.Beneficiaries = append(paymentPayload.Beneficiaries, domain.Beneficiary{
AccountNumber: b.AccountNumber,
Bank: b.Bank,
Amount: b.Amount,
})
}
// Instantiate payment client
expireDate := time.Now().AddDate(2, 0, 0) // 2 months from now
paymentClient := ArifpayGoPlugin.NewPayment(s.cfg.ARIFPAY.APIKey, expireDate)
// Create checkout session
response, err := paymentClient.MakePayment(paymentPayload)
if err != nil { if err != nil {
return "", err return nil, fmt.Errorf("failed to marshal checkout request: %w", err)
} }
transfer := domain.CreateTransfer{ // Send request to Arifpay API
Amount: domain.Currency(req.Beneficiaries[0].Amount), url := fmt.Sprintf("%s/api/checkout/session", s.cfg.ARIFPAY.BaseURL)
Verified: false, httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(payload))
Type: domain.DEPOSIT,
ReferenceNumber: uuid.NewString(),
Status: string(domain.PaymentStatusPending),
}
if _, err := s.transferStore.CreateTransfer(context.Background(), transfer); err != nil {
return "", err
}
return response, nil
}
func (s *ArifpayService) B2CTransfer(ctx context.Context, req domain.ArifPayB2CRequest, endpoint string) (*map[string]interface{}, error) {
// endpoint := c.baseURL + "/api/Telebirr/b2c/transfer"
payloadBytes, err := json.Marshal(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal request payload: %w", err) return nil, err
} }
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(payloadBytes))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey)
resp, err := s.httpClient.Do(httpReq) resp, err := s.httpClient.Do(httpReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("request to Telebirr B2C failed: %w", err) return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body) // Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Telebirr API returned status %d: %s", resp.StatusCode, string(body)) return nil, fmt.Errorf("failed to create checkout session: %s", string(body))
} }
var response map[string]interface{} // Optionally unmarshal response to struct
if err := json.Unmarshal(body, &response); err != nil { var result map[string]interface{}
return nil, fmt.Errorf("failed to parse response body: %w", err) if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("invalid response from Arifpay: %w", err)
} }
return &response, nil data := result["data"].(map[string]interface{})
// paymentURL := data["paymentUrl"].(string)
// Store transfer in DB
transfer := domain.CreateTransfer{
Amount: domain.Currency(req.Amount),
Verified: false,
Type: domain.DEPOSIT,
ReferenceNumber: nonce,
SessionID: fmt.Sprintf("%v", data["sessionId"]),
Status: string(domain.PaymentStatusPending),
}
if _, err := s.transferStore.CreateTransfer(context.Background(), transfer); err != nil {
return nil, err
}
return data, nil
} }
func (s *ArifpayService) VerifyByTransactionID(transactionID string, paymentType int) ([]byte, error) { func (s *ArifpayService) CancelCheckoutSession(ctx context.Context, sessionID string) (*domain.CancelCheckoutSessionResponse, error) {
url := "https://gateway.arifpay.org/api/checkout/getSessionByTransactionId" // Build the cancel URL
url := fmt.Sprintf("%s/api/sandbox/checkout/session/%s", s.cfg.ARIFPAY.BaseURL, sessionID)
var payload domain.ArifpayVerifyByTransactionIDRequest // Create the request
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
bodyBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("failed to create request: %w", err)
} }
// Add headers
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) req.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey)
// Execute request
resp, err := s.httpClient.Do(req) resp, err := s.httpClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("request failed: %w", err) return nil, fmt.Errorf("failed to execute cancel request: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body) // Read response body
body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err) return nil, fmt.Errorf("failed to read cancel response: %w", err)
} }
// Handle non-200 status codes
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("non-200 response from Arifpay: %s", string(respBody)) return nil, fmt.Errorf("cancel request failed: status=%d, body=%s", resp.StatusCode, string(body))
} }
return respBody, nil // Decode into response struct
var cancelResp domain.CancelCheckoutSessionResponse
if err := json.Unmarshal(body, &cancelResp); err != nil {
return nil, fmt.Errorf("failed to unmarshal cancel response: %w", err)
}
return &cancelResp, nil
} }
func (s *ArifpayService) VerifyBySessionID(sessionID string) ([]byte, error) { func (s *ArifpayService) HandleWebhook(ctx context.Context, req domain.WebhookRequest, userId int64, isDepost bool) error {
url := "https://gateway.arifpay.org/api/ms/transaction/status/" + sessionID // 1. Get transfer by SessionID
transfer, err := s.transferStore.GetTransferByReference(ctx, req.Transaction.TransactionID)
if err != nil {
return err
}
// Create GET request without body wallets, err := s.walletSvc.GetWalletsByUser(ctx, userId)
req, err := http.NewRequest("GET", url, nil) if err != nil {
return err
}
if transfer.Verified {
return errors.New("transfer already verified")
}
// 2. Update transfer status
newStatus := req.Transaction.TransactionStatus
// if req.Transaction.TransactionStatus != "" {
// newStatus = req.Transaction.TransactionStatus
// }
err = s.transferStore.UpdateTransferStatus(ctx, transfer.ID, newStatus)
if err != nil {
return err
}
err = s.transferStore.UpdateTransferVerification(ctx, transfer.ID, true)
if err != nil {
return err
}
// 3. If SUCCESS -> update customer wallet balance
if (newStatus == "SUCCESS" && isDepost) || (newStatus == "FAILED" && !isDepost) {
_, err = s.walletSvc.AddToWallet(ctx, wallets[0].ID, domain.Currency(req.TotalAmount), domain.ValidInt64{}, transfer.PaymentMethod, domain.PaymentDetails{
ReferenceNumber: domain.ValidString{
Value: req.Transaction.TransactionID,
Valid: true,
},
BankNumber: domain.ValidString{
Value: "",
Valid: false,
},
}, "")
if err != nil {
return err
}
}
return nil
}
func (s *ArifpayService) ExecuteTelebirrB2CTransfer(ctx context.Context, req domain.ArifpayB2CRequest, userId int64) error {
// Step 1: Create Session
referenceNum := uuid.NewString()
sessionReq := domain.CheckoutSessionClientRequest{
Amount: req.Amount,
CustomerEmail: req.CustomerEmail,
CustomerPhone: req.CustomerPhone,
}
sessionResp, err := s.CreateCheckoutSession(sessionReq, false)
if err != nil {
return fmt.Errorf("failed to create session: %w", err)
}
// Step 2: Execute Transfer
transferURL := fmt.Sprintf("%s/api/Telebirr/b2c/transfer", s.cfg.ARIFPAY.BaseURL)
reqBody := map[string]any{
"Sessionid": sessionResp["sessionId"],
"Phonenumber": req.PhoneNumber,
}
payload, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("failed to marshal transfer request: %w", err)
}
transferReq, err := http.NewRequestWithContext(ctx, http.MethodPost, transferURL, bytes.NewBuffer(payload))
if err != nil {
return fmt.Errorf("failed to build transfer request: %w", err)
}
transferReq.Header.Set("Content-Type", "application/json")
transferReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey)
transferResp, err := s.httpClient.Do(transferReq)
if err != nil {
return fmt.Errorf("failed to execute transfer request: %w", err)
}
defer transferResp.Body.Close()
if transferResp.StatusCode >= 300 {
body, _ := io.ReadAll(transferResp.Body)
return fmt.Errorf("transfer failed with status %d: %s", transferResp.StatusCode, string(body))
}
// Step 3: Store transfer in DB
transfer := domain.CreateTransfer{
Amount: domain.Currency(req.Amount),
Verified: false,
Type: domain.WITHDRAW, // B2C = payout
ReferenceNumber: referenceNum,
SessionID: fmt.Sprintf("%v", sessionResp["sessionId"]),
Status: string(domain.PaymentStatusPending),
PaymentMethod: domain.TRANSFER_ARIFPAY,
}
if _, err := s.transferStore.CreateTransfer(ctx, transfer); err != nil {
return fmt.Errorf("failed to store transfer: %w", err)
}
// Step 4: Deduct from wallet
userWallets, err := s.walletSvc.GetWalletsByUser(ctx, userId)
if err != nil {
return fmt.Errorf("failed to get user wallets: %w", err)
}
if len(userWallets) == 0 {
return fmt.Errorf("no wallet found for user %d", userId)
}
_, err = s.walletSvc.DeductFromWallet(
ctx,
userWallets[0].ID,
domain.Currency(req.Amount),
domain.ValidInt64{},
domain.TRANSFER_ARIFPAY,
"",
)
if err != nil {
return fmt.Errorf("failed to deduct from wallet: %w", err)
}
return nil
}
func (s *ArifpayService) ExecuteCBEB2CTransfer(ctx context.Context, req domain.ArifpayB2CRequest, userId int64) error {
// Step 1: Create Session
referenceNum := uuid.NewString()
sessionReq := domain.CheckoutSessionClientRequest{
Amount: req.Amount,
CustomerEmail: req.CustomerEmail,
CustomerPhone: req.CustomerPhone,
}
sessionResp, err := s.CreateCheckoutSession(sessionReq, false)
if err != nil {
return fmt.Errorf("cbebirr: failed to create session: %w", err)
}
// Step 2: Execute Transfer
transferURL := fmt.Sprintf("%s/api/Cbebirr/b2c/transfer", s.cfg.ARIFPAY.BaseURL)
reqBody := map[string]any{
"Sessionid": sessionResp["sessionId"],
"Phonenumber": req.PhoneNumber,
}
payload, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("cbebirr: failed to marshal transfer request: %w", err)
}
transferReq, err := http.NewRequestWithContext(ctx, http.MethodPost, transferURL, bytes.NewBuffer(payload))
if err != nil {
return fmt.Errorf("cbebirr: failed to build transfer request: %w", err)
}
transferReq.Header.Set("Content-Type", "application/json")
transferReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey)
transferResp, err := s.httpClient.Do(transferReq)
if err != nil {
return fmt.Errorf("cbebirr: failed to execute transfer request: %w", err)
}
defer transferResp.Body.Close()
if transferResp.StatusCode >= 300 {
body, _ := io.ReadAll(transferResp.Body)
return fmt.Errorf("cbebirr: transfer failed with status %d: %s", transferResp.StatusCode, string(body))
}
// Step 3: Store transfer in DB
transfer := domain.CreateTransfer{
Amount: domain.Currency(req.Amount),
Verified: false,
Type: domain.WITHDRAW, // B2C = payout
ReferenceNumber: referenceNum,
SessionID: fmt.Sprintf("%v", sessionResp["sessionId"]),
Status: string(domain.PaymentStatusPending),
PaymentMethod: domain.TRANSFER_ARIFPAY,
}
if _, err := s.transferStore.CreateTransfer(ctx, transfer); err != nil {
return fmt.Errorf("cbebirr: failed to store transfer: %w", err)
}
// Step 4: Deduct from user wallet
userWallets, err := s.walletSvc.GetWalletsByUser(ctx, userId)
if err != nil {
return fmt.Errorf("cbebirr: failed to get user wallets: %w", err)
}
if len(userWallets) == 0 {
return fmt.Errorf("cbebirr: no wallet found for user %d", userId)
}
_, err = s.walletSvc.DeductFromWallet(
ctx,
userWallets[0].ID,
domain.Currency(req.Amount),
domain.ValidInt64{},
domain.TRANSFER_ARIFPAY,
"",
)
if err != nil {
return fmt.Errorf("cbebirr: failed to deduct from wallet: %w", err)
}
return nil
}
func (s *ArifpayService) ExecuteMPesaB2CTransfer(ctx context.Context, req domain.ArifpayB2CRequest, userId int64) error {
// Step 1: Create Session
referenceNum := uuid.NewString()
sessionReq := domain.CheckoutSessionClientRequest{
Amount: req.Amount,
CustomerEmail: req.CustomerEmail,
CustomerPhone: req.CustomerPhone,
}
sessionResp, err := s.CreateCheckoutSession(sessionReq, false)
if err != nil {
return fmt.Errorf("Mpesa: failed to create session: %w", err)
}
// Step 2: Execute Transfer
transferURL := fmt.Sprintf("%s/api/Mpesa/b2c/transfer", s.cfg.ARIFPAY.BaseURL)
reqBody := map[string]any{
"Sessionid": sessionResp["sessionId"],
"Phonenumber": req.PhoneNumber,
}
payload, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("Mpesa: failed to marshal transfer request: %w", err)
}
transferReq, err := http.NewRequestWithContext(ctx, http.MethodPost, transferURL, bytes.NewBuffer(payload))
if err != nil {
return fmt.Errorf("Mpesa: failed to build transfer request: %w", err)
}
transferReq.Header.Set("Content-Type", "application/json")
transferReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey)
transferResp, err := s.httpClient.Do(transferReq)
if err != nil {
return fmt.Errorf("Mpesa: failed to execute transfer request: %w", err)
}
defer transferResp.Body.Close()
if transferResp.StatusCode >= 300 {
body, _ := io.ReadAll(transferResp.Body)
return fmt.Errorf("Mpesa: transfer failed with status %d: %s", transferResp.StatusCode, string(body))
}
// Step 3: Store transfer in DB
transfer := domain.CreateTransfer{
Amount: domain.Currency(req.Amount),
Verified: false,
Type: domain.WITHDRAW, // B2C = payout
ReferenceNumber: referenceNum,
SessionID: fmt.Sprintf("%v", sessionResp["sessionId"]),
Status: string(domain.PaymentStatusPending),
PaymentMethod: domain.TRANSFER_ARIFPAY,
}
if _, err := s.transferStore.CreateTransfer(ctx, transfer); err != nil {
return fmt.Errorf("Mpesa: failed to store transfer: %w", err)
}
// Step 4: Deduct from user wallet
userWallets, err := s.walletSvc.GetWalletsByUser(ctx, userId)
if err != nil {
return fmt.Errorf("Mpesa: failed to get user wallets: %w", err)
}
if len(userWallets) == 0 {
return fmt.Errorf("Mpesa: no wallet found for user %d", userId)
}
_, err = s.walletSvc.DeductFromWallet(
ctx,
userWallets[0].ID,
domain.Currency(req.Amount),
domain.ValidInt64{},
domain.TRANSFER_ARIFPAY,
"",
)
if err != nil {
return fmt.Errorf("Mpesa: failed to deduct from wallet: %w", err)
}
return nil
}
func (s *ArifpayService) VerifyTransactionByTransactionID(ctx context.Context, req domain.ArifpayVerifyByTransactionIDRequest) (*domain.WebhookRequest, error) {
endpoint := fmt.Sprintf("%s/api/checkout/getSessionByTransactionId", s.cfg.ARIFPAY.BaseURL)
// Marshal request payload
bodyBytes, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Build HTTP request
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(bodyBytes))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("Content-Type", "application/json") httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey)
req.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey)
resp, err := s.httpClient.Do(req) // Execute request
resp, err := s.httpClient.Do(httpReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("request failed: %w", err) return nil, fmt.Errorf("failed to call verify transaction API: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body) // Read response body
respBytes, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err) return nil, fmt.Errorf("failed to read response: %w", err)
} }
// Handle non-200 responses
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("non-200 response from Arifpay: %s", string(respBody)) return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBytes))
} }
return respBody, nil // Decode into domain response
var result domain.WebhookRequest
if err := json.Unmarshal(respBytes, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
return &result, nil
}
func (s *ArifpayService) VerifyTransactionBySessionID(ctx context.Context, sessionID string) (*domain.WebhookRequest, error) {
endpoint := fmt.Sprintf("%s/api/ms/transaction/status/%s", s.cfg.ARIFPAY.BaseURL, sessionID)
// Create HTTP GET request
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set required headers
httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey)
// Execute request
resp, err := s.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to call verify transaction API: %w", err)
}
defer resp.Body.Close()
// Read response body
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Handle non-200 responses
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBytes))
}
// Decode into domain response
var result domain.WebhookRequest
if err := json.Unmarshal(respBytes, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
return &result, nil
}
func (s *ArifpayService) GetPaymentMethodsMapping() []domain.ARIFPAYPaymentMethod {
return []domain.ARIFPAYPaymentMethod{
{ID: 1, Name: "ACCOUNT"},
{ID: 2, Name: "NONYMOUS_ACCOUNT"},
{ID: 3, Name: "ANONYMOUS_CARD"},
{ID: 4, Name: "TELEBIRR"},
{ID: 5, Name: "AWASH"},
{ID: 6, Name: "AWASH_WALLET"},
{ID: 7, Name: "PSS"},
{ID: 8, Name: "CBE"},
{ID: 9, Name: "AMOLE"},
{ID: 10, Name: "BOA"},
{ID: 11, Name: "KACHA"},
{ID: 12, Name: "ETHSWITCH"},
{ID: 13, Name: "TELEBIRR_USSD"},
{ID: 14, Name: "HELLOCASH"},
{ID: 15, Name: "MPESSA"},
}
} }

View File

@ -5,11 +5,12 @@ import (
"time" "time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/config"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
type SantimPayClient interface { type SantimPayClient interface {
GenerateSignedToken(amount int, reason string) (string, error) GenerateSignedToken(payload domain.SantimTokenPayload) (string, error)
CheckTransactionStatus(id string) CheckTransactionStatus(id string)
} }
@ -23,17 +24,26 @@ func NewSantimPayClient(cfg *config.Config) SantimPayClient {
} }
} }
func (c *santimClient) GenerateSignedToken(amount int, reason string) (string, error) { func (c *santimClient) GenerateSignedToken(payload domain.SantimTokenPayload) (string, error) {
now := time.Now().Unix() now := time.Now().Unix()
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"amount": amount, "amount": payload.Amount,
"paymentReason": reason, "paymentReason": payload.Reason,
"merchantId": c.cfg.SANTIMPAY.MerchantID, "merchantId": c.cfg.SANTIMPAY.MerchantID,
"generated": now, "generated": now,
}
// Optional fields
if payload.PaymentMethod != "" {
claims["paymentMethod"] = payload.PaymentMethod
}
if payload.PhoneNumber != "" {
claims["phoneNumber"] = payload.PhoneNumber
} }
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
privateKey, err := jwt.ParseECPrivateKeyFromPEM([]byte(c.cfg.SANTIMPAY.SecretKey)) privateKey, err := jwt.ParseECPrivateKeyFromPEM([]byte(c.cfg.SANTIMPAY.SecretKey))
if err != nil { if err != nil {
return "", fmt.Errorf("invalid private key: %w", err) return "", fmt.Errorf("invalid private key: %w", err)
@ -47,6 +57,7 @@ func (c *santimClient) GenerateSignedToken(amount int, reason string) (string, e
return signedToken, nil return signedToken, nil
} }
func (c *santimClient) CheckTransactionStatus(id string) { func (c *santimClient) CheckTransactionStatus(id string) {
// optional async checker — can log or poll transaction status // optional async checker — can log or poll transaction status
fmt.Println("Checking transaction status for:", id) fmt.Println("Checking transaction status for:", id)

View File

@ -6,6 +6,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/config"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
@ -14,42 +16,51 @@ import (
) )
// type SantimPayService interface { // type SantimPayService interface {
// GeneratePaymentURL(input domain.GeneratePaymentURLInput) (map[string]string, error) // GeneratePaymentURL(req domain.GeneratePaymentURLreq) (map[string]string, error)
// } // }
type SantimPayService struct { type SantimPayService struct {
client SantimPayClient client SantimPayClient
cfg *config.Config cfg *config.Config
transferStore wallet.TransferStore transferStore wallet.TransferStore
walletSvc *wallet.Service
} }
func NewSantimPayService(client SantimPayClient, cfg *config.Config, transferStore wallet.TransferStore) *SantimPayService { func NewSantimPayService(client SantimPayClient, cfg *config.Config, transferStore wallet.TransferStore, walletSvc *wallet.Service) *SantimPayService {
return &SantimPayService{ return &SantimPayService{
client: client, client: client,
cfg: cfg, cfg: cfg,
transferStore: transferStore, transferStore: transferStore,
walletSvc: walletSvc,
} }
} }
func (s *SantimPayService) GeneratePaymentURL(input domain.GeneratePaymentURLInput) (map[string]string, error) { func (s *SantimPayService) InitiatePayment(req domain.GeneratePaymentURLRequest) (map[string]string, error) {
paymentID := uuid.NewString() paymentID := uuid.NewString()
token, err := s.client.GenerateSignedToken(input.Amount, input.Reason) tokenPayload := domain.SantimTokenPayload{
Amount: req.Amount,
Reason: req.Reason,
}
// 1. Generate signed token (used as Bearer token in headers)
token, err := s.client.GenerateSignedToken(tokenPayload)
if err != nil { if err != nil {
return nil, fmt.Errorf("token generation failed: %w", err) return nil, fmt.Errorf("token generation failed: %w", err)
} }
payload := domain.InitiatePaymentPayload{ // 2. Prepare payload (without token in body)
payload := domain.InitiatePaymentRequest{
ID: paymentID, ID: paymentID,
Amount: input.Amount, Amount: req.Amount,
Reason: input.Reason, Reason: req.Reason,
MerchantID: s.cfg.SANTIMPAY.MerchantID, MerchantID: s.cfg.SANTIMPAY.MerchantID,
SuccessRedirectURL: s.cfg.SANTIMPAY.SuccessUrl,
FailureRedirectURL: s.cfg.SANTIMPAY.CancelUrl,
NotifyURL: s.cfg.SANTIMPAY.NotifyURL,
CancelRedirectURL: s.cfg.SANTIMPAY.CancelUrl,
PhoneNumber: req.PhoneNumber,
SignedToken: token, 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) jsonData, err := json.Marshal(payload)
@ -57,7 +68,16 @@ func (s *SantimPayService) GeneratePaymentURL(input domain.GeneratePaymentURLInp
return nil, fmt.Errorf("failed to marshal payload: %w", err) 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)) // 3. Prepare request with Bearer token header
httpReq, err := http.NewRequest("POST", s.cfg.SANTIMPAY.BaseURL+"/gateway/initiate-payment", bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(httpReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to send HTTP request: %w", err) return nil, fmt.Errorf("failed to send HTTP request: %w", err)
} }
@ -72,21 +92,324 @@ func (s *SantimPayService) GeneratePaymentURL(input domain.GeneratePaymentURLInp
return nil, fmt.Errorf("failed to decode response: %w", err) return nil, fmt.Errorf("failed to decode response: %w", err)
} }
// Save transfer // 4. Save transfer
transfer := domain.CreateTransfer{ transfer := domain.CreateTransfer{
Amount: domain.Currency(input.Amount), Amount: domain.Currency(req.Amount),
Verified: false, Verified: false,
Type: domain.DEPOSIT, Type: domain.DEPOSIT,
ReferenceNumber: paymentID, ReferenceNumber: paymentID,
Status: string(domain.PaymentStatusPending), Status: string(domain.PaymentStatusPending),
} }
if _, err := s.transferStore.CreateTransfer(context.Background(), transfer); err != nil { if _, err := s.transferStore.CreateTransfer(context.Background(), transfer); err != nil {
return nil, fmt.Errorf("failed to create transfer: %w", err) return nil, fmt.Errorf("failed to create transfer: %w", err)
} }
// Optionally check transaction status in a goroutine // 5. Optionally check transaction status asynchronously
go s.client.CheckTransactionStatus(paymentID) // go s.client.CheckTransactionStatus(paymentID)
return responseBody, nil
}
func (s *SantimPayService) ProcessCallback(ctx context.Context, payload domain.SantimPayCallbackPayload) error {
// 1. Parse amount
amount, err := strconv.ParseFloat(payload.Amount, 64)
if err != nil {
return fmt.Errorf("invalid amount in callback: %w", err)
}
// 2. Retrieve the corresponding transfer by txnId or refId
transfer, err := s.transferStore.GetTransferByReference(ctx, payload.TxnId)
if err != nil {
return fmt.Errorf("failed to fetch transfer for txnId %s: %w", payload.TxnId, err)
}
// 3. Update transfer status based on callback status
switch payload.Status {
case "COMPLETED":
transfer.Status = string(domain.PaymentStatusSuccessful)
transfer.Verified = true
userID, err := strconv.ParseInt(payload.ThirdPartyId, 10, 64)
if err != nil {
return fmt.Errorf("invalid ThirdPartyId '%s': %w", payload.ThirdPartyId, err)
}
wallets, err := s.walletSvc.GetWalletsByUser(ctx, userID)
if err != nil {
return fmt.Errorf("failed to get wallets for user %d: %w", userID, err)
}
// Optionally, credit user wallet
if transfer.Type == domain.DEPOSIT {
if _, err := s.walletSvc.AddToWallet(
ctx,
wallets[0].ID,
domain.Currency(amount),
domain.ValidInt64{},
domain.TRANSFER_SANTIMPAY,
domain.PaymentDetails{
ReferenceNumber: domain.ValidString{
Value: payload.TxnId,
Valid: true,
},
BankNumber: domain.ValidString{},
},
"",
); err != nil {
return fmt.Errorf("failed to credit wallet: %w", err)
}
}
case "FAILED", "CANCELLED":
transfer.Status = string(domain.PaymentStatusFailed)
transfer.Verified = false
default:
// Unknown status
return fmt.Errorf("unknown callback status: %s", payload.Status)
}
// 4. Save the updated transfer
if err := s.transferStore.UpdateTransferStatus(ctx, transfer.ID, string(domain.PaymentStatusCompleted)); err != nil {
return fmt.Errorf("failed to update transfer status: %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
}
func (s *SantimPayService) ProcessDirectPayment(ctx context.Context, req domain.GeneratePaymentURLRequest) (map[string]any, error) {
paymentID := uuid.NewString()
tokenPayload := domain.SantimTokenPayload{
Amount: req.Amount,
Reason: req.Reason,
PaymentMethod: req.PaymentMethod,
PhoneNumber: req.PhoneNumber,
}
// 1. Generate signed token for direct payment
token, err := s.client.GenerateSignedToken(tokenPayload)
if err != nil {
return nil, fmt.Errorf("failed to generate signed token: %w", err)
}
// 2. Build payload
payload := domain.InitiatePaymentRequest{
ID: paymentID,
Amount: req.Amount,
Reason: req.Reason,
MerchantID: s.cfg.SANTIMPAY.MerchantID,
SignedToken: token,
PhoneNumber: req.PhoneNumber,
NotifyURL: s.cfg.SANTIMPAY.NotifyURL,
PaymentMethod: req.PaymentMethod,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal payload: %w", err)
}
// 3. Prepare HTTP request
httpReq, err := http.NewRequest("POST", s.cfg.SANTIMPAY.BaseURL+"/direct-payment", bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(httpReq)
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)
}
// 4. Decode response
var responseBody map[string]any
if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
// 5. Save transfer in DB
transfer := domain.CreateTransfer{
Amount: domain.Currency(req.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)
}
// 6. Optionally check transaction status async
// go s.client.CheckTransactionStatus(paymentID)
return responseBody, nil
}
func (s *SantimPayService) GetB2CPartners(ctx context.Context) (*domain.B2CPartnersResponse, error) {
url := fmt.Sprintf("%s/api/v1/gateway/payout/partners", s.cfg.SANTIMPAY.BaseURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
HTTPClient := &http.Client{Timeout: 15 * time.Second}
resp, err := HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call SantimPay API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var partnersResp domain.B2CPartnersResponse
if err := json.NewDecoder(resp.Body).Decode(&partnersResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &partnersResp, nil
}
func (s *SantimPayService) ProcessB2CWithdrawal(ctx context.Context, req domain.GeneratePaymentURLRequest, userId int64) (map[string]any, error) {
transactID := uuid.NewString()
// 1. Generate signed token for B2C
tokenPayload := domain.SantimTokenPayload{
Amount: req.Amount,
Reason: req.Reason,
PaymentMethod: req.PaymentMethod,
PhoneNumber: req.PhoneNumber,
}
signedToken, err := s.client.GenerateSignedToken(tokenPayload)
if err != nil {
return nil, fmt.Errorf("failed to generate signed token for B2C: %w", err)
}
// 2. Build payload
payload := domain.SantimpayB2CWithdrawalRequest{
ID: transactID,
ClientReference: string(rune(userId)),
Amount: float64(req.Amount),
Reason: req.Reason,
MerchantID: s.cfg.SANTIMPAY.MerchantID,
SignedToken: signedToken,
ReceiverAccountNumber: req.PhoneNumber,
NotifyURL: s.cfg.SANTIMPAY.NotifyURL,
PaymentMethod: req.PaymentMethod,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal B2C payload: %w", err)
}
// 3. Send HTTP request
url := s.cfg.SANTIMPAY.BaseURL + "/payout-transfer"
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create B2C request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+signedToken)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to send B2C request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("B2C request failed with status code: %d", resp.StatusCode)
}
// 4. Decode response
var responseBody map[string]any
if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil {
return nil, fmt.Errorf("failed to decode B2C response: %w", err)
}
// 5. Persist withdrawal record in DB
withdrawal := domain.CreateTransfer{
Amount: domain.Currency(req.Amount),
Verified: false,
Type: domain.WITHDRAW,
ReferenceNumber: transactID,
Status: string(domain.PaymentStatusPending),
}
if _, err := s.transferStore.CreateTransfer(context.Background(), withdrawal); err != nil {
return nil, fmt.Errorf("failed to create withdrawal transfer: %w", err)
}
return responseBody, nil
}
func (s *SantimPayService) CheckTransactionStatus(ctx context.Context, req domain.TransactionStatusRequest) (map[string]any, error) {
// 1. Generate signed token for status check
tokenPayload := domain.SantimTokenPayload{
ID: req.TransactionID,
}
signedToken, err := s.client.GenerateSignedToken(tokenPayload)
if err != nil {
return nil, fmt.Errorf("failed to generate signed token for transaction status: %w", err)
}
// 2. Build request payload
payload := map[string]any{
"id": req.TransactionID,
"merchantId": s.cfg.SANTIMPAY.MerchantID,
"signedToken": signedToken,
"fullParams": req.FullParams,
"generated": time.Now().Unix(),
}
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal transaction status payload: %w", err)
}
// 3. Send HTTP request
url := s.cfg.SANTIMPAY.BaseURL + "/fetch-transaction-status"
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create transaction status request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+signedToken)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to send transaction status request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("transaction status request failed with status code: %d", resp.StatusCode)
}
// 4. Decode response
var responseBody map[string]any
if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil {
return nil, fmt.Errorf("failed to decode transaction status response: %w", err)
}
return responseBody, nil return responseBody, nil
} }

View File

@ -39,7 +39,7 @@ func NewClient(cfg *config.Config, walletSvc *wallet.Service) *Client {
} }
// Signature generator // Signature generator
func (c *Client) generateSignature(params map[string]string) (string, error) { func (c *Client) generateSignature(params map[string]any) (string, error) {
keys := make([]string, 0, len(params)) keys := make([]string, 0, len(params))
for k := range params { for k := range params {
keys = append(keys, k) keys = append(keys, k)
@ -47,11 +47,31 @@ func (c *Client) generateSignature(params map[string]string) (string, error) {
sort.Strings(keys) sort.Strings(keys)
var b strings.Builder var b strings.Builder
for i, k := range keys { first := true
if i > 0 {
appendKV := func(_ string, value string) {
if !first {
b.WriteString(";") b.WriteString(";")
} }
b.WriteString(fmt.Sprintf("%s:%s", k, params[k])) b.WriteString(value)
first = false
}
for _, k := range keys {
v := params[k]
switch val := v.(type) {
case []string:
for i, item := range val {
appendKV(k, fmt.Sprintf("%s:%d:%s", k, i, item))
}
case []any:
for i, item := range val {
appendKV(k, fmt.Sprintf("%s:%d:%v", k, i, item))
}
default:
appendKV(k, fmt.Sprintf("%s:%v", k, val))
}
} }
fmt.Println("String being signed:", b.String()) fmt.Println("String being signed:", b.String())
@ -63,12 +83,12 @@ func (c *Client) generateSignature(params map[string]string) (string, error) {
signature := base64.StdEncoding.EncodeToString(hash) signature := base64.StdEncoding.EncodeToString(hash)
fmt.Println("Generated signature:", signature) fmt.Println("Generated signature:", signature)
return fmt.Sprintf("%s:%s", c.OperatorID, signature), nil return fmt.Sprintf("%s:%s", c.OperatorID, signature), nil
// return fmt.Sprintf("%s:%s", c.OperatorID, base64.StdEncoding.EncodeToString(h.Sum(nil))), nil
} }
// POST helper // POST helper
func (c *Client) post(ctx context.Context, path string, body any, sigParams map[string]string, result any) error { func (c *Client) post(ctx context.Context, path string, body any, sigParams map[string]any, result any) error {
data, _ := json.Marshal(body) data, _ := json.Marshal(body)
sig, err := c.generateSignature(sigParams) sig, err := c.generateSignature(sigParams)
if err != nil { if err != nil {

View File

@ -17,4 +17,5 @@ type VeliVirtualGameService interface {
ProcessWin(ctx context.Context, req domain.WinRequest) (*domain.WinResponse, error) ProcessWin(ctx context.Context, req domain.WinRequest) (*domain.WinResponse, error)
ProcessCancel(ctx context.Context, req domain.CancelRequest) (*domain.CancelResponse, error) ProcessCancel(ctx context.Context, req domain.CancelRequest) (*domain.CancelResponse, error)
GetGamingActivity(ctx context.Context, req domain.GamingActivityRequest) (*domain.GamingActivityResponse, error) GetGamingActivity(ctx context.Context, req domain.GamingActivityRequest) (*domain.GamingActivityResponse, error)
GetHugeWins(ctx context.Context, req domain.HugeWinsRequest) (*domain.HugeWinsResponse, error)
} }

View File

@ -5,9 +5,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
) )
var ( var (
@ -18,18 +19,22 @@ var (
) )
type service struct { type service struct {
client *Client client *Client
walletSvc *wallet.Service
cfg *config.Config
} }
func New(client *Client) VeliVirtualGameService { func New(client *Client, walletSvc *wallet.Service, cfg *config.Config) VeliVirtualGameService {
return &service{ return &service{
client: client, client: client,
walletSvc: walletSvc,
cfg: cfg,
} }
} }
func (s *service) GetProviders(ctx context.Context, req domain.ProviderRequest) (*domain.ProviderResponse, error) { func (s *service) GetProviders(ctx context.Context, req domain.ProviderRequest) (*domain.ProviderResponse, error) {
// Always mirror request body fields into sigParams // Always mirror request body fields into sigParams
sigParams := map[string]string{ sigParams := map[string]any{
"brandId": req.BrandID, "brandId": req.BrandID,
} }
@ -51,9 +56,8 @@ func (s *service) GetProviders(ctx context.Context, req domain.ProviderRequest)
return &res, err return &res, err
} }
func (s *service) GetGames(ctx context.Context, req domain.GameListRequest) ([]domain.GameEntity, error) { func (s *service) GetGames(ctx context.Context, req domain.GameListRequest) ([]domain.GameEntity, error) {
sigParams := map[string]string{ sigParams := map[string]any{
"brandId": req.BrandID, "providerId": req.ProviderID, "brandId": req.BrandID, "providerId": req.ProviderID,
} }
var res struct { var res struct {
@ -64,10 +68,10 @@ func (s *service) GetGames(ctx context.Context, req domain.GameListRequest) ([]d
} }
func (s *service) StartGame(ctx context.Context, req domain.GameStartRequest) (*domain.GameStartResponse, error) { func (s *service) StartGame(ctx context.Context, req domain.GameStartRequest) (*domain.GameStartResponse, error) {
sigParams := map[string]string{ sigParams := map[string]any{
"sessionId": req.SessionID, "providerId": req.ProviderID, "sessionId": req.SessionID, "providerId": req.ProviderID,
"gameId": req.GameID, "language": req.Language, "playerId": req.PlayerID, "gameId": req.GameID, "language": req.Language, "playerId": req.PlayerID,
"currency": req.Currency, "deviceType": req.DeviceType, "country": req.Country, "currency": req.Currency, "deviceType": req.DeviceType, "country": "US",
"ip": req.IP, "brandId": req.BrandID, "ip": req.IP, "brandId": req.BrandID,
} }
var res domain.GameStartResponse var res domain.GameStartResponse
@ -76,7 +80,7 @@ func (s *service) StartGame(ctx context.Context, req domain.GameStartRequest) (*
} }
func (s *service) StartDemoGame(ctx context.Context, req domain.DemoGameRequest) (*domain.GameStartResponse, error) { func (s *service) StartDemoGame(ctx context.Context, req domain.DemoGameRequest) (*domain.GameStartResponse, error) {
sigParams := map[string]string{ sigParams := map[string]any{
"providerId": req.ProviderID, "gameId": req.GameID, "providerId": req.ProviderID, "gameId": req.GameID,
"language": req.Language, "deviceType": req.DeviceType, "language": req.Language, "deviceType": req.DeviceType,
"ip": req.IP, "brandId": req.BrandID, "ip": req.IP, "brandId": req.BrandID,
@ -87,156 +91,347 @@ func (s *service) StartDemoGame(ctx context.Context, req domain.DemoGameRequest)
} }
func (s *service) GetBalance(ctx context.Context, req domain.BalanceRequest) (*domain.BalanceResponse, error) { func (s *service) GetBalance(ctx context.Context, req domain.BalanceRequest) (*domain.BalanceResponse, error) {
sigParams := map[string]string{ // Retrieve player's real balance from wallet service
"sessionId": req.SessionID, playerIDInt64, err := strconv.ParseInt(req.PlayerID, 10, 64)
"providerId": req.ProviderID, if err != nil {
"playerId": req.PlayerID, return nil, fmt.Errorf("invalid PlayerID: %w", err)
"currency": req.Currency,
"brandId": req.BrandID,
} }
if req.GameID != "" { playerWallets, err := s.walletSvc.GetWalletsByUser(ctx, playerIDInt64)
sigParams["gameId"] = req.GameID if err != nil {
return nil, fmt.Errorf("failed to get real balance: %w", err)
}
if len(playerWallets) == 0 {
return nil, fmt.Errorf("PLAYER_NOT_FOUND: no wallet found for player %s", req.PlayerID)
} }
var res domain.BalanceResponse realBalance := playerWallets[0].Balance
err := s.client.post(ctx, "/balance", req, sigParams, &res)
return &res, err // Retrieve bonus balance if applicable
var bonusBalance float64
if len(playerWallets) > 1 {
bonusBalance = float64(playerWallets[1].Balance)
} else {
bonusBalance = 0
}
// Build the response
res := &domain.BalanceResponse{
Real: struct {
Currency string `json:"currency"`
Amount float64 `json:"amount"`
}{
Currency: req.Currency,
Amount: float64(realBalance),
},
}
if bonusBalance > 0 {
res.Bonus = &struct {
Currency string `json:"currency"`
Amount float64 `json:"amount"`
}{
Currency: req.Currency,
Amount: bonusBalance,
}
}
return res, nil
} }
func (s *service) ProcessBet(ctx context.Context, req domain.BetRequest) (*domain.BetResponse, error) { func (s *service) ProcessBet(ctx context.Context, req domain.BetRequest) (*domain.BetResponse, error) {
sigParams := map[string]string{ // --- 1. Validate PlayerID ---
"sessionId": req.SessionID,
"providerId": req.ProviderID,
"playerId": req.PlayerID,
"currency": req.Amount.Currency,
"brandId": req.BrandID,
"gameId": req.GameID,
"roundId": req.RoundID,
"transactionId": req.TransactionID,
"correlationId": req.CorrelationID,
}
if req.GameType != "" {
sigParams["gameType"] = req.GameType
}
if req.IsAdjustment {
sigParams["isAdjustment"] = "true"
}
if req.JackpotID != "" {
sigParams["jackpotId"] = req.JackpotID
sigParams["jackpotContribution"] = fmt.Sprintf("%.2f", req.JackpotContribution)
}
var res domain.BetResponse
err := s.client.post(ctx, "/bet", req, sigParams, &res)
playerIDInt64, err := strconv.ParseInt(req.PlayerID, 10, 64) playerIDInt64, err := strconv.ParseInt(req.PlayerID, 10, 64)
if err != nil { if err != nil {
return &domain.BetResponse{}, fmt.Errorf("invalid PlayerID: %w", err) return nil, fmt.Errorf("BAD_REQUEST: invalid PlayerID %s", req.PlayerID)
} }
wallets, err := s.client.walletSvc.GetWalletsByUser(ctx, playerIDInt64)
// // --- 2. Validate session (optional, if you have sessionSvc) ---
// sessionValid, expired, err := s.sessionSvc.ValidateSession(ctx, req.SessionID, req.PlayerID)
// if err != nil {
// return nil, fmt.Errorf("session validation failed")
// }
// if !sessionValid {
// if expired {
// return nil, fmt.Errorf("SESSION_EXPIRED: session %s expired", req.SessionID)
// }
// return nil, fmt.Errorf("SESSION_NOT_FOUND: session %s not found", req.SessionID)
// }
// --- 3. Get player wallets ---
playerWallets, err := s.walletSvc.GetWalletsByUser(ctx, playerIDInt64)
if err != nil { if err != nil {
return &domain.BetResponse{}, err return nil, fmt.Errorf("failed to get real balance: %w", err)
}
if len(playerWallets) == 0 {
return nil, fmt.Errorf("no wallets found for player %s", req.PlayerID)
} }
s.client.walletSvc.DeductFromWallet(ctx, wallets[0].ID, domain.Currency(req.Amount.Amount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, realWallet := playerWallets[0]
fmt.Sprintf("Deducting %v from wallet for creating Veli Game Bet", req.Amount.Amount), realBalance := float64(realWallet.Balance)
)
return &res, err var bonusBalance float64
if len(playerWallets) > 1 {
bonusBalance = float64(playerWallets[1].Balance)
}
// --- 4. Check sufficient balance ---
totalBalance := realBalance + bonusBalance
if totalBalance < req.Amount.Amount {
return nil, fmt.Errorf("INSUFFICIENT_BALANCE")
}
// --- 5. Deduct funds (bonus first, then real) ---
remaining := req.Amount.Amount
var usedBonus, usedReal float64
if bonusBalance > 0 {
if bonusBalance >= remaining {
// fully cover from bonus
usedBonus = remaining
bonusBalance -= remaining
remaining = 0
} else {
// partially cover from bonus
usedBonus = bonusBalance
remaining -= bonusBalance
bonusBalance = 0
}
}
if remaining > 0 {
if realBalance >= remaining {
usedReal = remaining
realBalance -= remaining
remaining = 0
} else {
// should never happen because of totalBalance check
return nil, fmt.Errorf("INSUFFICIENT_BALANCE")
}
}
// --- 6. Persist wallet deductions ---
if usedBonus > 0 && len(playerWallets) > 1 {
_, err = s.walletSvc.DeductFromWallet(ctx, playerWallets[1].ID,
domain.Currency(usedBonus),
domain.ValidInt64{},
domain.TRANSFER_DIRECT,
fmt.Sprintf("Deduct bonus %.2f for bet %s", usedBonus, req.TransactionID),
)
if err != nil {
return nil, fmt.Errorf("bonus deduction failed: %w", err)
}
}
if usedReal > 0 {
_, err = s.walletSvc.DeductFromWallet(ctx, realWallet.ID,
domain.Currency(usedReal),
domain.ValidInt64{},
domain.TRANSFER_DIRECT,
fmt.Sprintf("Deduct real %.2f for bet %s", usedReal, req.TransactionID),
)
if err != nil {
return nil, fmt.Errorf("real deduction failed: %w", err)
}
}
// --- 7. Build response ---
res := &domain.BetResponse{
Real: domain.BalanceDetail{
Currency: "ETB",
Amount: realBalance,
},
WalletTransactionID: req.TransactionID,
UsedRealAmount: usedReal,
UsedBonusAmount: usedBonus,
}
if bonusBalance > 0 {
res.Bonus = &domain.BalanceDetail{
Currency: "ETB",
Amount: bonusBalance,
}
}
return res, nil
} }
func (s *service) ProcessWin(ctx context.Context, req domain.WinRequest) (*domain.WinResponse, error) { func (s *service) ProcessWin(ctx context.Context, req domain.WinRequest) (*domain.WinResponse, error) {
sigParams := map[string]string{ // --- 1. Validate PlayerID ---
"sessionId": req.SessionID,
"providerId": req.ProviderID,
"playerId": req.PlayerID,
"currency": req.Amount.Currency,
"brandId": req.BrandID,
"gameId": req.GameID,
"roundId": req.RoundID,
"transactionId": req.TransactionID,
"correlationId": req.CorrelationID,
"winType": req.WinType,
}
if req.GameType != "" {
sigParams["gameType"] = req.GameType
}
if req.RewardID != "" {
sigParams["rewardId"] = req.RewardID
}
if req.IsCashOut {
sigParams["isCashOut"] = "true"
}
var res domain.WinResponse
err := s.client.post(ctx, "/win", req, sigParams, &res)
playerIDInt64, err := strconv.ParseInt(req.PlayerID, 10, 64) playerIDInt64, err := strconv.ParseInt(req.PlayerID, 10, 64)
if err != nil { if err != nil {
return &domain.WinResponse{}, fmt.Errorf("invalid PlayerID: %w", err) return nil, fmt.Errorf("BAD_REQUEST: invalid PlayerID %s", req.PlayerID)
} }
wallets, err := s.client.walletSvc.GetWalletsByUser(ctx, playerIDInt64) // --- 2. Get player wallets ---
playerWallets, err := s.walletSvc.GetWalletsByUser(ctx, playerIDInt64)
if err != nil { if err != nil {
return &domain.WinResponse{}, err return nil, fmt.Errorf("failed to get wallets: %w", err)
}
if len(playerWallets) == 0 {
return nil, fmt.Errorf("PLAYER_NOT_FOUND: no wallets for player %s", req.PlayerID)
} }
s.client.walletSvc.AddToWallet(ctx, wallets[0].ID, domain.Currency(req.Amount.Amount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, realWallet := playerWallets[0]
fmt.Sprintf("Adding %v to wallet due to winning Veli Games bet", req.Amount), realBalance := float64(realWallet.Balance)
)
return &res, err var bonusBalance float64
if len(playerWallets) > 1 {
bonusBalance = float64(playerWallets[1].Balance)
}
// --- 3. Apply winnings (for now, everything goes to real wallet) ---
winAmount := req.Amount.Amount
usedReal := winAmount
usedBonus := 0.0
// TODO: If you want to split between bonus/real (e.g. free spins),
// you can extend logic here based on req.WinType / req.RewardID.
_, err = s.walletSvc.AddToWallet(
ctx,
realWallet.ID,
domain.Currency(winAmount),
domain.ValidInt64{},
domain.TRANSFER_DIRECT,
domain.PaymentDetails{},
fmt.Sprintf("Win %.2f for transaction %s", winAmount, req.TransactionID),
)
if err != nil {
return nil, fmt.Errorf("failed to credit real wallet: %w", err)
}
// --- 4. Reload balances after credit ---
updatedWallets, err := s.walletSvc.GetWalletsByUser(ctx, playerIDInt64)
if err != nil {
return nil, fmt.Errorf("failed to reload balances: %w", err)
}
updatedReal := updatedWallets[0]
realBalance = float64(updatedReal.Balance)
if len(updatedWallets) > 1 {
bonusBalance = float64(updatedWallets[1].Balance)
}
// --- 5. Build response ---
res := &domain.WinResponse{
Real: domain.BalanceDetail{
Currency: req.Amount.Currency,
Amount: realBalance,
},
WalletTransactionID: req.TransactionID,
UsedRealAmount: usedReal,
UsedBonusAmount: usedBonus,
}
if bonusBalance > 0 {
res.Bonus = &domain.BalanceDetail{
Currency: req.Amount.Currency,
Amount: bonusBalance,
}
}
return res, nil
} }
func (s *service) ProcessCancel(ctx context.Context, req domain.CancelRequest) (*domain.CancelResponse, error) { func (s *service) ProcessCancel(ctx context.Context, req domain.CancelRequest) (*domain.CancelResponse, error) {
sigParams := map[string]string{ // --- 1. Validate PlayerID ---
"sessionId": req.SessionID,
"providerId": req.ProviderID,
"playerId": req.PlayerID,
"brandId": req.BrandID,
"gameId": req.GameID,
"roundId": req.RoundID,
"transactionId": req.TransactionID,
"cancelType": req.CancelType,
}
if req.GameType != "" {
sigParams["gameType"] = req.GameType
}
if req.CorrelationID != "" {
sigParams["correlationId"] = req.CorrelationID
}
if req.RefTransactionID != "" {
sigParams["refTransactionId"] = req.RefTransactionID
}
if req.AdjustmentRefund.Amount > 0 {
sigParams["adjustmentRefundAmount"] = fmt.Sprintf("%.2f", req.AdjustmentRefund.Amount)
sigParams["adjustmentRefundCurrency"] = req.AdjustmentRefund.Currency
}
var res domain.CancelResponse
err := s.client.post(ctx, "/cancel", req, sigParams, &res)
playerIDInt64, err := strconv.ParseInt(req.PlayerID, 10, 64) playerIDInt64, err := strconv.ParseInt(req.PlayerID, 10, 64)
if err != nil { if err != nil {
return &domain.CancelResponse{}, fmt.Errorf("invalid PlayerID: %w", err) return nil, fmt.Errorf("invalid PlayerID %s", req.PlayerID)
} }
wallets, err := s.client.walletSvc.GetWalletsByUser(ctx, playerIDInt64) // --- 2. Get player wallets ---
playerWallets, err := s.walletSvc.GetWalletsByUser(ctx, playerIDInt64)
if err != nil { if err != nil {
return &domain.CancelResponse{}, err return nil, fmt.Errorf("failed to get wallets: %w", err)
}
if len(playerWallets) == 0 {
return nil, fmt.Errorf("no wallets for player %s", req.PlayerID)
} }
s.client.walletSvc.AddToWallet(ctx, wallets[0].ID, domain.Currency(req.AdjustmentRefund.Amount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, realWallet := playerWallets[0]
fmt.Sprintf("Adding %v to wallet due to cancelling virtual game bet", req.AdjustmentRefund.Amount), realBalance := float64(realWallet.Balance)
var bonusBalance float64
if len(playerWallets) > 1 {
bonusBalance = float64(playerWallets[1].Balance)
}
// --- 3. Refund handling ---
var refundAmount float64
if req.AdjustmentRefund.Amount > 0 {
refundAmount = req.AdjustmentRefund.Amount
} else {
// If cancelType = CANCEL_BET and no explicit adjustmentRefund,
// we may need to look up the original bet transaction and refund that.
// TODO: implement transaction lookup if required by your domain.
return nil, fmt.Errorf("missing adjustmentRefund for CANCEL_BET")
}
// For now, we assume refund goes back fully to real wallet
usedReal := refundAmount
usedBonus := 0.0
_, err = s.walletSvc.AddToWallet(
ctx,
realWallet.ID,
domain.Currency(refundAmount),
domain.ValidInt64{},
domain.TRANSFER_DIRECT,
domain.PaymentDetails{
ReferenceNumber: domain.ValidString{
Value: req.TransactionID,
Valid: true,
},
BankNumber: domain.ValidString{},
},
fmt.Sprintf("Cancel %s refunded %.2f for transaction %s", req.CancelType, refundAmount, req.RefTransactionID),
) )
return &res, err if err != nil {
return nil, fmt.Errorf("failed to refund wallet: %w", err)
}
// --- 4. Reload balances after refund ---
updatedWallets, err := s.walletSvc.GetWalletsByUser(ctx, playerIDInt64)
if err != nil {
return nil, fmt.Errorf("failed to reload balances: %w", err)
}
updatedReal := updatedWallets[0]
realBalance = float64(updatedReal.Balance)
if len(updatedWallets) > 1 {
bonusBalance = float64(updatedWallets[1].Balance)
}
// --- 5. Build response ---
res := &domain.CancelResponse{
WalletTransactionID: req.TransactionID,
Real: domain.BalanceDetail{
Currency: req.AdjustmentRefund.Currency,
Amount: realBalance,
},
UsedRealAmount: usedReal,
UsedBonusAmount: usedBonus,
}
if bonusBalance > 0 {
res.Bonus = &domain.BalanceDetail{
Currency: req.AdjustmentRefund.Currency,
Amount: bonusBalance,
}
}
return res, nil
} }
func (s *service) GetGamingActivity(ctx context.Context, req domain.GamingActivityRequest) (*domain.GamingActivityResponse, error) { func (s *service) GetGamingActivity(ctx context.Context, req domain.GamingActivityRequest) (*domain.GamingActivityResponse, error) {
// Prepare signature parameters (sorted string map of non-nested fields) // --- Signature Params (flattened strings for signing) ---
sigParams := map[string]string{ sigParams := map[string]any{
"fromDate": req.FromDate, "fromDate": req.FromDate,
"toDate": req.ToDate, "toDate": req.ToDate,
"brandId": req.BrandID, "brandId": s.cfg.VeliGames.BrandID,
} }
// Optional filters // Optional filters
@ -244,28 +439,77 @@ func (s *service) GetGamingActivity(ctx context.Context, req domain.GamingActivi
sigParams["providerId"] = req.ProviderID sigParams["providerId"] = req.ProviderID
} }
if len(req.PlayerIDs) > 0 { if len(req.PlayerIDs) > 0 {
sigParams["playerIds"] = strings.Join(req.PlayerIDs, ",") sigParams["playerIds"] = req.PlayerIDs // pass as []string, not joined
} }
if len(req.GameIDs) > 0 { if len(req.GameIDs) > 0 {
sigParams["gameIds"] = strings.Join(req.GameIDs, ",") sigParams["gameIds"] = req.GameIDs // pass as []string
} }
if len(req.Currencies) > 0 { if len(req.Currencies) > 0 {
sigParams["currencies"] = strings.Join(req.Currencies, ",") sigParams["currencies"] = req.Currencies // pass as []string
} }
if req.Page > 0 { if req.Page > 0 {
sigParams["page"] = fmt.Sprintf("%d", req.Page) sigParams["page"] = req.Page
} else {
sigParams["page"] = 1
req.Page = 1
} }
if req.Size > 0 { if req.Size > 0 {
sigParams["size"] = fmt.Sprintf("%d", req.Size) sigParams["size"] = req.Size
} else {
sigParams["size"] = 100
req.Size = 100
} }
if req.ExcludeFreeWin != nil { if req.ExcludeFreeWin != nil {
sigParams["excludeFreeWin"] = fmt.Sprintf("%t", *req.ExcludeFreeWin) sigParams["excludeFreeWin"] = *req.ExcludeFreeWin
} }
// --- Actual API Call ---
var res domain.GamingActivityResponse var res domain.GamingActivityResponse
err := s.client.post(ctx, "/report-api/public/gaming-activity", req, sigParams, &res) err := s.client.post(ctx, "/report-api/public/gaming-activity", req, sigParams, &res)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// --- Return parsed response ---
return &res, nil
}
func (s *service) GetHugeWins(ctx context.Context, req domain.HugeWinsRequest) (*domain.HugeWinsResponse, error) {
// --- Signature Params (flattened strings for signing) ---
sigParams := map[string]any{
"fromDate": req.FromDate,
"toDate": req.ToDate,
"brandId": req.BrandID,
}
if req.ProviderID != "" {
sigParams["providerId"] = req.ProviderID
}
if len(req.GameIDs) > 0 {
sigParams["gameIds"] = req.GameIDs // pass slice directly
}
if len(req.Currencies) > 0 {
sigParams["currencies"] = req.Currencies // pass slice directly
}
if req.Page > 0 {
sigParams["page"] = req.Page
} else {
sigParams["page"] = 1
req.Page = 1
}
if req.Size > 0 {
sigParams["size"] = req.Size
} else {
sigParams["size"] = 100
req.Size = 100
}
// --- Actual API Call ---
var res domain.HugeWinsResponse
err := s.client.post(ctx, "/report-api/public/gaming-activity/huge-wins", req, sigParams, &res)
if err != nil {
return nil, err
}
return &res, nil return &res, nil
} }

View File

@ -1,8 +1,6 @@
package handlers package handlers
import ( import (
"encoding/json"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@ -14,13 +12,13 @@ import (
// @Tags Arifpay // @Tags Arifpay
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body domain.CreateCheckoutSessionRequest true "Checkout session request payload" // @Param request body domain.CheckoutSessionClientRequest true "Checkout session request payload"
// @Success 200 {object} domain.Response // @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse // @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/arifpay/checkout [post] // @Router /api/v1/arifpay/checkout [post]
func (h *Handler) CreateCheckoutSessionHandler(c *fiber.Ctx) error { func (h *Handler) CreateCheckoutSessionHandler(c *fiber.Ctx) error {
var req domain.CreateCheckoutSessionRequest var req domain.CheckoutSessionClientRequest
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(), Error: err.Error(),
@ -28,7 +26,7 @@ func (h *Handler) CreateCheckoutSessionHandler(c *fiber.Ctx) error {
}) })
} }
paymentURL, err := h.arifpaySvc.CreateCheckoutSession(req) data, err := h.arifpaySvc.CreateCheckoutSession(req, true)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(), Error: err.Error(),
@ -38,62 +36,138 @@ func (h *Handler) CreateCheckoutSessionHandler(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(domain.Response{ return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Checkout session created successfully", Message: "Checkout session created successfully",
Data: paymentURL, Data: data,
Success: true, Success: true,
StatusCode: fiber.StatusOK, StatusCode: fiber.StatusOK,
}) })
} }
// B2CTransferHandler handles Arifpay B2C transfers based on the transfer_mode. // CancelCheckoutSessionHandler cancels an existing Arifpay checkout session.
// //
// @Summary Initiate B2C Transfer // @Summary Cancel Arifpay Checkout Session
// @Description Initiates a B2C transfer via Telebirr, CBE, or MPESA through Arifpay // @Description Cancels a payment session using Arifpay before completion.
// @Tags Arifpay // @Tags Arifpay
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param transfer_mode query string true "Transfer mode (Telebirr, CBE, MPESA)" // @Param sessionId path string true "Checkout session ID"
// @Param request body domain.ArifPayB2CRequest true "Transfer request payload" // @Success 200 {object} domain.Response
// @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse
// @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse
// @Failure 502 {object} domain.ErrorResponse // @Router /api/v1/arifpay/checkout/{sessionId}/cancel [post]
// @Router /api/v1/arifpay/b2c/transfer [post] func (h *Handler) CancelCheckoutSessionHandler(c *fiber.Ctx) error {
func (h *Handler) B2CTransferHandler(c *fiber.Ctx) error { sessionID := c.Params("sessionId")
transferMode := c.Query("transfer_mode") if sessionID == "" {
var endpoint string
switch transferMode {
case "Telebirr":
endpoint = "https://telebirr-b2c.arifpay.net/api/Telebirr/b2c/transfer"
case "CBE":
endpoint = "https://cbe-b2c.arifpay.net/api/Cbebirr/b2c/transfer"
case "MPESA":
endpoint = "https://mpesa-b2c.arifpay.net/api/Mpesa/b2c/transfer"
default:
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: "invalid transfer_mode. Allowed values: Telebirr, CBE, MPESA", Error: "missing session ID",
Message: "Failed to process your request", Message: "Session ID is required",
}) })
} }
var req domain.ArifPayB2CRequest data, err := h.arifpaySvc.CancelCheckoutSession(c.Context(), sessionID)
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Failed to process your request",
})
}
resp, err := h.arifpaySvc.B2CTransfer(c.Context(), req, endpoint)
if err != nil { if err != nil {
return c.Status(fiber.StatusBadGateway).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(), Error: err.Error(),
Message: "Failed to process your request", Message: "Failed to cancel checkout session",
}) })
} }
return c.Status(fiber.StatusOK).JSON(domain.Response{ return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Transfer initiated successfully", Message: "Checkout session canceled successfully",
Data: resp, Data: data,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// HandleWebhook processes Arifpay webhook notifications.
//
// @Summary Handle Arifpay C2B Webhook
// @Description Handles webhook notifications from Arifpay for C2B transfers and updates transfer + wallet status.
// @Tags Arifpay
// @Accept json
// @Produce json
// @Param request body domain.WebhookRequest true "Arifpay webhook payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/arifpay/c2b-webhook [post]
func (h *Handler) HandleArifpayC2BWebhook(c *fiber.Ctx) error {
var req domain.WebhookRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Invalid webhook payload",
})
}
// 🚨 Decide how to get userId:
// If you get it from auth context/middleware, extract it here.
// For now, let's assume userId comes from your auth claims:
userId, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Error: "missing user id",
Message: "Unauthorized",
})
}
err := h.arifpaySvc.HandleWebhook(c.Context(), req, userId, true)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Failed to process webhook",
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Webhook processed successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}
// HandleWebhook processes Arifpay webhook notifications.
//
// @Summary Handle Arifpay B2C Webhook
// @Description Handles webhook notifications from Arifpay for B2C transfers and updates transfer + wallet status.
// @Tags Arifpay
// @Accept json
// @Produce json
// @Param request body domain.WebhookRequest true "Arifpay webhook payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/arifpay/b2c-webhook [post]
func (h *Handler) HandleArifpayB2CWebhook(c *fiber.Ctx) error {
var req domain.WebhookRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Invalid webhook payload",
})
}
// 🚨 Decide how to get userId:
// If you get it from auth context/middleware, extract it here.
// For now, let's assume userId comes from your auth claims:
userId, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Error: "missing user id",
Message: "Unauthorized",
})
}
err := h.arifpaySvc.HandleWebhook(c.Context(), req, userId, false)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Failed to process webhook",
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Webhook processed successfully",
Success: true, Success: true,
StatusCode: fiber.StatusOK, StatusCode: fiber.StatusOK,
}) })
@ -119,14 +193,7 @@ func (h *Handler) ArifpayVerifyByTransactionIDHandler(c *fiber.Ctx) error {
}) })
} }
if req.TransactionID == "" || req.PaymentType == 0 { resp, err := h.arifpaySvc.VerifyTransactionByTransactionID(c.Context(), req)
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: "missing transactionId or paymentType",
Message: "transactionId and paymentType are required fields",
})
}
resp, err := h.arifpaySvc.VerifyByTransactionID(req.TransactionID, req.PaymentType)
if err != nil { if err != nil {
return c.Status(fiber.StatusBadGateway).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadGateway).JSON(domain.ErrorResponse{
Error: err.Error(), Error: err.Error(),
@ -136,7 +203,7 @@ func (h *Handler) ArifpayVerifyByTransactionIDHandler(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(domain.Response{ return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Transaction verified successfully", Message: "Transaction verified successfully",
Data: json.RawMessage(resp), Data: resp,
Success: true, Success: true,
StatusCode: fiber.StatusOK, StatusCode: fiber.StatusOK,
}) })
@ -162,18 +229,104 @@ func (h *Handler) ArifpayVerifyBySessionIDHandler(c *fiber.Ctx) error {
}) })
} }
resp, err := h.arifpaySvc.VerifyBySessionID(sessionID) resp, err := h.arifpaySvc.VerifyTransactionBySessionID(c.Context(), sessionID)
if err != nil { if err != nil {
return c.Status(fiber.StatusBadGateway).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadGateway).JSON(domain.ErrorResponse{
Error: err.Error(), Error: err.Error(),
Message: "Failed to verify session", Message: "Failed to verify transaction",
}) })
} }
return c.Status(fiber.StatusOK).JSON(domain.Response{ return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Session verified successfully", Message: "Transaction verified successfully",
Data: json.RawMessage(resp), Data: resp,
Success: true, Success: true,
StatusCode: fiber.StatusOK, StatusCode: fiber.StatusOK,
}) })
} }
// ExecuteTransfer handles B2C transfers via Telebirr, CBE, or MPESA.
//
// @Summary Execute B2C Transfer
// @Description Initiates a B2C transfer using Telebirr, CBE, or MPESA depending on the "type" query parameter
// @Tags Arifpay
// @Accept json
// @Produce json
// @Param type query string true "Transfer type (telebirr, cbe, mpesa)"
// @Param request body domain.ArifpayB2CRequest true "Transfer request payload"
// @Success 200 {object} map[string]string "message: transfer executed successfully"
// @Failure 400 {object} map[string]string "error: invalid request or unsupported transfer type"
// @Failure 500 {object} map[string]string "error: internal server error"
// @Router /api/v1/arifpay/b2c/transfer [post]
func (h *Handler) ExecuteArifpayB2CTransfer(c *fiber.Ctx) error {
transferType := c.Query("type")
if transferType == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to process your withdrawal request",
Error: "missing query parameter: type (telebirr, cbe, mpesa)",
})
}
userId, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Error: "missing user id",
Message: "Unauthorized",
})
}
var req domain.ArifpayB2CRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to process your withdrawal request",
Error: "invalid request body",
})
}
var err error
switch transferType {
case "telebirr":
err = h.arifpaySvc.ExecuteTelebirrB2CTransfer(c.Context(), req, userId)
case "cbe":
err = h.arifpaySvc.ExecuteCBEB2CTransfer(c.Context(), req, userId)
case "mpesa":
err = h.arifpaySvc.ExecuteMPesaB2CTransfer(c.Context(), req, userId)
default:
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to process your withdrawal request",
Error: "unsupported transfer type, must be one of: telebirr, cbe, mpesa",
})
}
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to process your withdrawal request",
Error: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Withdrawal process initiated successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetPaymentMethodsHandler returns the list of all Arifpay payment methods
//
// @Summary List Arifpay Payment Methods
// @Description Returns all payment method IDs and names for Arifpay
// @Tags Arifpay
// @Produce json
// @Success 200 {object} []domain.ARIFPAYPaymentMethod
// @Router /api/v1/arifpay/payment-methods [get]
func (h *Handler) GetArifpayPaymentMethodsHandler(c *fiber.Ctx) error {
methods := h.arifpaySvc.GetPaymentMethodsMapping()
return c.Status(fiber.StatusOK).JSON(domain.Response{
Success: true,
Message: "Arifpay payment methods fetched successfully",
Data: methods,
StatusCode: fiber.StatusOK,
})
}

View File

@ -12,13 +12,13 @@ import (
// @Tags SantimPay // @Tags SantimPay
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body domain.GeneratePaymentURLInput true "SantimPay payment request payload" // @Param request body domain.GeneratePaymentURLRequest true "SantimPay payment request payload"
// @Success 200 {object} domain.Response // @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse // @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/santimpay/payment [post] // @Router /api/v1/santimpay/payment [post]
func (h *Handler) CreateSantimPayPaymentHandler(c *fiber.Ctx) error { func (h *Handler) InititateSantimPayPaymentHandler(c *fiber.Ctx) error {
var req domain.GeneratePaymentURLInput var req domain.GeneratePaymentURLRequest
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(), Error: err.Error(),
@ -26,7 +26,7 @@ func (h *Handler) CreateSantimPayPaymentHandler(c *fiber.Ctx) error {
}) })
} }
paymentURL, err := h.santimpaySvc.GeneratePaymentURL(req) paymentURL, err := h.santimpaySvc.InitiatePayment(req)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(), Error: err.Error(),
@ -41,3 +41,192 @@ func (h *Handler) CreateSantimPayPaymentHandler(c *fiber.Ctx) error {
StatusCode: fiber.StatusOK, StatusCode: fiber.StatusOK,
}) })
} }
// ProcessSantimPayCallbackHandler handles incoming SantimPay payment callbacks.
//
// @Summary Process SantimPay Payment Callback
// @Description Processes a callback from SantimPay, updates transfer status, and credits user wallet if payment was successful.
// @Tags SantimPay
// @Accept json
// @Produce json
// @Param request body domain.SantimPayCallbackPayload true "SantimPay callback payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/santimpay/callback [post]
func (h *Handler) ProcessSantimPayCallbackHandler(c *fiber.Ctx) error {
var payload domain.SantimPayCallbackPayload
if err := c.BodyParser(&payload); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Invalid callback payload",
})
}
if err := h.santimpaySvc.ProcessCallback(c.Context(), payload); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Failed to process SantimPay callback",
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "SantimPay callback processed successfully",
Data: nil,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// ProcessSantimPayDirectPaymentHandler initializes a direct payment session with SantimPay.
//
// @Summary Process SantimPay Direct Payment
// @Description Initiates a direct payment request with SantimPay and returns the response.
// @Tags SantimPay
// @Accept json
// @Produce json
// @Param request body domain.GeneratePaymentURLRequest true "SantimPay direct payment request payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/santimpay/direct-payment [post]
func (h *Handler) ProcessSantimPayDirectPaymentHandler(c *fiber.Ctx) error {
var req domain.GeneratePaymentURLRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Invalid direct payment request payload",
})
}
response, err := h.santimpaySvc.ProcessDirectPayment(c.Context(), req)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Failed to process SantimPay direct payment",
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "SantimPay direct payment processed successfully",
Data: response,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetSantimPayB2CPartnersHandler retrieves all available SantimPay B2C payout partners.
//
// @Summary Get SantimPay B2C Partners
// @Description Fetches a list of available B2C payout partners (e.g., Telebirr, Mpesa, Banks) from SantimPay.
// @Tags SantimPay
// @Accept json
// @Produce json
// @Success 200 {object} domain.Response
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/santimpay/b2c/partners [get]
func (h *Handler) GetSantimPayB2CPartnersHandler(c *fiber.Ctx) error {
partners, err := h.santimpaySvc.GetB2CPartners(c.Context())
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Failed to fetch SantimPay B2C partners",
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "SantimPay B2C partners retrieved successfully",
Data: partners,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// ProcessSantimPayB2CWithdrawalHandler processes a B2C (Withdrawal) transaction with SantimPay.
//
// @Summary Process SantimPay B2C Withdrawal
// @Description Initiates a B2C withdrawal request with SantimPay and returns the response.
// @Tags SantimPay
// @Accept json
// @Produce json
// @Param request body domain.GeneratePaymentURLRequest true "SantimPay B2C withdrawal request payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/santimpay/b2c-withdrawal [post]
func (h *Handler) ProcessSantimPayB2CWithdrawalHandler(c *fiber.Ctx) error {
var req domain.GeneratePaymentURLRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Invalid B2C withdrawal request payload",
})
}
// Extract userId from context/session (adapt based on your auth flow)
userId, ok := c.Locals("userId").(int64)
if !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: "missing userId in context",
Message: "Could not process withdrawal without user ID",
})
}
response, err := h.santimpaySvc.ProcessB2CWithdrawal(c.Context(), req, userId)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Failed to process SantimPay B2C withdrawal",
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "SantimPay B2C withdrawal processed successfully",
Data: response,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// CheckSantimPayTransactionStatusHandler checks the status of a SantimPay transaction.
//
// @Summary Check SantimPay Transaction Status
// @Description Retrieves the real-time status of a transaction from SantimPay.
// @Tags SantimPay
// @Accept json
// @Produce json
// @Param request body domain.TransactionStatusRequest true "Transaction status request payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/santimpay/transaction-status [post]
func (h *Handler) CheckSantimPayTransactionStatusHandler(c *fiber.Ctx) error {
var req domain.TransactionStatusRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Invalid transaction status request payload",
})
}
// Optional: extract fullParams from request, default to true if not provided
fullParams := true
if req.FullParams == nil {
req.FullParams = &fullParams
}
response, err := h.santimpaySvc.CheckTransactionStatus(c.Context(), req)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Error: err.Error(),
Message: "Failed to check SantimPay transaction status",
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "SantimPay transaction status retrieved successfully",
Data: response,
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -14,7 +14,7 @@ import (
// @Tags Telebirr // @Tags Telebirr
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body domain.GeneratePaymentURLInput true "Telebirr payment request payload" // @Param request body domain.GeneratePaymentURLRequest true "Telebirr payment request payload"
// @Success 200 {object} domain.Response // @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse // @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse

View File

@ -3,6 +3,7 @@ package handlers
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log" "log"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
@ -102,6 +103,14 @@ func (h *Handler) GetGamesByProvider(c *fiber.Ctx) error {
// @Failure 502 {object} domain.ErrorResponse // @Failure 502 {object} domain.ErrorResponse
// @Router /api/v1/veli/start-game [post] // @Router /api/v1/veli/start-game [post]
func (h *Handler) StartGame(c *fiber.Ctx) error { func (h *Handler) StartGame(c *fiber.Ctx) error {
userId, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Error: "missing user id",
Message: "Unauthorized",
})
}
var req domain.GameStartRequest var req domain.GameStartRequest
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
@ -110,6 +119,7 @@ func (h *Handler) StartGame(c *fiber.Ctx) error {
}) })
} }
req.PlayerID = fmt.Sprintf("%d", userId)
if req.BrandID == "" { if req.BrandID == "" {
req.BrandID = h.Cfg.VeliGames.BrandID req.BrandID = h.Cfg.VeliGames.BrandID
} }
@ -175,14 +185,22 @@ func (h *Handler) StartDemoGame(c *fiber.Ctx) error {
func (h *Handler) GetBalance(c *fiber.Ctx) error { func (h *Handler) GetBalance(c *fiber.Ctx) error {
var req domain.BalanceRequest var req domain.BalanceRequest
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") // return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
} }
// Optionally verify signature here... // Optionally verify signature here...
balance, err := h.veliVirtualGameSvc.GetBalance(c.Context(), req) balance, err := h.veliVirtualGameSvc.GetBalance(c.Context(), req)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error()) // return fiber.NewError(fiber.StatusInternalServerError, err.Error())
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to retrieve balance",
Error: err.Error(),
})
} }
return c.JSON(balance) return c.JSON(balance)
@ -191,7 +209,11 @@ func (h *Handler) GetBalance(c *fiber.Ctx) error {
func (h *Handler) PlaceBet(c *fiber.Ctx) error { func (h *Handler) PlaceBet(c *fiber.Ctx) error {
var req domain.BetRequest var req domain.BetRequest
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") // return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
} }
// Signature check optional here // Signature check optional here
@ -201,7 +223,11 @@ func (h *Handler) PlaceBet(c *fiber.Ctx) error {
if errors.Is(err, veli.ErrDuplicateTransaction) { if errors.Is(err, veli.ErrDuplicateTransaction) {
return fiber.NewError(fiber.StatusConflict, "DUPLICATE_TRANSACTION") return fiber.NewError(fiber.StatusConflict, "DUPLICATE_TRANSACTION")
} }
return fiber.NewError(fiber.StatusBadRequest, err.Error()) return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to process bet",
Error: err.Error(),
})
// return fiber.NewError(fiber.StatusBadRequest, err.Error())
} }
return c.JSON(res) return c.JSON(res)
@ -210,7 +236,11 @@ func (h *Handler) PlaceBet(c *fiber.Ctx) error {
func (h *Handler) RegisterWin(c *fiber.Ctx) error { func (h *Handler) RegisterWin(c *fiber.Ctx) error {
var req domain.WinRequest var req domain.WinRequest
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") // return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
} }
res, err := h.veliVirtualGameSvc.ProcessWin(c.Context(), req) res, err := h.veliVirtualGameSvc.ProcessWin(c.Context(), req)
@ -218,7 +248,11 @@ func (h *Handler) RegisterWin(c *fiber.Ctx) error {
if errors.Is(err, veli.ErrDuplicateTransaction) { if errors.Is(err, veli.ErrDuplicateTransaction) {
return fiber.NewError(fiber.StatusConflict, "DUPLICATE_TRANSACTION") return fiber.NewError(fiber.StatusConflict, "DUPLICATE_TRANSACTION")
} }
return fiber.NewError(fiber.StatusInternalServerError, err.Error()) // return fiber.NewError(fiber.StatusInternalServerError, err.Error())
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to process win",
Error: err.Error(),
})
} }
return c.JSON(res) return c.JSON(res)
@ -227,7 +261,11 @@ func (h *Handler) RegisterWin(c *fiber.Ctx) error {
func (h *Handler) CancelTransaction(c *fiber.Ctx) error { func (h *Handler) CancelTransaction(c *fiber.Ctx) error {
var req domain.CancelRequest var req domain.CancelRequest
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") // return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
} }
res, err := h.veliVirtualGameSvc.ProcessCancel(c.Context(), req) res, err := h.veliVirtualGameSvc.ProcessCancel(c.Context(), req)
@ -235,7 +273,11 @@ func (h *Handler) CancelTransaction(c *fiber.Ctx) error {
if errors.Is(err, veli.ErrDuplicateTransaction) { if errors.Is(err, veli.ErrDuplicateTransaction) {
return fiber.NewError(fiber.StatusConflict, "DUPLICATE_TRANSACTION") return fiber.NewError(fiber.StatusConflict, "DUPLICATE_TRANSACTION")
} }
return fiber.NewError(fiber.StatusInternalServerError, err.Error()) // return fiber.NewError(fiber.StatusInternalServerError, err.Error())
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to process cancel",
Error: err.Error(),
})
} }
return c.JSON(res) return c.JSON(res)
@ -261,11 +303,6 @@ func (h *Handler) GetGamingActivity(c *fiber.Ctx) error {
}) })
} }
// Inject BrandID if not provided
if req.BrandID == "" {
req.BrandID = h.Cfg.VeliGames.BrandID
}
resp, err := h.veliVirtualGameSvc.GetGamingActivity(c.Context(), req) resp, err := h.veliVirtualGameSvc.GetGamingActivity(c.Context(), req)
if err != nil { if err != nil {
log.Println("GetGamingActivity error:", err) log.Println("GetGamingActivity error:", err)
@ -282,3 +319,40 @@ func (h *Handler) GetGamingActivity(c *fiber.Ctx) error {
Success: true, Success: true,
}) })
} }
// GetHugeWins godoc
// @Summary Get Veli Huge Wins
// @Description Retrieves huge win transactions based on brand configuration (e.g. win > 10000 USD or 100x bet)
// @Tags Virtual Games - VeliGames
// @Accept json
// @Produce json
// @Param request body domain.HugeWinsRequest true "Huge Wins Request"
// @Success 200 {object} domain.Response{data=domain.HugeWinsResponse}
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/veli/huge-wins [post]
func (h *Handler) GetHugeWins(c *fiber.Ctx) error {
var req domain.HugeWinsRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request payload",
Error: err.Error(),
})
}
resp, err := h.veliVirtualGameSvc.GetHugeWins(c.Context(), req)
if err != nil {
log.Println("GetHugeWins error:", err)
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to retrieve huge wins",
Error: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Huge wins retrieved successfully",
Data: resp,
StatusCode: fiber.StatusOK,
Success: true,
})
}

View File

@ -114,16 +114,26 @@ func (a *App) initAppRoutes() {
//Arifpay //Arifpay
groupV1.Post("/arifpay/checkout", a.authMiddleware, h.CreateCheckoutSessionHandler) groupV1.Post("/arifpay/checkout", a.authMiddleware, h.CreateCheckoutSessionHandler)
groupV1.Post("/arifpay/b2c/transfer", a.authMiddleware, h.B2CTransferHandler) groupV1.Post("/arifpay/checkout/cancel/:session_id", a.authMiddleware, h.CancelCheckoutSessionHandler)
groupV1.Post("/api/v1/arifpay/c2b-webhook", a.authMiddleware, h.HandleArifpayC2BWebhook)
groupV1.Post("/api/v1/arifpay/b2c-webhook", a.authMiddleware, h.HandleArifpayB2CWebhook)
groupV1.Post("/arifpay/b2c/transfer", a.authMiddleware, h.ExecuteArifpayB2CTransfer)
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)
groupV1.Get("/arifpay/payment-methods", a.authMiddleware, h.GetArifpayPaymentMethodsHandler)
//Telebirr //Telebirr
groupV1.Post("/telebirr/init-payment", a.authMiddleware, h.CreateTelebirrPaymentHandler) groupV1.Post("/telebirr/init-payment", a.authMiddleware, h.CreateTelebirrPaymentHandler)
groupV1.Post("/telebirr/callback", h.HandleTelebirrCallback) groupV1.Post("/telebirr/callback", h.HandleTelebirrCallback)
//Santimpay //Santimpay
groupV1.Post("/santimpay/init-payment", h.CreateSantimPayPaymentHandler) groupV1.Post("/santimpay/init-payment", a.authMiddleware, h.InititateSantimPayPaymentHandler)
groupV1.Post("/santimpay/callback", h.ProcessSantimPayCallbackHandler)
groupV1.Post("/santimpay/direct-payment", a.authMiddleware, h.ProcessSantimPayDirectPaymentHandler)
groupV1.Get("/santimpay/b2c/partners", h.GetSantimPayB2CPartnersHandler)
groupV1.Post("/santimpay/b2c/withdraw", a.authMiddleware, h.ProcessSantimPayB2CWithdrawalHandler)
groupV1.Post("/santimpay/transaction/verify", a.authMiddleware, h.CheckSantimPayTransactionStatusHandler)
// 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)
@ -279,12 +289,13 @@ func (a *App) initAppRoutes() {
groupV1.Post("/webhooks/alea-play", a.authMiddleware, h.HandleAleaCallback) groupV1.Post("/webhooks/alea-play", a.authMiddleware, h.HandleAleaCallback)
//Veli Virtual Game Routes //Veli Virtual Game Routes
groupV1.Post("/veli/providers", h.GetProviders) groupV1.Post("/veli/providers", a.authMiddleware, h.GetProviders)
groupV1.Post("/veli/games-list", h.GetGamesByProvider) groupV1.Post("/veli/games-list", a.authMiddleware, h.GetGamesByProvider)
groupV1.Post("/veli/start-game", a.authMiddleware, h.StartGame) groupV1.Post("/veli/start-game", a.authMiddleware, h.StartGame)
groupV1.Post("/veli/start-demo-game", h.StartDemoGame) groupV1.Post("/veli/start-demo-game", h.StartDemoGame)
a.fiber.Post("/balance", h.GetBalance) a.fiber.Post("/balance", h.GetBalance)
groupV1.Post("/veli/gaming-activity", h.GetGamingActivity) groupV1.Post("/veli/gaming-activity", a.authMiddleware, h.GetGamingActivity)
groupV1.Post("/veli/huge-wins", a.authMiddleware, h.GetHugeWins)
//mongoDB logs //mongoDB logs
groupV1.Get("/logs", a.authMiddleware, a.SuperAdminOnly, handlers.GetLogsHandler(context.Background())) groupV1.Get("/logs", a.authMiddleware, a.SuperAdminOnly, handlers.GetLogsHandler(context.Background()))