Yimaru-BackEnd/internal/services/arifpay/service.go

585 lines
17 KiB
Go

package arifpay
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
"github.com/google/uuid"
)
type ArifpayService struct {
cfg *config.Config
transferStore wallet.TransferStore
walletSvc *wallet.Service
httpClient *http.Client
}
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.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
}
// 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),
}
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) (*domain.CancelCheckoutSessionResponse, 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, nil
}
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
}
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)
}
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"},
}
}