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

970 lines
29 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,
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)
}
// Create ArifPay checkout session with specific payment method
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: []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,
}
// Create session
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.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"])
// 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)
}
// Now initiate direct transfer based on payment method
directResp, err := s.initiateDirectTransfer(ctx, sessionID, formatPhone(req.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)
}
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
}
// 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
}