Yimaru-BackEnd/internal/services/arifpay/service.go
Yared Yemane 7a4253edf4 Add explicit payment provider selection for subscriptions.
Require the client to choose CHAPA or ARIFPAY in the subscription checkout request body and route payment initiation and verification through the matching provider.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 04:18:24 -07:00

1063 lines
32 KiB
Go

package arifpay
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"Yimaru-Backend/internal/config"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"github.com/google/uuid"
)
var (
ErrPaymentNotFound = errors.New("payment not found")
ErrPaymentAlreadyPaid = errors.New("payment already processed")
ErrPaymentExpired = errors.New("payment has expired")
ErrInvalidPaymentState = errors.New("invalid payment state")
)
type ArifpayService struct {
cfg *config.Config
httpClient *http.Client
paymentStore ports.PaymentStore
subscriptionStore ports.SubscriptionStore
}
func NewArifpayService(
cfg *config.Config,
httpClient *http.Client,
paymentStore ports.PaymentStore,
subscriptionStore ports.SubscriptionStore,
) *ArifpayService {
return &ArifpayService{
cfg: cfg,
httpClient: httpClient,
paymentStore: paymentStore,
subscriptionStore: subscriptionStore,
}
}
// InitiateSubscriptionPayment creates a payment session for a subscription plan
func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID int64, req domain.InitiateSubscriptionPaymentRequest) (*domain.InitiateSubscriptionPaymentResponse, error) {
// Get the subscription plan
plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, req.PlanID)
if err != nil {
return nil, fmt.Errorf("failed to get subscription plan: %w", err)
}
if !plan.IsActive {
return nil, errors.New("subscription plan is not active")
}
// Check if user already has an active subscription
hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to check active subscription: %w", err)
}
if hasActive {
return nil, errors.New("user already has an active subscription")
}
// Generate unique nonce
nonce := uuid.NewString()
expiresAt := time.Now().Add(3 * time.Hour)
// Create payment record
payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{
UserID: userID,
PlanID: &req.PlanID,
Amount: plan.Price,
Currency: plan.Currency,
PaymentMethod: func() *string {
v := string(domain.PaymentProviderArifPay)
return &v
}(),
Nonce: nonce,
ExpiresAt: &expiresAt,
})
if err != nil {
return nil, fmt.Errorf("failed to create payment record: %w", err)
}
// Create ArifPay checkout session
checkoutReq := domain.CheckoutSessionRequest{
CancelURL: s.cfg.ARIFPAY.CancelUrl,
Phone: formatPhone(req.Phone),
Email: req.Email,
Nonce: nonce,
SuccessURL: s.cfg.ARIFPAY.SuccessUrl,
ErrorURL: s.cfg.ARIFPAY.ErrorUrl,
NotifyURL: s.cfg.ARIFPAY.C2BNotifyUrl,
PaymentMethods: s.cfg.ARIFPAY.PaymentMethods,
ExpireDate: expiresAt.Format("2006-01-02T15:04:05"),
Items: []struct {
Name string `json:"name"`
Quantity int `json:"quantity"`
Price float64 `json:"price"`
Description string `json:"description"`
}{
{
Name: plan.Name,
Quantity: 1,
Price: plan.Price,
Description: fmt.Sprintf("Subscription: %s", plan.Name),
},
},
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: plan.Price,
},
},
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.NewRequestWithContext(ctx, "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, fmt.Errorf("failed to call ArifPay API: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ArifPay API error: %s", string(body))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("invalid response from ArifPay: %w", err)
}
data, ok := result["data"].(map[string]interface{})
if !ok {
return nil, errors.New("invalid response structure from ArifPay")
}
sessionID := fmt.Sprintf("%v", data["sessionId"])
paymentURL := fmt.Sprintf("%v", data["paymentUrl"])
// Update payment with session info
if err := s.paymentStore.UpdatePaymentSessionID(ctx, payment.ID, sessionID, paymentURL); err != nil {
return nil, fmt.Errorf("failed to update payment session: %w", err)
}
return &domain.InitiateSubscriptionPaymentResponse{
PaymentID: payment.ID,
SessionID: sessionID,
PaymentURL: paymentURL,
Amount: plan.Price,
Currency: plan.Currency,
ExpiresAt: expiresAt.Format(time.RFC3339),
}, nil
}
// ProcessPaymentWebhook handles the webhook callback from ArifPay
func (s *ArifpayService) ProcessPaymentWebhook(ctx context.Context, req domain.WebhookRequest) error {
// Get payment by nonce
payment, err := s.paymentStore.GetPaymentByNonce(ctx, req.Nonce)
if err != nil {
return fmt.Errorf("payment not found for nonce %s: %w", req.Nonce, err)
}
if payment.Status == string(domain.PaymentStatusSuccess) {
return ErrPaymentAlreadyPaid
}
transactionStatus := strings.ToUpper(req.Transaction.TransactionStatus)
if transactionStatus == "" {
transactionStatus = strings.ToUpper(req.TransactionStatus)
}
var newStatus string
switch transactionStatus {
case "SUCCESS", "COMPLETED":
newStatus = string(domain.PaymentStatusSuccess)
case "FAILED", "REJECTED":
newStatus = string(domain.PaymentStatusFailed)
case "CANCELLED":
newStatus = string(domain.PaymentStatusCancelled)
case "PENDING", "PROCESSING":
newStatus = string(domain.PaymentStatusProcessing)
default:
newStatus = string(domain.PaymentStatusPending)
}
// Update payment status
if err := s.paymentStore.UpdatePaymentStatusByNonce(
ctx,
req.Nonce,
newStatus,
req.Transaction.TransactionID,
req.PaymentMethod,
); err != nil {
return fmt.Errorf("failed to update payment status: %w", err)
}
// If payment succeeded, create the subscription
if newStatus == string(domain.PaymentStatusSuccess) && payment.PlanID != nil {
plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, *payment.PlanID)
if err != nil {
return fmt.Errorf("failed to get subscription plan: %w", err)
}
startsAt := time.Now()
expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit)
activeStatus := string(domain.SubscriptionStatusActive)
autoRenew := false
paymentRef := payment.Nonce
paymentMethod := req.PaymentMethod
subscription, err := s.subscriptionStore.CreateUserSubscription(ctx, domain.CreateUserSubscriptionInput{
UserID: payment.UserID,
PlanID: *payment.PlanID,
StartsAt: &startsAt,
ExpiresAt: expiresAt,
Status: &activeStatus,
PaymentReference: &paymentRef,
PaymentMethod: &paymentMethod,
AutoRenew: &autoRenew,
})
if err != nil {
return fmt.Errorf("failed to create subscription: %w", err)
}
// Link payment to subscription
if err := s.paymentStore.LinkPaymentToSubscription(ctx, payment.ID, subscription.ID); err != nil {
return fmt.Errorf("failed to link payment to subscription: %w", err)
}
}
return nil
}
// VerifyPayment checks the status of a payment with ArifPay
func (s *ArifpayService) VerifyPayment(ctx context.Context, sessionID string) (*domain.Payment, error) {
// Get local payment record
payment, err := s.paymentStore.GetPaymentBySessionID(ctx, sessionID)
if err != nil {
return nil, ErrPaymentNotFound
}
// If already success or failed, return cached result
if payment.Status == string(domain.PaymentStatusSuccess) ||
payment.Status == string(domain.PaymentStatusFailed) {
return payment, nil
}
// Call ArifPay to verify
endpoint := fmt.Sprintf("%s/api/ms/transaction/status/%s", s.cfg.ARIFPAY.BaseURL, sessionID)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey)
resp, err := s.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to verify with ArifPay: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ArifPay verify API error: %s", string(respBytes))
}
var result domain.WebhookRequest
if err := json.Unmarshal(respBytes, &result); err != nil {
return nil, fmt.Errorf("failed to parse ArifPay response: %w", err)
}
// Process the verification result same as webhook
if err := s.ProcessPaymentWebhook(ctx, result); err != nil && err != ErrPaymentAlreadyPaid {
return nil, err
}
// Return updated payment
return s.paymentStore.GetPaymentBySessionID(ctx, sessionID)
}
// GetPaymentsByUser returns payment history for a user
func (s *ArifpayService) GetPaymentsByUser(ctx context.Context, userID int64, limit, offset int32) ([]domain.Payment, error) {
return s.paymentStore.GetPaymentsByUserID(ctx, userID, limit, offset)
}
// GetPaymentByID returns a specific payment
func (s *ArifpayService) GetPaymentByID(ctx context.Context, id int64) (*domain.Payment, error) {
return s.paymentStore.GetPaymentByID(ctx, id)
}
// CancelPayment cancels a pending payment
func (s *ArifpayService) CancelPayment(ctx context.Context, paymentID int64, userID int64) error {
payment, err := s.paymentStore.GetPaymentByID(ctx, paymentID)
if err != nil {
return ErrPaymentNotFound
}
if payment.UserID != userID {
return errors.New("unauthorized")
}
if payment.Status != string(domain.PaymentStatusPending) {
return ErrInvalidPaymentState
}
return s.paymentStore.UpdatePaymentStatus(ctx, paymentID, string(domain.PaymentStatusCancelled))
}
// CreateCheckoutSession creates a generic checkout session (for non-subscription payments)
func (s *ArifpayService) CreateCheckoutSession(req domain.CheckoutSessionClientRequest, isDeposit bool, userId int64) (map[string]any, error) {
nonce := uuid.NewString()
var NotifyURL string
if isDeposit {
NotifyURL = s.cfg.ARIFPAY.C2BNotifyUrl
} else {
NotifyURL = s.cfg.ARIFPAY.B2CNotifyUrl
}
checkoutReq := domain.CheckoutSessionRequest{
CancelURL: s.cfg.ARIFPAY.CancelUrl,
Phone: formatPhone(req.CustomerPhone),
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,
}
payload, err := json.Marshal(checkoutReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal checkout request: %w", err)
}
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()
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))
}
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{})
return data, nil
}
// CancelCheckoutSession cancels an existing checkout session
func (s *ArifpayService) CancelCheckoutSession(ctx context.Context, sessionID string) (any, error) {
url := fmt.Sprintf("%s/api/sandbox/checkout/session/%s", s.cfg.ARIFPAY.BaseURL, sessionID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
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)
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute cancel request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read cancel response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("cancel request failed: status=%d, body=%s", resp.StatusCode, string(body))
}
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
}
// VerifyTransactionBySessionID verifies a transaction by session ID
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)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey)
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()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBytes))
}
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
}
// VerifyTransactionByTransactionID verifies a transaction by transaction ID
func (s *ArifpayService) VerifyTransactionByTransactionID(ctx context.Context, req domain.ArifpayVerifyByTransactionIDRequest) (*domain.WebhookRequest, error) {
endpoint := fmt.Sprintf("%s/api/checkout/getSessionByTransactionId", s.cfg.ARIFPAY.BaseURL)
bodyBytes, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
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)
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()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBytes))
}
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
}
// GetPaymentMethodsMapping returns the list of payment methods
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"},
}
}
// =====================
// Direct Payment Methods (OTP-based)
// =====================
// InitiateDirectPayment creates a session and initiates direct payment (triggers OTP)
func (s *ArifpayService) InitiateDirectPayment(ctx context.Context, userID int64, req domain.InitiateDirectPaymentRequest) (*domain.InitiateDirectPaymentResponse, error) {
// Get the subscription plan
plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, req.PlanID)
if err != nil {
return nil, fmt.Errorf("failed to get subscription plan: %w", err)
}
if !plan.IsActive {
return nil, errors.New("subscription plan is not active")
}
// Check if user already has an active subscription
hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to check active subscription: %w", err)
}
if hasActive {
return nil, errors.New("user already has an active subscription")
}
// Generate unique nonce
nonce := uuid.NewString()
expiresAt := time.Now().Add(15 * time.Minute) // Shorter expiry for direct payments
// Create payment record
paymentMethod := string(req.PaymentMethod)
payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{
UserID: userID,
PlanID: &req.PlanID,
Amount: plan.Price,
Currency: plan.Currency,
Nonce: nonce,
PaymentMethod: &paymentMethod,
ExpiresAt: &expiresAt,
})
if err != nil {
return nil, fmt.Errorf("failed to create payment record: %w", err)
}
phone := formatPhone(req.Phone)
checkoutReq := s.buildDirectCheckoutRequest(req, phone, nonce, expiresAt, plan)
var (
sessionID string
directResp string
)
if req.PaymentMethod == domain.DirectPaymentTelebirrUSSD || req.PaymentMethod == domain.DirectPaymentMPesa {
// TELEBIRR_USSD and MPESA use direct proxy endpoints with full checkout payload.
sessionID, directResp, err = s.initiateFullPayloadDirectProxy(ctx, checkoutReq, req.PaymentMethod)
} else {
sessionID, err = s.createCheckoutSessionForDirect(ctx, checkoutReq)
if err == nil {
directResp, err = s.initiateDirectTransfer(ctx, sessionID, phone, req.PaymentMethod)
}
}
if err != nil {
// Update payment status to failed
s.paymentStore.UpdatePaymentStatus(ctx, payment.ID, string(domain.PaymentStatusFailed))
return nil, fmt.Errorf("failed to initiate direct transfer: %w", err)
}
// Update payment with session info
if err := s.paymentStore.UpdatePaymentSessionID(ctx, payment.ID, sessionID, ""); err != nil {
return nil, fmt.Errorf("failed to update payment session: %w", err)
}
requiresOTP := s.paymentMethodRequiresOTP(req.PaymentMethod)
message := "Payment initiated"
if requiresOTP {
message = "OTP sent to your phone. Please verify to complete payment."
} else {
message = directResp
}
return &domain.InitiateDirectPaymentResponse{
PaymentID: payment.ID,
SessionID: sessionID,
RequiresOTP: requiresOTP,
Message: message,
Amount: plan.Price,
Currency: plan.Currency,
}, nil
}
func (s *ArifpayService) buildDirectCheckoutRequest(
req domain.InitiateDirectPaymentRequest,
phone string,
nonce string,
expiresAt time.Time,
plan *domain.SubscriptionPlan,
) domain.CheckoutSessionRequest {
return domain.CheckoutSessionRequest{
CancelURL: s.cfg.ARIFPAY.CancelUrl,
Phone: phone,
Email: req.Email,
Nonce: nonce,
SuccessURL: s.cfg.ARIFPAY.SuccessUrl,
ErrorURL: s.cfg.ARIFPAY.ErrorUrl,
NotifyURL: s.cfg.ARIFPAY.C2BNotifyUrl,
PaymentMethods: []string{string(req.PaymentMethod)},
ExpireDate: expiresAt.Format("2006-01-02T15:04:05"),
Items: []struct {
Name string `json:"name"`
Quantity int `json:"quantity"`
Price float64 `json:"price"`
Description string `json:"description"`
}{
{
Name: plan.Name,
Quantity: 1,
Price: plan.Price,
Description: fmt.Sprintf("Subscription: %s", plan.Name),
},
},
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: plan.Price,
},
},
Lang: s.cfg.ARIFPAY.Lang,
}
}
func (s *ArifpayService) createCheckoutSessionForDirect(ctx context.Context, checkoutReq domain.CheckoutSessionRequest) (string, error) {
payload, err := json.Marshal(checkoutReq)
if err != nil {
return "", fmt.Errorf("failed to marshal checkout request: %w", err)
}
url := fmt.Sprintf("%s/api/checkout/session", s.cfg.ARIFPAY.BaseURL)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(payload))
if err != nil {
return "", 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 "", fmt.Errorf("failed to call ArifPay API: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("ArifPay API error: %s", string(body))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("invalid response from ArifPay: %w", err)
}
data, ok := result["data"].(map[string]interface{})
if !ok {
return "", errors.New("invalid response structure from ArifPay")
}
return fmt.Sprintf("%v", data["sessionId"]), nil
}
func (s *ArifpayService) initiateFullPayloadDirectProxy(
ctx context.Context,
checkoutReq domain.CheckoutSessionRequest,
method domain.DirectPaymentMethod,
) (string, string, error) {
payload, err := json.Marshal(checkoutReq)
if err != nil {
return "", "", fmt.Errorf("failed to marshal direct proxy request: %w", err)
}
var endpoint string
switch method {
case domain.DirectPaymentTelebirrUSSD:
endpoint = fmt.Sprintf("%s/api/checkout/telebirr-ussd/transfer/direct", s.cfg.ARIFPAY.BaseURL)
case domain.DirectPaymentMPesa:
endpoint = fmt.Sprintf("%s/api/checkout/mpesa/transfer/direct", s.cfg.ARIFPAY.BaseURL)
default:
return "", "", fmt.Errorf("unsupported full-payload direct proxy method: %s", method)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(payload))
if err != nil {
return "", "", 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 "", "", fmt.Errorf("failed to call direct proxy API: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", err
}
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("direct proxy request failed: %s", string(body))
}
var result struct {
Msg string `json:"msg"`
Data struct {
SessionID interface{} `json:"sessionId"`
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", "", fmt.Errorf("invalid direct proxy response: %w", err)
}
sessionID := fmt.Sprintf("%v", result.Data.SessionID)
if sessionID == "" || sessionID == "<nil>" {
sessionID = checkoutReq.Nonce
}
message := result.Msg
if message == "" {
message = "USSD transfer initiated"
}
if result.Data.URL != "" {
message = result.Data.URL
}
return sessionID, message, nil
}
// initiateDirectTransfer calls the appropriate direct transfer endpoint
func (s *ArifpayService) initiateDirectTransfer(ctx context.Context, sessionID, phone string, method domain.DirectPaymentMethod) (string, error) {
var endpoint string
switch method {
case domain.DirectPaymentTelebirr, domain.DirectPaymentTelebirrUSSD:
endpoint = fmt.Sprintf("%s/api/checkout/telebirr/direct/transfer", s.cfg.ARIFPAY.BaseURL)
case domain.DirectPaymentCBE:
endpoint = fmt.Sprintf("%s/api/checkout/cbe/direct/transfer", s.cfg.ARIFPAY.BaseURL)
case domain.DirectPaymentAmole:
endpoint = fmt.Sprintf("%s/api/checkout/amole/direct/transfer", s.cfg.ARIFPAY.BaseURL)
case domain.DirectPaymentHelloCash:
endpoint = fmt.Sprintf("%s/api/checkout/hellocash/direct/transfer", s.cfg.ARIFPAY.BaseURL)
case domain.DirectPaymentAwash:
endpoint = fmt.Sprintf("%s/api/checkout/awash/direct/transfer", s.cfg.ARIFPAY.BaseURL)
case domain.DirectPaymentMPesa:
endpoint = fmt.Sprintf("%s/api/Mpesa/c2b/transfer", s.cfg.ARIFPAY.BaseURL)
default:
return "", fmt.Errorf("unsupported direct payment method: %s", method)
}
reqBody := map[string]string{
"sessionId": sessionID,
"phoneNumber": phone,
}
payload, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(payload))
if err != nil {
return "", 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 "", fmt.Errorf("failed to initiate direct transfer: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("direct transfer failed: %s", string(body))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return string(body), nil
}
if msg, ok := result["msg"].(string); ok {
return msg, nil
}
if msg, ok := result["message"].(string); ok {
return msg, nil
}
return "Transfer initiated successfully", nil
}
// VerifyDirectPaymentOTP verifies the OTP for direct payment methods
func (s *ArifpayService) VerifyDirectPaymentOTP(ctx context.Context, userID int64, req domain.VerifyOTPRequest) (*domain.VerifyOTPResponse, error) {
// Get payment by session ID
payment, err := s.paymentStore.GetPaymentBySessionID(ctx, req.SessionID)
if err != nil {
return nil, ErrPaymentNotFound
}
// Verify ownership
if payment.UserID != userID {
return nil, errors.New("unauthorized")
}
// Check payment status
if payment.Status == string(domain.PaymentStatusSuccess) {
return &domain.VerifyOTPResponse{
Success: true,
Message: "Payment already completed",
PaymentID: payment.ID,
}, nil
}
if payment.Status != string(domain.PaymentStatusPending) && payment.Status != string(domain.PaymentStatusProcessing) {
return nil, ErrInvalidPaymentState
}
// Determine OTP verification endpoint based on payment method
paymentMethod := ""
if payment.PaymentMethod != nil {
paymentMethod = *payment.PaymentMethod
}
endpoint, err := s.getOTPVerifyEndpoint(domain.DirectPaymentMethod(paymentMethod))
if err != nil {
return nil, err
}
// Call OTP verification
reqBody := map[string]string{
"sessionId": req.SessionID,
"otp": req.OTP,
}
// Some endpoints use different field names
if paymentMethod == string(domain.DirectPaymentAmole) {
reqBody["SessionId"] = req.SessionID
}
payload, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, 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, fmt.Errorf("failed to verify OTP: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result map[string]interface{}
json.Unmarshal(body, &result)
if resp.StatusCode != http.StatusOK {
errMsg := "OTP verification failed"
if msg, ok := result["msg"].(string); ok {
errMsg = msg
} else if msg, ok := result["message"].(string); ok {
errMsg = msg
}
return &domain.VerifyOTPResponse{
Success: false,
Message: errMsg,
}, nil
}
// OTP verified successfully - update payment status
transactionID := ""
if tid, ok := result["transactionId"].(string); ok {
transactionID = tid
}
// Update payment to success
if err := s.paymentStore.UpdatePaymentStatusBySessionID(
ctx,
req.SessionID,
string(domain.PaymentStatusSuccess),
transactionID,
paymentMethod,
); err != nil {
return nil, fmt.Errorf("failed to update payment status: %w", err)
}
// Create subscription
if payment.PlanID != nil {
plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, *payment.PlanID)
if err == nil {
startsAt := time.Now()
expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit)
activeStatus := string(domain.SubscriptionStatusActive)
autoRenew := false
paymentRef := payment.Nonce
subscription, err := s.subscriptionStore.CreateUserSubscription(ctx, domain.CreateUserSubscriptionInput{
UserID: payment.UserID,
PlanID: *payment.PlanID,
StartsAt: &startsAt,
ExpiresAt: expiresAt,
Status: &activeStatus,
PaymentReference: &paymentRef,
PaymentMethod: &paymentMethod,
AutoRenew: &autoRenew,
})
if err == nil {
s.paymentStore.LinkPaymentToSubscription(ctx, payment.ID, subscription.ID)
}
}
}
return &domain.VerifyOTPResponse{
Success: true,
Message: "Payment completed successfully",
TransactionID: transactionID,
PaymentID: payment.ID,
}, nil
}
// getOTPVerifyEndpoint returns the OTP verification endpoint for the payment method
func (s *ArifpayService) getOTPVerifyEndpoint(method domain.DirectPaymentMethod) (string, error) {
switch method {
case domain.DirectPaymentAmole:
return fmt.Sprintf("%s/api/checkout/amole/direct/verifyOTP", s.cfg.ARIFPAY.BaseURL), nil
case domain.DirectPaymentHelloCash:
return fmt.Sprintf("%s/api/checkout/hellocash/direct/verify", s.cfg.ARIFPAY.BaseURL), nil
case domain.DirectPaymentCBE:
return fmt.Sprintf("%s/api/checkout/cbe/direct/verify", s.cfg.ARIFPAY.BaseURL), nil
case domain.DirectPaymentAwash:
return fmt.Sprintf("%s/api/checkout/awash/direct/verify", s.cfg.ARIFPAY.BaseURL), nil
default:
return "", fmt.Errorf("payment method %s does not require OTP verification", method)
}
}
// paymentMethodRequiresOTP checks if the payment method requires OTP verification
func (s *ArifpayService) paymentMethodRequiresOTP(method domain.DirectPaymentMethod) bool {
switch method {
case domain.DirectPaymentAmole, domain.DirectPaymentHelloCash, domain.DirectPaymentAwash:
return true
case domain.DirectPaymentTelebirr, domain.DirectPaymentTelebirrUSSD, domain.DirectPaymentCBE, domain.DirectPaymentMPesa:
// These use push notification or USSD, no OTP needed in API
return false
default:
return false
}
}
// GetDirectPaymentMethods returns payment methods that support direct payment
func (s *ArifpayService) GetDirectPaymentMethods() []domain.ARIFPAYPaymentMethod {
return []domain.ARIFPAYPaymentMethod{
{ID: 1, Name: string(domain.DirectPaymentTelebirr)},
{ID: 2, Name: string(domain.DirectPaymentTelebirrUSSD)},
{ID: 3, Name: string(domain.DirectPaymentCBE)},
{ID: 4, Name: string(domain.DirectPaymentAmole)},
{ID: 5, Name: string(domain.DirectPaymentHelloCash)},
{ID: 6, Name: string(domain.DirectPaymentAwash)},
{ID: 7, Name: string(domain.DirectPaymentMPesa)},
}
}
// Helper to format phone number
func formatPhone(phone string) string {
phone = strings.TrimSpace(phone)
if strings.HasPrefix(phone, "+251") {
return strings.TrimPrefix(phone, "+")
}
if strings.HasPrefix(phone, "0") {
return "251" + phone[1:]
}
if strings.HasPrefix(phone, "251") {
return phone
}
return "251" + phone
}