PluginProbe
HyperPay Payments / trunk
HyperPay Payments vtrunk
6.6.0 trunk 1.7 1.8 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.1.0 2.2.0 2.3.0 2.3.1 2.3.2 2.3.3 3.0.0 3.0.5 4.0.0 4.0.2 All 34 releases
hyperpay-gateways / src / Traits / HasSubscription.php

HasSubscription.php in HyperPay Payments trunk, at src/Traits/HasSubscription.php

359 lines 13.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Trait: HasSubscription
5 * Adds WooCommerce Subscriptions support for Hyperpay gateways.
6 *
7 * @package Hyperpay\Gateways\Traits
8 */
9
10 namespace Hyperpay\Gateways\Traits;
11
12 defined('ABSPATH') || exit;
13
14 use Exception;
15 use Hyperpay\Gateways\Helpers\COF;
16 use WC_Order;
17 use WC_Payment_Token;
18 use WC_Payment_Tokens;
19 use WC_Subscription;
20 use Hyperpay\Gateways\Helpers\Http;
21 use Hyperpay\Gateways\Helpers\SubscriptionsManager;
22 use Hyperpay\Gateways\Helpers\TokenManager;
23
24 /**
25 * Trait HasSubscription
26 *
27 * Provides support for WooCommerce Subscriptions:
28 * - Handles initial subscription payments and tokenization.
29 * - Handles scheduled subscription renewals (MIT).
30 * - Provides helper methods for recurring configuration and validation.
31 */
32 trait HasSubscription
33 {
34 /** @var string Recurring (MIT) entity ID. */
35 protected string $recurring_entityId = '';
36
37 /** @var string Recurring (MIT) access token. */
38 protected string $recurring_accessToken = '';
39
40 /**
41 * Bootstraps subscription support and attaches relevant hooks.
42 */
43 public function bootHasSubscription(): void
44 {
45 $this->recurring_entityId = $this->get_option('recurring_entityId') ?? '';
46 $this->recurring_accessToken = $this->get_option('recurring_accessToken') ?? '';
47
48 $this->supports = array_merge($this->supports, [
49 'subscriptions',
50 'subscription_cancellation',
51 'subscription_suspension',
52 'subscription_reactivation',
53 'subscription_amount_changes',
54 'subscription_date_changes',
55 'subscription_payment_method_change',
56 'subscription_payment_method_change_customer',
57 'subscription_payment_method_change_admin',
58 'multiple_subscriptions',
59 ]);
60
61 $this->init_recurring_fields();
62
63 add_action('wc_ajax_recurring_update_checkout', [$this, 'updateCheckout']);
64 add_action("hyperpay_payment_success_{$this->id}", [$this, 'handle_initial_payment_success'], 10, 2);
65 add_action("woocommerce_scheduled_subscription_payment_{$this->id}", [$this, 'process_subscription_renewal'], 10, 3);
66 }
67
68 /**
69 * Initializes recurring payment-related form fields.
70 */
71 public function init_recurring_fields(): void
72 {
73 if (!class_exists('WC_Subscriptions_Order') && !function_exists('wcs_get_subscriptions_for_order')) {
74 return;
75 }
76
77 $this->form_fields = array_merge($this->form_fields, [
78 'recurring_entityId' => [
79 'title' => __('Recurring Entity ID', 'hyperpay-gateways'),
80 'type' => 'password',
81 'description' => __('Required for recurring payments and saved cards. Obtain from Hyperpay dashboard. Must match store currency (e.g. SAR, USD, AED).', 'hyperpay-gateways'),
82 ],
83 'recurring_accessToken' => [
84 'title' => __('Recurring Secret', 'hyperpay-gateways'),
85 'type' => 'password',
86 'description' => __('Access token/secret for the recurring entity ID above.', 'hyperpay-gateways'),
87 ],
88 ]);
89 }
90
91 /**
92 * Handles payment success and tokenization for initial subscription orders.
93 *
94 * @param WC_Order $order The order object.
95 * @param array $response The gateway response.
96 */
97 public function handle_initial_payment_success(WC_Order $order, array $response): void
98 {
99 $registrationId = $response['registrationId'] ?? null;
100 if (empty($registrationId)) {
101 return;
102 }
103
104 $agreementId = $response['customParameters']['recurringPaymentAgreement'] ?? '';
105 $initialTxId = $response['CardholderInitiatedTransactionID'] ??
106 $response['standingInstruction']['initialTransactionId'] ??
107 '';
108
109 $card = $response['card'] ?? [];
110
111 // Enrich card data for storage.
112 $card['recurringPaymentAgreement'] = $agreementId;
113 $card['initial_tx_id'] = $initialTxId;
114 $card['paymentBrand'] = $response['paymentBrand'] ?? 'UNKNOWN';
115
116 $token = TokenManager::save($registrationId, $card, $this->id);
117
118 if ($token) {
119 $subscriptions = SubscriptionsManager::getSubscriptionsForOrder($order, ['order_type' => 'any']);
120 foreach ($subscriptions as $subscription) {
121 /** @var WC_Subscription $subscription */
122 $subscription->update_meta_data('_payment_token_id', $token->get_id());
123 $subscription->save();
124 }
125 }
126 }
127
128
129
130 /**
131 * AJAX: Updates checkout session with registration parameters.
132 */
133 public function updateCheckout(): void
134 {
135 try {
136
137 if (empty($_POST['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'hyperpay_update_checkout')) {
138 throw new Exception(esc_html__('Invalid request (nonce failed).', 'hyperpay-gateways'));
139 }
140
141
142
143 $orderId = sanitize_text_field(wp_unslash($_POST['orderId'] ?? ''));
144 $checkoutId = sanitize_text_field(wp_unslash($_POST['checkoutId'] ?? ''));
145 $createReg = sanitize_text_field(wp_unslash($_POST['createRegistration'] ?? 'false')) === 'true';
146 $selectedCard = sanitize_text_field(wp_unslash($_POST['selectedCard'] ?? '')); // nosemgrep: audit.php.wp.security.xss.sanitized-var-passed-to-method
147
148 if (!$orderId || !$checkoutId) {
149 $this->send_json(false, __('Missing required parameters.', 'hyperpay-gateways'));
150 }
151
152 $order = $this->get_validated_order($orderId);
153
154 $isSubscription = SubscriptionsManager::orderContainsSubscription($order);
155
156 // disable tokenization for guest users
157 if (!is_user_logged_in() && !$isSubscription) {
158 $this->send_json(true, __('No registration needed for guest users.', 'hyperpay-gateways'));
159 return;
160 }
161
162 if($isSubscription && !is_user_logged_in()){
163 throw new Exception(esc_html__('You must be logged in to purchase a subscription.', 'hyperpay-gateways'));
164 }
165
166 $needsRegistration = $createReg || $isSubscription || !empty($selectedCard);
167
168 if (!$needsRegistration) {
169 $this->send_json(true, __('No registration needed.', 'hyperpay-gateways'));
170 }
171
172 $citParams = $this->get_cit_parameters($order, $selectedCard);
173 if ($createReg) {
174 $citParams['createRegistration'] = 'true';
175 }
176
177 $data = $this->getBasicData();
178 $data['body'] = array_merge($data['body'], $citParams);
179
180 $url = $this->ACI_base_url . '/v1/checkouts/' . $checkoutId;
181 $response = Http::post($url, $data);
182
183 if (preg_match("/^(000\.200)/", $response['result']['code'] ?? '')) {
184 $this->send_json(true, __('Checkout updated successfully.', 'hyperpay-gateways'));
185 }
186
187
188 $errorMessage = $response['result']['description'] ?? __('Unknown error', 'hyperpay-gateways');
189 $errorCode = $response['result']['code'] ?? 'N/A';
190 /* translators: %s: error message, %s: error code */
191 $this->send_json(false, sprintf(__('Payment update failed: %1$s (Code: %2$s)', 'hyperpay-gateways'), $errorMessage, $errorCode));
192 } catch (Exception $e) {
193 /* translators: %s: error message */
194 $this->send_json(false, sprintf(__('Checkout update failed: %s', 'hyperpay-gateways'), $e->getMessage()));
195 }
196 }
197
198 /**
199 * Processes a subscription renewal payment (MIT).
200 *
201 * @param float $amount The amount to charge.
202 * @param WC_Order $renewalOrder The renewal order.
203 */
204 public function process_subscription_renewal(float $amount, WC_Order $renewalOrder): void
205 {
206 $tokenId = null;
207 $subscriptions = SubscriptionsManager::getSubscriptionsForOrder($renewalOrder, ['order_type' => 'renewal']);
208 foreach ($subscriptions as $subscription) {
209 /** @var WC_Subscription $subscription */
210 $tokenId = $subscription->get_meta('_payment_token_id', true);
211 if ($tokenId) break;
212 }
213
214 $token = $tokenId ? WC_Payment_Tokens::get($tokenId) : null;
215
216 if (!$token || !$token->get_token()) {
217 $renewalOrder->add_order_note(__('No payment token found for subscription.', 'hyperpay-gateways'));
218 $renewalOrder->update_status('failed');
219 return;
220 }
221
222 if ($amount <= 0) {
223 $renewalOrder->payment_complete();
224 $renewalOrder->add_order_note(__('No payment required for this renewal order.', 'hyperpay-gateways'));
225 return;
226 }
227
228 try {
229 $result = $this->process_recurring_payment($renewalOrder, $token, $amount);
230
231 $renewalOrder->add_order_note($result['message']);
232 $renewalOrder->update_status($result['status'] ?? 'failed');
233 } catch (Exception $e) {
234 $renewalOrder->update_status('failed');
235 }
236 }
237
238 /**
239 * Performs the recurring (MIT) charge request via Hyperpay API.
240 *
241 * @return array{status:string, transaction_id:string, message:string}
242 */
243 protected function process_recurring_payment(WC_Order $renewalOrder, WC_Payment_Token $token, float $amount): array
244 {
245 $registrationId = $token->get_token();
246 $url = $this->ACI_base_url . '/v1/registrations/' . $registrationId . '/payments';
247 $orderId = $renewalOrder->get_id();
248
249 $initialTxId = $token->get_meta('initial_transaction_id') ?: '';
250 $agreementId = $token->get_meta('agreement_id') ?: $renewalOrder->get_meta('agreement_id');
251 $transactionKey = wp_rand(11111111, 99999999);
252
253 $MIT = COF::build_mit_params($agreementId, $initialTxId);
254
255 $body = [
256 'entityId' => $this->recurring_entityId,
257 'amount' => number_format($amount, 2, '.', ''),
258 'currency' => $renewalOrder->get_currency(),
259 'paymentType' => $this->trans_type,
260 'customer.email' => $renewalOrder->get_billing_email(),
261 'merchantTransactionId' => $orderId . 'I' . $transactionKey,
262 'customParameters[bill_number]' => $orderId,
263 ];
264
265 if ($this->testMode) {
266 $body['testMode'] = 'EXTERNAL';
267 }
268
269 $data = [
270 'headers' => ['Authorization' => 'Bearer ' . $this->recurring_accessToken],
271 'body' => \array_merge($body, $MIT),
272 ];
273
274 $response = Http::post($url, $data);
275
276 $status = 'failed';
277 $result = $response['result'] ?? [];
278 $transactionId = $response['id'] ?? '';
279 $message = sprintf(
280 /* translators: 1: error message, 2: error code, 3: error description */
281 __('Recurring payment failed: %1$s (Code: %2$s), Description: %3$s', 'hyperpay-gateways'),
282 $result['description'] ?? 'Unknown error',
283 $result['code'] ?? 'N/A',
284 $result['code'] ?? 'N/A',
285 );
286
287 if ($this->is_successful_response($response)) {
288 $status = 'completed';
289 /* translators: %s: Transaction ID */
290 $message = sprintf(__('Recurring payment processed successfully. Transaction ID: %s', 'hyperpay-gateways'), $transactionId);
291 }
292
293 return compact('status', 'transactionId', 'message');
294 }
295
296
297 /**
298 * Prepares CIT parameters for an initial subscription transaction.
299 */
300 protected function get_cit_parameters(WC_Order $order, ?string $selectedCard): array
301 {
302 $tokenObj = null;
303
304 if ($selectedCard) {
305 $tokens = WC_Payment_Tokens::get_customer_tokens(get_current_user_id(), $this->id);
306 foreach ($tokens as $token) {
307 if ($token->get_token() === $selectedCard) {
308 $tokenObj = $token;
309 break;
310 }
311 }
312 }
313
314 $agreementId = $tokenObj
315 ? $tokenObj->get_meta('agreement_id')
316 : $this->generate_agreement_id_for_order($order);
317
318 $order->update_meta_data('agreement_id', $agreementId);
319 $order->save();
320
321 return COF::build_cit_params($agreementId);
322 }
323
324 /**
325 * Generates a unique recurring agreement ID for an order.
326 */
327 protected function generate_agreement_id_for_order(WC_Order $order): string
328 {
329 $customerId = $order->get_customer_id() ?: 0;
330 return sprintf('AGR%s%s%s', $order->get_id(), $customerId, time());
331 }
332
333
334 /** Retrieves and validates order ownership. */
335 protected function get_validated_order(int $orderId): WC_Order
336 {
337 $order = wc_get_order($orderId); // nosemgrep: audit.php.wp.security.xss.sanitized-var-passed-to-method
338 if (!$order) {
339 throw new Exception(esc_html__('Order not found.', 'hyperpay-gateways'));
340 }
341
342 $currentUserId = get_current_user_id();
343 if ($order->get_customer_id() && $order->get_customer_id() !== $currentUserId) {
344 throw new Exception(esc_html__('You do not have permission to update this order.', 'hyperpay-gateways'));
345 }
346
347 return $order;
348 }
349
350 /** Sends a JSON response and terminates execution. */
351 protected function send_json(bool $success, string $message = ''): void
352 {
353 wp_send_json([
354 'success' => $success,
355 'message' => $message,
356 ]);
357 }
358 }
359