recurring_entityId = $this->get_option('recurring_entityId') ?? ''; $this->recurring_accessToken = $this->get_option('recurring_accessToken') ?? ''; $this->supports = array_merge($this->supports, [ 'subscriptions', 'subscription_cancellation', 'subscription_suspension', 'subscription_reactivation', 'subscription_amount_changes', 'subscription_date_changes', 'subscription_payment_method_change', 'subscription_payment_method_change_customer', 'subscription_payment_method_change_admin', 'multiple_subscriptions', ]); $this->init_recurring_fields(); add_action('wc_ajax_recurring_update_checkout', [$this, 'updateCheckout']); add_action("hyperpay_payment_success_{$this->id}", [$this, 'handle_initial_payment_success'], 10, 2); add_action("woocommerce_scheduled_subscription_payment_{$this->id}", [$this, 'process_subscription_renewal'], 10, 3); } /** * Initializes recurring payment-related form fields. */ public function init_recurring_fields(): void { if (!class_exists('WC_Subscriptions_Order') && !function_exists('wcs_get_subscriptions_for_order')) { return; } $this->form_fields = array_merge($this->form_fields, [ 'recurring_entityId' => [ 'title' => __('Recurring Entity ID', 'hyperpay-gateways'), 'type' => 'password', 'description' => __('Required for recurring payments and saved cards. Obtain from Hyperpay dashboard. Must match store currency (e.g. SAR, USD, AED).', 'hyperpay-gateways'), ], 'recurring_accessToken' => [ 'title' => __('Recurring Secret', 'hyperpay-gateways'), 'type' => 'password', 'description' => __('Access token/secret for the recurring entity ID above.', 'hyperpay-gateways'), ], ]); } /** * Handles payment success and tokenization for initial subscription orders. * * @param WC_Order $order The order object. * @param array $response The gateway response. */ public function handle_initial_payment_success(WC_Order $order, array $response): void { $registrationId = $response['registrationId'] ?? null; if (empty($registrationId)) { return; } $agreementId = $response['customParameters']['recurringPaymentAgreement'] ?? ''; $initialTxId = $response['CardholderInitiatedTransactionID'] ?? $response['standingInstruction']['initialTransactionId'] ?? ''; $card = $response['card'] ?? []; // Enrich card data for storage. $card['recurringPaymentAgreement'] = $agreementId; $card['initial_tx_id'] = $initialTxId; $card['paymentBrand'] = $response['paymentBrand'] ?? 'UNKNOWN'; $token = TokenManager::save($registrationId, $card, $this->id); if ($token) { $subscriptions = SubscriptionsManager::getSubscriptionsForOrder($order, ['order_type' => 'any']); foreach ($subscriptions as $subscription) { /** @var WC_Subscription $subscription */ $subscription->update_meta_data('_payment_token_id', $token->get_id()); $subscription->save(); } } } /** * AJAX: Updates checkout session with registration parameters. */ public function updateCheckout(): void { try { if (empty($_POST['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'hyperpay_update_checkout')) { throw new Exception(esc_html__('Invalid request (nonce failed).', 'hyperpay-gateways')); } $orderId = sanitize_text_field(wp_unslash($_POST['orderId'] ?? '')); $checkoutId = sanitize_text_field(wp_unslash($_POST['checkoutId'] ?? '')); $createReg = sanitize_text_field(wp_unslash($_POST['createRegistration'] ?? 'false')) === 'true'; $selectedCard = sanitize_text_field(wp_unslash($_POST['selectedCard'] ?? '')); // nosemgrep: audit.php.wp.security.xss.sanitized-var-passed-to-method if (!$orderId || !$checkoutId) { $this->send_json(false, __('Missing required parameters.', 'hyperpay-gateways')); } $order = $this->get_validated_order($orderId); $isSubscription = SubscriptionsManager::orderContainsSubscription($order); // disable tokenization for guest users if (!is_user_logged_in() && !$isSubscription) { $this->send_json(true, __('No registration needed for guest users.', 'hyperpay-gateways')); return; } if($isSubscription && !is_user_logged_in()){ throw new Exception(esc_html__('You must be logged in to purchase a subscription.', 'hyperpay-gateways')); } $needsRegistration = $createReg || $isSubscription || !empty($selectedCard); if (!$needsRegistration) { $this->send_json(true, __('No registration needed.', 'hyperpay-gateways')); } $citParams = $this->get_cit_parameters($order, $selectedCard); if ($createReg) { $citParams['createRegistration'] = 'true'; } $data = $this->getBasicData(); $data['body'] = array_merge($data['body'], $citParams); $url = $this->ACI_base_url . '/v1/checkouts/' . $checkoutId; $response = Http::post($url, $data); if (preg_match("/^(000\.200)/", $response['result']['code'] ?? '')) { $this->send_json(true, __('Checkout updated successfully.', 'hyperpay-gateways')); } $errorMessage = $response['result']['description'] ?? __('Unknown error', 'hyperpay-gateways'); $errorCode = $response['result']['code'] ?? 'N/A'; /* translators: %s: error message, %s: error code */ $this->send_json(false, sprintf(__('Payment update failed: %1$s (Code: %2$s)', 'hyperpay-gateways'), $errorMessage, $errorCode)); } catch (Exception $e) { /* translators: %s: error message */ $this->send_json(false, sprintf(__('Checkout update failed: %s', 'hyperpay-gateways'), $e->getMessage())); } } /** * Processes a subscription renewal payment (MIT). * * @param float $amount The amount to charge. * @param WC_Order $renewalOrder The renewal order. */ public function process_subscription_renewal(float $amount, WC_Order $renewalOrder): void { $tokenId = null; $subscriptions = SubscriptionsManager::getSubscriptionsForOrder($renewalOrder, ['order_type' => 'renewal']); foreach ($subscriptions as $subscription) { /** @var WC_Subscription $subscription */ $tokenId = $subscription->get_meta('_payment_token_id', true); if ($tokenId) break; } $token = $tokenId ? WC_Payment_Tokens::get($tokenId) : null; if (!$token || !$token->get_token()) { $renewalOrder->add_order_note(__('No payment token found for subscription.', 'hyperpay-gateways')); $renewalOrder->update_status('failed'); return; } if ($amount <= 0) { $renewalOrder->payment_complete(); $renewalOrder->add_order_note(__('No payment required for this renewal order.', 'hyperpay-gateways')); return; } try { $result = $this->process_recurring_payment($renewalOrder, $token, $amount); $renewalOrder->add_order_note($result['message']); $renewalOrder->update_status($result['status'] ?? 'failed'); } catch (Exception $e) { $renewalOrder->update_status('failed'); } } /** * Performs the recurring (MIT) charge request via Hyperpay API. * * @return array{status:string, transaction_id:string, message:string} */ protected function process_recurring_payment(WC_Order $renewalOrder, WC_Payment_Token $token, float $amount): array { $registrationId = $token->get_token(); $url = $this->ACI_base_url . '/v1/registrations/' . $registrationId . '/payments'; $orderId = $renewalOrder->get_id(); $initialTxId = $token->get_meta('initial_transaction_id') ?: ''; $agreementId = $token->get_meta('agreement_id') ?: $renewalOrder->get_meta('agreement_id'); $transactionKey = wp_rand(11111111, 99999999); $MIT = COF::build_mit_params($agreementId, $initialTxId); $body = [ 'entityId' => $this->recurring_entityId, 'amount' => number_format($amount, 2, '.', ''), 'currency' => $renewalOrder->get_currency(), 'paymentType' => $this->trans_type, 'customer.email' => $renewalOrder->get_billing_email(), 'merchantTransactionId' => $orderId . 'I' . $transactionKey, 'customParameters[bill_number]' => $orderId, ]; if ($this->testMode) { $body['testMode'] = 'EXTERNAL'; } $data = [ 'headers' => ['Authorization' => 'Bearer ' . $this->recurring_accessToken], 'body' => \array_merge($body, $MIT), ]; $response = Http::post($url, $data); $status = 'failed'; $result = $response['result'] ?? []; $transactionId = $response['id'] ?? ''; $message = sprintf( /* translators: 1: error message, 2: error code, 3: error description */ __('Recurring payment failed: %1$s (Code: %2$s), Description: %3$s', 'hyperpay-gateways'), $result['description'] ?? 'Unknown error', $result['code'] ?? 'N/A', $result['code'] ?? 'N/A', ); if ($this->is_successful_response($response)) { $status = 'completed'; /* translators: %s: Transaction ID */ $message = sprintf(__('Recurring payment processed successfully. Transaction ID: %s', 'hyperpay-gateways'), $transactionId); } return compact('status', 'transactionId', 'message'); } /** * Prepares CIT parameters for an initial subscription transaction. */ protected function get_cit_parameters(WC_Order $order, ?string $selectedCard): array { $tokenObj = null; if ($selectedCard) { $tokens = WC_Payment_Tokens::get_customer_tokens(get_current_user_id(), $this->id); foreach ($tokens as $token) { if ($token->get_token() === $selectedCard) { $tokenObj = $token; break; } } } $agreementId = $tokenObj ? $tokenObj->get_meta('agreement_id') : $this->generate_agreement_id_for_order($order); $order->update_meta_data('agreement_id', $agreementId); $order->save(); return COF::build_cit_params($agreementId); } /** * Generates a unique recurring agreement ID for an order. */ protected function generate_agreement_id_for_order(WC_Order $order): string { $customerId = $order->get_customer_id() ?: 0; return sprintf('AGR%s%s%s', $order->get_id(), $customerId, time()); } /** Retrieves and validates order ownership. */ protected function get_validated_order(int $orderId): WC_Order { $order = wc_get_order($orderId); // nosemgrep: audit.php.wp.security.xss.sanitized-var-passed-to-method if (!$order) { throw new Exception(esc_html__('Order not found.', 'hyperpay-gateways')); } $currentUserId = get_current_user_id(); if ($order->get_customer_id() && $order->get_customer_id() !== $currentUserId) { throw new Exception(esc_html__('You do not have permission to update this order.', 'hyperpay-gateways')); } return $order; } /** Sends a JSON response and terminates execution. */ protected function send_json(bool $success, string $message = ''): void { wp_send_json([ 'success' => $success, 'message' => $message, ]); } }