970 lines
29 KiB
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
|
|
}
|