# hyperpay-gateways/trunk/src/Traits/HasSubscription.php

HyperPay Payments, version trunk. 359 lines.

- Page: https://pluginprobe.com/plugins/hyperpay-gateways/trunk/code/src/Traits/HasSubscription.php
- Raw: https://pluginprobe.com/plugins/hyperpay-gateways/trunk/raw/src/Traits/HasSubscription.php
- Modified: 2025-12-21T16:14:38+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/hyperpay-gateways/trunk/code/src/Traits/HasSubscription.php#L10-L20`.

```php
<?php

/**
 * Trait: HasSubscription
 * Adds WooCommerce Subscriptions support for Hyperpay gateways.
 *
 * @package Hyperpay\Gateways\Traits
 */

namespace Hyperpay\Gateways\Traits;

defined('ABSPATH') || exit;

use Exception;
use Hyperpay\Gateways\Helpers\COF;
use WC_Order;
use WC_Payment_Token;
use WC_Payment_Tokens;
use WC_Subscription;
use Hyperpay\Gateways\Helpers\Http;
use Hyperpay\Gateways\Helpers\SubscriptionsManager;
use Hyperpay\Gateways\Helpers\TokenManager;

/**
 * Trait HasSubscription
 *
 * Provides support for WooCommerce Subscriptions:
 *  - Handles initial subscription payments and tokenization.
 *  - Handles scheduled subscription renewals (MIT).
 *  - Provides helper methods for recurring configuration and validation.
 */
trait HasSubscription
{
    /** @var string Recurring (MIT) entity ID. */
    protected string $recurring_entityId = '';

    /** @var string Recurring (MIT) access token. */
    protected string $recurring_accessToken = '';

    /**
     * Bootstraps subscription support and attaches relevant hooks.
     */
    public function bootHasSubscription(): void
    {
        $this->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,
        ]);
    }
}

```
