package arifpay import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/ports" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/google/uuid" ) type ArifpayService struct { cfg *config.Config transferStore ports.TransferStore walletSvc *wallet.Service httpClient *http.Client } func NewArifpayService(cfg *config.Config, transferStore ports.TransferStore, walletSvc *wallet.Service, httpClient *http.Client) *ArifpayService { return &ArifpayService{ cfg: cfg, transferStore: transferStore, walletSvc: walletSvc, httpClient: httpClient, } } func (s *ArifpayService) CreateCheckoutSession(req domain.CheckoutSessionClientRequest, isDeposit bool, userId int64) (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 } // 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, } // Marshal to JSON payload, err := json.Marshal(checkoutReq) if err != nil { return nil, fmt.Errorf("failed to marshal checkout request: %w", err) } // 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, err } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) resp, err := s.httpClient.Do(httpReq) if err != nil { return nil, err } defer resp.Body.Close() // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to create checkout session: %s", string(body)) } // 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) } 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), CashierID: domain.ValidInt64{ Value: userId, Valid: true, }, } if _, err := s.transferStore.CreateTransfer(context.Background(), transfer); err != nil { return nil, err } return data, nil } func (s *ArifpayService) CancelCheckoutSession(ctx context.Context, sessionID string) (any, error) { // Build the cancel URL url := fmt.Sprintf("%s/api/sandbox/checkout/session/%s", s.cfg.ARIFPAY.BaseURL, sessionID) // 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("failed to execute cancel request: %w", err) } defer resp.Body.Close() // Read response body body, err := io.ReadAll(resp.Body) if err != nil { 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("cancel request failed: status=%d, body=%s", resp.StatusCode, string(body)) } // 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.Data, nil } func (s *ArifpayService) ProcessWebhook(ctx context.Context, req domain.WebhookRequest, isDeposit bool) error { // 1. Get transfer by SessionID transfer, err := s.transferStore.GetTransferByReference(ctx, req.Nonce) if err != nil { return err } userId := transfer.DepositorID.Value wallet, err := s.walletSvc.GetCustomerWallet(ctx, userId) if err != nil { return err } if transfer.Verified { return errors.New("transfer already verified") } // 2. Update transfer status newStatus := strings.ToLower(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" && isDeposit) || (newStatus == "failed" && !isDeposit) { _, err = s.walletSvc.AddToWallet(ctx, wallet.RegularID, domain.Currency(req.TotalAmount), domain.ValidInt64{}, transfer.PaymentMethod, domain.PaymentDetails{ ReferenceNumber: domain.ValidString{ Value: req.Nonce, Valid: true, }, BankNumber: domain.ValidString{ Value: "", Valid: false, }, }, "") if err != nil { return err } } return nil } func (s *ArifpayService) ExecuteTelebirrB2CTransfer(ctx context.Context, req domain.CheckoutSessionClientRequest, userId int64) error { // Step 1: Create Session userWallet, err := s.walletSvc.GetCustomerWallet(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, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, "", ) if err != nil { return fmt.Errorf("failed to deduct from wallet: %w", err) } referenceNum := uuid.NewString() sessionReq := domain.CheckoutSessionClientRequest{ Amount: req.Amount, CustomerEmail: req.CustomerEmail, CustomerPhone: "251" + req.CustomerPhone[:9], } sessionResp, err := s.CreateCheckoutSession(sessionReq, false, userId) if err != nil { _, err = s.walletSvc.AddToWallet( ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "", ) if err != nil { return fmt.Errorf("failed to deduct from wallet: %w", err) } return fmt.Errorf("failed to create session: %w", err) } sessionRespData := sessionResp["data"].(map[string]any) // Step 2: Execute Transfer transferURL := fmt.Sprintf("%s/api/Telebirr/b2c/transfer", s.cfg.ARIFPAY.BaseURL) reqBody := map[string]any{ "Sessionid": sessionRespData["sessionId"], "Phonenumber": "251" + req.CustomerPhone[:9], } payload, err := json.Marshal(reqBody) if err != nil { _, err = s.walletSvc.AddToWallet( ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "", ) if err != nil { return fmt.Errorf("failed to deduct from wallet: %w", err) } return fmt.Errorf("failed to marshal transfer request: %w", err) } transferReq, err := http.NewRequestWithContext(ctx, http.MethodPost, transferURL, bytes.NewBuffer(payload)) if err != nil { _, err = s.walletSvc.AddToWallet( ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "", ) if err != nil { return fmt.Errorf("failed to deduct from wallet: %w", err) } 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 { _, err = s.walletSvc.AddToWallet( ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "", ) if err != nil { return fmt.Errorf("failed to deduct from wallet: %w", err) } return fmt.Errorf("failed to execute transfer request: %w", err) } defer transferResp.Body.Close() if transferResp.StatusCode != http.StatusOK { _, err = s.walletSvc.AddToWallet( ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "", ) if err != nil { return fmt.Errorf("failed to deduct from wallet: %w", err) } 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", sessionRespData["sessionId"]), Status: string(domain.PaymentStatusPending), PaymentMethod: domain.TRANSFER_ARIFPAY, CashierID: domain.ValidInt64{ Value: userId, Valid: true, }, } if _, err := s.transferStore.CreateTransfer(ctx, transfer); err != nil { return fmt.Errorf("failed to store transfer: %w", err) } // Step 4: Deduct from wallet return nil } func (s *ArifpayService) ExecuteCBEB2CTransfer(ctx context.Context, req domain.CheckoutSessionClientRequest, userId int64) error { // Step 1: Deduct from user wallet first userWallet, err := s.walletSvc.GetCustomerWallet(ctx, userId) if err != nil { return fmt.Errorf("cbebirr: failed to get user wallet: %w", err) } _, err = s.walletSvc.DeductFromWallet( ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, "", ) if err != nil { return fmt.Errorf("cbebirr: failed to deduct from wallet: %w", err) } referenceNum := uuid.NewString() // Step 2: Create Session sessionReq := domain.CheckoutSessionClientRequest{ Amount: req.Amount, CustomerEmail: req.CustomerEmail, CustomerPhone: "251" + req.CustomerPhone[:9], } sessionResp, err := s.CreateCheckoutSession(sessionReq, false, userId) if err != nil { // refund wallet if session creation fails _, refundErr := s.walletSvc.AddToWallet( ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "", ) if refundErr != nil { return fmt.Errorf("cbebirr: refund failed after session creation error: %v", refundErr) } return fmt.Errorf("cbebirr: failed to create session: %w", err) } // Step 3: Execute Transfer transferURL := fmt.Sprintf("%s/api/Cbebirr/b2c/transfer", s.cfg.ARIFPAY.BaseURL) reqBody := map[string]any{ "Sessionid": sessionResp["sessionId"], "Phonenumber": "251" + req.CustomerPhone[:9], } payload, err := json.Marshal(reqBody) if err != nil { s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") 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 { s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") 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 { s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") return fmt.Errorf("cbebirr: failed to execute transfer request: %w", err) } defer transferResp.Body.Close() if transferResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(transferResp.Body) s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") return fmt.Errorf("cbebirr: transfer failed with status %d: %s", transferResp.StatusCode, string(body)) } // Step 4: 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, CashierID: domain.ValidInt64{ Value: userId, Valid: true, }, } if _, err := s.transferStore.CreateTransfer(ctx, transfer); err != nil { return fmt.Errorf("cbebirr: failed to store transfer: %w", err) } return nil } func (s *ArifpayService) ExecuteMPesaB2CTransfer(ctx context.Context, req domain.CheckoutSessionClientRequest, userId int64) error { // Step 1: Deduct from user wallet first userWallet, err := s.walletSvc.GetCustomerWallet(ctx, userId) if err != nil { return fmt.Errorf("mpesa: failed to get user wallet: %w", err) } _, err = s.walletSvc.DeductFromWallet( ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, "", ) if err != nil { return fmt.Errorf("mpesa: failed to deduct from wallet: %w", err) } referenceNum := uuid.NewString() // Step 2: Create Session sessionReq := domain.CheckoutSessionClientRequest{ Amount: req.Amount, CustomerEmail: req.CustomerEmail, CustomerPhone: "251" + req.CustomerPhone[:9], } sessionResp, err := s.CreateCheckoutSession(sessionReq, false, userId) if err != nil { // Refund wallet if session creation fails _, refundErr := s.walletSvc.AddToWallet( ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "", ) if refundErr != nil { return fmt.Errorf("mpesa: refund failed after session creation error: %v", refundErr) } return fmt.Errorf("mpesa: failed to create session: %w", err) } // Step 3: Execute Transfer transferURL := fmt.Sprintf("%s/api/Mpesa/b2c/transfer", s.cfg.ARIFPAY.BaseURL) reqBody := map[string]any{ "Sessionid": sessionResp["sessionId"], "Phonenumber": "251" + req.CustomerPhone[:9], } payload, err := json.Marshal(reqBody) if err != nil { s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") 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 { s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") 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 { s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") return fmt.Errorf("mpesa: failed to execute transfer request: %w", err) } defer transferResp.Body.Close() if transferResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(transferResp.Body) s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") return fmt.Errorf("mpesa: transfer failed with status %d: %s", transferResp.StatusCode, string(body)) } // Step 4: 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, CashierID: domain.ValidInt64{ Value: userId, Valid: true, }, } if _, err := s.transferStore.CreateTransfer(ctx, transfer); err != nil { return fmt.Errorf("mpesa: failed to store transfer: %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) } 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) 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"}, } }