From f1a4f5e6f9da1032f01dbd5fc2ec6c6068c3b97e Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Sun, 17 Aug 2025 12:13:29 +0300 Subject: [PATCH] Arifpay webhook fixes --- cmd/main.go | 4 +- db/migrations/000001_fortune.up.sql | 1 + ...l => 000004_virtual_game_Session.down.sql} | 0 ...sql => 000004_virtual_game_Session.up.sql} | 0 db/query/transfer.sql | 3 +- gen/db/models.go | 2 + gen/db/transfer.sql.go | 20 +- go.mod | 2 +- internal/config/config.go | 37 +- internal/domain/arifpay.go | 90 ++- internal/domain/transfer.go | 3 + internal/repository/transfer.go | 6 + internal/services/arifpay/service.go | 594 +++++++++++++++--- internal/services/santimpay/service.go | 2 +- internal/services/virtualGame/veli/service.go | 55 +- internal/web_server/handlers/arifpay.go | 263 ++++++-- internal/web_server/handlers/veli_games.go | 48 +- internal/web_server/routes.go | 8 +- 18 files changed, 910 insertions(+), 228 deletions(-) rename db/migrations/{000004_virtual_game_Sessios.down.sql => 000004_virtual_game_Session.down.sql} (100%) rename db/migrations/{000004_virtual_game_Sessios.up.sql => 000004_virtual_game_Session.up.sql} (100%) diff --git a/cmd/main.go b/cmd/main.go index c737e71..a9b4970 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -153,7 +153,7 @@ func main() { virtualGameSvc := virtualgameservice.New(vitualGameRepo, *walletSvc, store, cfg, logger) aleaService := alea.NewAleaPlayService(vitualGameRepo, *walletSvc, cfg, logger) veliCLient := veli.NewClient(cfg, walletSvc) - veliVirtualGameService := veli.New(veliCLient) + veliVirtualGameService := veli.New(veliCLient, walletSvc) recommendationSvc := recommendation.NewService(recommendationRepo) chapaClient := chapa.NewClient(cfg.CHAPA_BASE_URL, cfg.CHAPA_SECRET_KEY) @@ -234,7 +234,7 @@ func main() { transferStore := wallet.TransferStore(store) // walletStore := wallet.WalletStore(store) - arifpaySvc := arifpay.NewArifpayService(cfg, transferStore, &http.Client{ + arifpaySvc := arifpay.NewArifpayService(cfg, transferStore, walletSvc, &http.Client{ Timeout: 30 * time.Second}) santimpayClient := santimpay.NewSantimPayClient(cfg) diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 9a952a1..71519c4 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -190,6 +190,7 @@ CREATE TABLE IF NOT EXISTS wallet_transfer ( cashier_id BIGINT, verified BOOLEAN DEFAULT false, reference_number VARCHAR(255) NOT NULL, + session_id VARCHAR(255), status VARCHAR(255), payment_method VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, diff --git a/db/migrations/000004_virtual_game_Sessios.down.sql b/db/migrations/000004_virtual_game_Session.down.sql similarity index 100% rename from db/migrations/000004_virtual_game_Sessios.down.sql rename to db/migrations/000004_virtual_game_Session.down.sql diff --git a/db/migrations/000004_virtual_game_Sessios.up.sql b/db/migrations/000004_virtual_game_Session.up.sql similarity index 100% rename from db/migrations/000004_virtual_game_Sessios.up.sql rename to db/migrations/000004_virtual_game_Session.up.sql diff --git a/db/query/transfer.sql b/db/query/transfer.sql index 9219943..dc4c156 100644 --- a/db/query/transfer.sql +++ b/db/query/transfer.sql @@ -8,10 +8,11 @@ INSERT INTO wallet_transfer ( cashier_id, verified, reference_number, + session_id, status, 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 *; -- name: GetAllTransfers :many SELECT * diff --git a/gen/db/models.go b/gen/db/models.go index a414f7f..7138ff5 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -713,6 +713,7 @@ type WalletTransfer struct { CashierID pgtype.Int8 `json:"cashier_id"` Verified pgtype.Bool `json:"verified"` ReferenceNumber string `json:"reference_number"` + SessionID pgtype.Text `json:"session_id"` Status pgtype.Text `json:"status"` PaymentMethod pgtype.Text `json:"payment_method"` CreatedAt pgtype.Timestamp `json:"created_at"` @@ -729,6 +730,7 @@ type WalletTransferDetail struct { CashierID pgtype.Int8 `json:"cashier_id"` Verified pgtype.Bool `json:"verified"` ReferenceNumber string `json:"reference_number"` + SessionID pgtype.Text `json:"session_id"` Status pgtype.Text `json:"status"` PaymentMethod pgtype.Text `json:"payment_method"` CreatedAt pgtype.Timestamp `json:"created_at"` diff --git a/gen/db/transfer.sql.go b/gen/db/transfer.sql.go index b564b72..35e38d4 100644 --- a/gen/db/transfer.sql.go +++ b/gen/db/transfer.sql.go @@ -21,11 +21,12 @@ INSERT INTO wallet_transfer ( cashier_id, verified, reference_number, + session_id, status, payment_method ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) -RETURNING id, amount, message, type, receiver_wallet_id, sender_wallet_id, cashier_id, verified, reference_number, status, payment_method, created_at, updated_at +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, session_id, status, payment_method, created_at, updated_at ` type CreateTransferParams struct { @@ -37,6 +38,7 @@ type CreateTransferParams struct { CashierID pgtype.Int8 `json:"cashier_id"` Verified pgtype.Bool `json:"verified"` ReferenceNumber string `json:"reference_number"` + SessionID pgtype.Text `json:"session_id"` Status pgtype.Text `json:"status"` PaymentMethod pgtype.Text `json:"payment_method"` } @@ -51,6 +53,7 @@ func (q *Queries) CreateTransfer(ctx context.Context, arg CreateTransferParams) arg.CashierID, arg.Verified, arg.ReferenceNumber, + arg.SessionID, arg.Status, arg.PaymentMethod, ) @@ -65,6 +68,7 @@ func (q *Queries) CreateTransfer(ctx context.Context, arg CreateTransferParams) &i.CashierID, &i.Verified, &i.ReferenceNumber, + &i.SessionID, &i.Status, &i.PaymentMethod, &i.CreatedAt, @@ -74,7 +78,7 @@ func (q *Queries) CreateTransfer(ctx context.Context, arg CreateTransferParams) } 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 ` @@ -97,6 +101,7 @@ func (q *Queries) GetAllTransfers(ctx context.Context) ([]WalletTransferDetail, &i.CashierID, &i.Verified, &i.ReferenceNumber, + &i.SessionID, &i.Status, &i.PaymentMethod, &i.CreatedAt, @@ -116,7 +121,7 @@ func (q *Queries) GetAllTransfers(ctx context.Context) ([]WalletTransferDetail, } 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 WHERE id = $1 ` @@ -134,6 +139,7 @@ func (q *Queries) GetTransferByID(ctx context.Context, id int64) (WalletTransfer &i.CashierID, &i.Verified, &i.ReferenceNumber, + &i.SessionID, &i.Status, &i.PaymentMethod, &i.CreatedAt, @@ -146,7 +152,7 @@ func (q *Queries) GetTransferByID(ctx context.Context, id int64) (WalletTransfer } 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 WHERE reference_number = $1 ` @@ -164,6 +170,7 @@ func (q *Queries) GetTransferByReference(ctx context.Context, referenceNumber st &i.CashierID, &i.Verified, &i.ReferenceNumber, + &i.SessionID, &i.Status, &i.PaymentMethod, &i.CreatedAt, @@ -176,7 +183,7 @@ func (q *Queries) GetTransferByReference(ctx context.Context, referenceNumber st } 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 WHERE receiver_wallet_id = $1 OR sender_wallet_id = $1 @@ -201,6 +208,7 @@ func (q *Queries) GetTransfersByWallet(ctx context.Context, receiverWalletID pgt &i.CashierID, &i.Verified, &i.ReferenceNumber, + &i.SessionID, &i.Status, &i.PaymentMethod, &i.CreatedAt, diff --git a/go.mod b/go.mod index 71351a2..9001f12 100644 --- a/go.mod +++ b/go.mod @@ -94,6 +94,6 @@ require ( 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 diff --git a/internal/config/config.go b/internal/config/config.go index a0acbb1..afc4d78 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "strconv" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" @@ -59,11 +60,25 @@ type VeliConfig struct { } type ARIFPAYConfig struct { - APIKey string `mapstructure:"ARIFPAYAPI_KEY"` - CancelUrl string `mapstructure:"ARIFPAY_BASE_URL"` - ErrorUrl string `mapstructure:"ARIFPAY_SECRET_KEY"` - NotifyUrl string `mapstructure:"ARIFPAY_OPERATOR_ID"` - SuccessUrl string `mapstructure:"ARIFPAY_BRAND_ID"` + APIKey string `mapstructure:"ARIFPAY_API_KEY"` + BaseURL string `mapstructure:"ARIFPAY_BASE_URL"` + + // Default URLs + 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 { @@ -224,8 +239,18 @@ func (c *Config) loadEnv() error { c.ARIFPAY.APIKey = os.Getenv("ARIFPAY_API_KEY") c.ARIFPAY.CancelUrl = os.Getenv("ARIFPAY_CANCEL_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.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.MerchantID = os.Getenv("SANTIMPAY_MERCHANT_ID") diff --git a/internal/domain/arifpay.go b/internal/domain/arifpay.go index 372a7b8..94ac010 100644 --- a/internal/domain/arifpay.go +++ b/internal/domain/arifpay.go @@ -1,37 +1,39 @@ 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 { - Name string `json:"name"` - Quantity int `json:"quantity"` - Price float64 `json:"price"` - Description string `json:"description"` - Image string `json:"image"` + Items []struct { + Name string `json:"name"` + Quantity int `json:"quantity"` + Price float64 `json:"price"` + Description string `json:"description"` + } `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 { - AccountNumber string `json:"accountNumber"` - Bank string `json:"bank"` - Amount float64 `json:"amount"` +type CheckoutSessionClientRequest struct { + Amount float64 `json:"amount" binding:"required"` + CustomerEmail string `json:"customerEmail" binding:"required"` + CustomerPhone string `json:"customerPhone" binding:"required"` } -type CreateCheckoutSessionRequest 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 { +type CancelCheckoutSessionResponse struct { Error bool `json:"error"` Msg string `json:"msg"` Data struct { @@ -42,12 +44,34 @@ type ArifPayCheckoutResponse struct { } `json:"data"` } -type ArifPayB2CRequest struct { - SessionID string `json:"Sessionid"` - PhoneNumber string `json:"Phonenumber"` +type WebhookRequest struct { + UUID string `json:"uuid"` + 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 { - TransactionID string `json:"transactionId"` - PaymentType int `json:"paymentType"` +type ArifpayB2CRequest struct{ + PhoneNumber string `json:"Phonenumber"` + 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 } diff --git a/internal/domain/transfer.go b/internal/domain/transfer.go index 370b8a1..cf629c3 100644 --- a/internal/domain/transfer.go +++ b/internal/domain/transfer.go @@ -66,6 +66,7 @@ type Transfer struct { ReceiverWalletID ValidInt64 `json:"receiver_wallet_id"` SenderWalletID ValidInt64 `json:"sender_wallet_id"` ReferenceNumber string `json:"reference_number"` // <-- needed + SessionID string `json:"session_id"` Status string `json:"status"` DepositorID ValidInt64 `json:"depositor_id"` CreatedAt time.Time `json:"created_at"` @@ -81,6 +82,7 @@ type TransferDetail struct { ReceiverWalletID ValidInt64 `json:"receiver_wallet_id"` SenderWalletID ValidInt64 `json:"sender_wallet_id"` ReferenceNumber string `json:"reference_number"` // <-- needed + SessionID string `json:"session_id"` Status string `json:"status"` DepositorID ValidInt64 `json:"depositor_id"` DepositorFirstName string `json:"depositor_first_name"` @@ -99,6 +101,7 @@ type CreateTransfer struct { ReceiverWalletID ValidInt64 `json:"receiver_wallet_id"` SenderWalletID ValidInt64 `json:"sender_wallet_id"` ReferenceNumber string `json:"reference_number"` // <-- needed + SessionID string `json:"session_id"` Status string `json:"status"` CashierID ValidInt64 `json:"cashier_id"` } diff --git a/internal/repository/transfer.go b/internal/repository/transfer.go index 4324ddf..cad330e 100644 --- a/internal/repository/transfer.go +++ b/internal/repository/transfer.go @@ -32,6 +32,7 @@ func convertDBTransferDetail(transfer dbgen.WalletTransferDetail) domain.Transfe DepositorPhoneNumber: transfer.PhoneNumber.String, PaymentMethod: domain.PaymentMethod(transfer.PaymentMethod.String), ReferenceNumber: transfer.ReferenceNumber, + SessionID: transfer.SessionID.String, Status: transfer.Status.String, CreatedAt: transfer.CreatedAt.Time, UpdatedAt: transfer.UpdatedAt.Time, @@ -58,6 +59,7 @@ func convertDBTransfer(transfer dbgen.WalletTransfer) domain.Transfer { }, PaymentMethod: domain.PaymentMethod(transfer.PaymentMethod.String), ReferenceNumber: transfer.ReferenceNumber, + SessionID: transfer.SessionID.String, Status: transfer.Status.String, CreatedAt: transfer.CreatedAt.Time, UpdatedAt: transfer.UpdatedAt.Time, @@ -82,6 +84,10 @@ func convertCreateTransfer(transfer domain.CreateTransfer) dbgen.CreateTransferP Valid: transfer.CashierID.Valid, }, ReferenceNumber: string(transfer.ReferenceNumber), + SessionID: pgtype.Text{ + String: transfer.SessionID, + Valid: true, + }, PaymentMethod: pgtype.Text{String: string(transfer.PaymentMethod), Valid: true}, Verified: pgtype.Bool{ diff --git a/internal/services/arifpay/service.go b/internal/services/arifpay/service.go index 8573bc5..a074d98 100644 --- a/internal/services/arifpay/service.go +++ b/internal/services/arifpay/service.go @@ -4,12 +4,11 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" - "time" - "github.com/AnaniyaBelew/ArifpayGoPlugin" "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" @@ -19,174 +18,567 @@ import ( type ArifpayService struct { cfg *config.Config transferStore wallet.TransferStore + walletSvc *wallet.Service 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{ cfg: cfg, transferStore: transferStore, + walletSvc: walletSvc, httpClient: httpClient, } } -func (s *ArifpayService) CreateCheckoutSession(req domain.CreateCheckoutSessionRequest) (string, error) { - // Create SDK-compatible payload - paymentPayload := ArifpayGoPlugin.PaymentRequest{ - CancelUrl: s.cfg.ARIFPAY.CancelUrl, - Phone: req.Phone, - Email: req.Email, - Nonce: req.Nonce, - ErrorUrl: s.cfg.ARIFPAY.ErrorUrl, - NotifyUrl: s.cfg.ARIFPAY.NotifyUrl, - SuccessUrl: s.cfg.ARIFPAY.SuccessUrl, - PaymentMethods: req.PaymentMethods, - Lang: req.Lang, +func (s *ArifpayService) CreateCheckoutSession(req domain.CheckoutSessionClientRequest, isDeposit bool) (map[string]any, error) { + // Generate unique nonce + nonce := uuid.NewString() + + var NotifyURL string + + if isDeposit{ + NotifyURL = s.cfg.ARIFPAY.C2BNotifyUrl + }else{ + NotifyURL = s.cfg.ARIFPAY.B2CNotifyUrl } - // Convert items - for _, item := range req.Items { - paymentPayload.Items = append(paymentPayload.Items, domain.Item{ - Name: item.Name, - Quantity: item.Quantity, - Price: item.Price, - Description: item.Description, - Image: item.Image, - }) + // Construct full checkout request + checkoutReq := domain.CheckoutSessionRequest{ + CancelURL: s.cfg.ARIFPAY.CancelUrl, + Phone: req.CustomerPhone, // must be in format 2519... + Email: req.CustomerEmail, + Nonce: nonce, + SuccessURL: s.cfg.ARIFPAY.SuccessUrl, + 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 - for _, b := range req.Beneficiaries { - 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) + // Marshal to JSON + payload, err := json.Marshal(checkoutReq) if err != nil { - return "", err + return nil, fmt.Errorf("failed to marshal checkout request: %w", err) } - transfer := domain.CreateTransfer{ - Amount: domain.Currency(req.Beneficiaries[0].Amount), - Verified: false, - 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) + // Send request to Arifpay API + url := fmt.Sprintf("%s/api/checkout/session", s.cfg.ARIFPAY.BaseURL) + httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) 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("Accept", "application/json") httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) resp, err := s.httpClient.Do(httpReq) if err != nil { - return nil, fmt.Errorf("request to Telebirr B2C failed: %w", err) + return nil, err } 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 { - 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{} - if err := json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("failed to parse response body: %w", err) + // Optionally unmarshal response to struct + var result map[string]interface{} + 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) { - url := "https://gateway.arifpay.org/api/checkout/getSessionByTransactionId" +func (s *ArifpayService) CancelCheckoutSession(ctx context.Context, sessionID string) (*domain.CancelCheckoutSessionResponse, error) { + // Build the cancel URL + url := fmt.Sprintf("%s/api/sandbox/checkout/session/%s", s.cfg.ARIFPAY.BaseURL, sessionID) - var payload domain.ArifpayVerifyByTransactionIDRequest - - 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)) + // Create the request + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } + // Add headers req.Header.Set("Content-Type", "application/json") req.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) + // Execute request resp, err := s.httpClient.Do(req) 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() - respBody, err := io.ReadAll(resp.Body) + // Read response body + body, err := io.ReadAll(resp.Body) 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 { - 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) { - url := "https://gateway.arifpay.org/api/ms/transaction/status/" + sessionID +func (s *ArifpayService) HandleWebhook(ctx context.Context, req domain.WebhookRequest, userId int64, isDepost bool) error { + // 1. Get transfer by SessionID + transfer, err := s.transferStore.GetTransferByReference(ctx, req.Transaction.TransactionID) + if err != nil { + return err + } - // Create GET request without body - req, err := http.NewRequest("GET", url, nil) + wallets, err := s.walletSvc.GetWalletsByUser(ctx, userId) + 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 { return nil, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) + httpReq.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 { - return nil, fmt.Errorf("request failed: %w", err) + return nil, fmt.Errorf("failed to call verify transaction API: %w", err) } defer resp.Body.Close() - respBody, err := io.ReadAll(resp.Body) + // Read response body + respBytes, err := io.ReadAll(resp.Body) 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 { - 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"}, + } } diff --git a/internal/services/santimpay/service.go b/internal/services/santimpay/service.go index eca451e..a557f49 100644 --- a/internal/services/santimpay/service.go +++ b/internal/services/santimpay/service.go @@ -47,7 +47,7 @@ func (s *SantimPayService) GeneratePaymentURL(input domain.GeneratePaymentURLInp SignedToken: token, SuccessRedirectURL: s.cfg.ARIFPAY.SuccessUrl, FailureRedirectURL: s.cfg.ARIFPAY.ErrorUrl, - NotifyURL: s.cfg.ARIFPAY.NotifyUrl, + NotifyURL: s.cfg.ARIFPAY.B2CNotifyUrl, CancelRedirectURL: s.cfg.ARIFPAY.CancelUrl, PhoneNumber: input.PhoneNumber, } diff --git a/internal/services/virtualGame/veli/service.go b/internal/services/virtualGame/veli/service.go index 3d4638c..1a07114 100644 --- a/internal/services/virtualGame/veli/service.go +++ b/internal/services/virtualGame/veli/service.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" ) var ( @@ -19,11 +20,13 @@ var ( type service struct { client *Client + walletSvc *wallet.Service } -func New(client *Client) VeliVirtualGameService { +func New(client *Client, walletSvc *wallet.Service) VeliVirtualGameService { return &service{ client: client, + walletSvc: walletSvc, } } @@ -87,20 +90,48 @@ func (s *service) StartDemoGame(ctx context.Context, req domain.DemoGameRequest) } func (s *service) GetBalance(ctx context.Context, req domain.BalanceRequest) (*domain.BalanceResponse, error) { - sigParams := map[string]string{ - "sessionId": req.SessionID, - "providerId": req.ProviderID, - "playerId": req.PlayerID, - "currency": req.Currency, - "brandId": req.BrandID, + // Retrieve player's real balance from wallet service + playerIDInt64, err := strconv.ParseInt(req.PlayerID, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid PlayerID: %w", err) } - if req.GameID != "" { - sigParams["gameId"] = req.GameID + playerWallets, err := s.walletSvc.GetWalletsByUser(ctx, playerIDInt64) + if err != nil { + return nil, fmt.Errorf("failed to get real balance: %w", err) } - var res domain.BalanceResponse - err := s.client.post(ctx, "/balance", req, sigParams, &res) - return &res, err + realBalance := playerWallets[0].Balance + + // 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: string(playerWallets[0].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) { diff --git a/internal/web_server/handlers/arifpay.go b/internal/web_server/handlers/arifpay.go index ad8b9ce..c61d24e 100644 --- a/internal/web_server/handlers/arifpay.go +++ b/internal/web_server/handlers/arifpay.go @@ -1,8 +1,6 @@ package handlers import ( - "encoding/json" - "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/gofiber/fiber/v2" ) @@ -14,13 +12,13 @@ import ( // @Tags Arifpay // @Accept 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 // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/arifpay/checkout [post] func (h *Handler) CreateCheckoutSessionHandler(c *fiber.Ctx) error { - var req domain.CreateCheckoutSessionRequest + var req domain.CheckoutSessionClientRequest if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ 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 { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Error: err.Error(), @@ -38,62 +36,138 @@ func (h *Handler) CreateCheckoutSessionHandler(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(domain.Response{ Message: "Checkout session created successfully", - Data: paymentURL, + Data: data, Success: true, StatusCode: fiber.StatusOK, }) } -// B2CTransferHandler handles Arifpay B2C transfers based on the transfer_mode. +// CancelCheckoutSessionHandler cancels an existing Arifpay checkout session. // -// @Summary Initiate B2C Transfer -// @Description Initiates a B2C transfer via Telebirr, CBE, or MPESA through Arifpay +// @Summary Cancel Arifpay Checkout Session +// @Description Cancels a payment session using Arifpay before completion. // @Tags Arifpay // @Accept json // @Produce json -// @Param transfer_mode query string true "Transfer mode (Telebirr, CBE, MPESA)" -// @Param request body domain.ArifPayB2CRequest true "Transfer request payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 502 {object} domain.ErrorResponse -// @Router /api/v1/arifpay/b2c/transfer [post] -func (h *Handler) B2CTransferHandler(c *fiber.Ctx) error { - transferMode := c.Query("transfer_mode") - - 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: +// @Param sessionId path string true "Checkout session ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/arifpay/checkout/{sessionId}/cancel [post] +func (h *Handler) CancelCheckoutSessionHandler(c *fiber.Ctx) error { + sessionID := c.Params("sessionId") + if sessionID == "" { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Error: "invalid transfer_mode. Allowed values: Telebirr, CBE, MPESA", - Message: "Failed to process your request", + Error: "missing session ID", + Message: "Session ID is required", }) } - var req domain.ArifPayB2CRequest - 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) + data, err := h.arifpaySvc.CancelCheckoutSession(c.Context(), sessionID) if err != nil { - return c.Status(fiber.StatusBadGateway).JSON(domain.ErrorResponse{ + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Error: err.Error(), - Message: "Failed to process your request", + Message: "Failed to cancel checkout session", }) } return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "Transfer initiated successfully", - Data: resp, + Message: "Checkout session canceled successfully", + 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, StatusCode: fiber.StatusOK, }) @@ -119,14 +193,7 @@ func (h *Handler) ArifpayVerifyByTransactionIDHandler(c *fiber.Ctx) error { }) } - if req.TransactionID == "" || req.PaymentType == 0 { - 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) + resp, err := h.arifpaySvc.VerifyTransactionByTransactionID(c.Context(), req) if err != nil { return c.Status(fiber.StatusBadGateway).JSON(domain.ErrorResponse{ Error: err.Error(), @@ -136,7 +203,7 @@ func (h *Handler) ArifpayVerifyByTransactionIDHandler(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(domain.Response{ Message: "Transaction verified successfully", - Data: json.RawMessage(resp), + Data: resp, Success: true, 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 { return c.Status(fiber.StatusBadGateway).JSON(domain.ErrorResponse{ Error: err.Error(), - Message: "Failed to verify session", + Message: "Failed to verify transaction", }) } return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "Session verified successfully", - Data: json.RawMessage(resp), + Message: "Transaction verified successfully", + Data: resp, Success: true, 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, + }) +} diff --git a/internal/web_server/handlers/veli_games.go b/internal/web_server/handlers/veli_games.go index 2bdacd6..4378f9b 100644 --- a/internal/web_server/handlers/veli_games.go +++ b/internal/web_server/handlers/veli_games.go @@ -175,14 +175,22 @@ func (h *Handler) StartDemoGame(c *fiber.Ctx) error { func (h *Handler) GetBalance(c *fiber.Ctx) error { var req domain.BalanceRequest 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... balance, err := h.veliVirtualGameSvc.GetBalance(c.Context(), req) 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) @@ -191,7 +199,11 @@ func (h *Handler) GetBalance(c *fiber.Ctx) error { func (h *Handler) PlaceBet(c *fiber.Ctx) error { var req domain.BetRequest 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 @@ -201,7 +213,11 @@ func (h *Handler) PlaceBet(c *fiber.Ctx) error { if errors.Is(err, veli.ErrDuplicateTransaction) { 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) @@ -210,7 +226,11 @@ func (h *Handler) PlaceBet(c *fiber.Ctx) error { func (h *Handler) RegisterWin(c *fiber.Ctx) error { var req domain.WinRequest 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) @@ -218,7 +238,11 @@ func (h *Handler) RegisterWin(c *fiber.Ctx) error { if errors.Is(err, veli.ErrDuplicateTransaction) { 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) @@ -227,7 +251,11 @@ func (h *Handler) RegisterWin(c *fiber.Ctx) error { func (h *Handler) CancelTransaction(c *fiber.Ctx) error { var req domain.CancelRequest 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) @@ -235,7 +263,11 @@ func (h *Handler) CancelTransaction(c *fiber.Ctx) error { if errors.Is(err, veli.ErrDuplicateTransaction) { 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) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 8132608..d27718f 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -114,9 +114,13 @@ func (a *App) initAppRoutes() { //Arifpay 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.Get("/arifpay/session-id/verify-transaction/:session_id", a.authMiddleware, h.ArifpayVerifyBySessionIDHandler) + groupV1.Get("/arifpay/payment-methods", a.authMiddleware, h.GetArifpayPaymentMethodsHandler) //Telebirr groupV1.Post("/telebirr/init-payment", a.authMiddleware, h.CreateTelebirrPaymentHandler) @@ -281,7 +285,7 @@ func (a *App) initAppRoutes() { //Veli Virtual Game Routes groupV1.Post("/veli/providers", h.GetProviders) groupV1.Post("/veli/games-list", h.GetGamesByProvider) - groupV1.Post("/veli/start-game", a.authMiddleware, h.StartGame) + groupV1.Post("/veli/start-game", h.StartGame) groupV1.Post("/veli/start-demo-game", h.StartDemoGame) a.fiber.Post("/balance", h.GetBalance) groupV1.Post("/veli/gaming-activity", h.GetGamingActivity)