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 }