PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 1.11.2
Subscriptions for WooCommerce with Stripe Recurring Payments v1.11.2
2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
subscription / includes / Illuminate / Gateways / Paypal / Paypal.php

Paypal.php in Subscriptions for WooCommerce with Stripe Recurring Payments 1.11.2, at includes/Illuminate/Gateways/Paypal/Paypal.php

1,979 lines 69.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace SpringDevs\Subscription\Illuminate\Gateways\Paypal;
4
5 use Exception;
6 use PHPUnit\TextUI\Help;
7 use SpringDevs\Subscription\Illuminate\Action;
8 use SpringDevs\Subscription\Illuminate\Helper;
9 use SpringDevs\Subscription\Illuminate\Subscription\Subscription;
10 use WC_Order;
11 use WC_Order_Item_Product;
12 use WC_Product;
13
14 /**
15 * Class PayPal
16 * PayPal Payment Gateway for Subscription Plugin
17 *
18 * @package SpringDevs\Subscription\Illuminate\Gateways
19 */
20 class Paypal extends \WC_Payment_Gateway {
21
22 /**
23 * Singleton instance.
24 *
25 * @var self|null
26 */
27 private static ?self $instance = null;
28
29 /**
30 * Sandbox mode.
31 *
32 * @var bool
33 */
34 public $sandbox_mode = false;
35
36 /**
37 * PayPal Client ID.
38 *
39 * @var string
40 */
41 protected $client_id;
42
43 /**
44 * PayPal Client Secret.
45 *
46 * @var string
47 */
48 protected $client_secret;
49
50 /**
51 * PayPal Webhook ID.
52 *
53 * @var string
54 */
55 protected $webhook_id;
56
57 /**
58 * API endpoint for PayPal.
59 *
60 * @var string
61 */
62 protected $api_endpoint;
63
64 /**
65 * Constructor for the gateway.
66 */
67 public function __construct() {
68 $this->id = 'wp_subscription_paypal';
69 $this->has_fields = false;
70 $this->method_title = __( 'PayPal for WPSubscription', 'subscription' );
71 $this->method_description = __( 'Accept wp subscription recurring payments through PayPal. Only WPSubscription is supported.', 'subscription' );
72 $this->supports = [ 'products', 'subscriptions', 'refunds' ];
73 $this->icon = apply_filters( 'wp_subscription_paypal_icon', SUBSCRPT_URL . '/assets/images/integrations/paypal.svg' );
74
75 // Load the settings.
76 $this->init_form_fields();
77 $this->init_settings();
78
79 // Plugin variables.
80 $this->enabled = $this->get_option( 'enabled' );
81 $this->title = $this->get_option( 'title' );
82 $this->description = $this->get_option( 'description' );
83
84 // PayPal Credentials.
85 $this->sandbox_mode = 'yes' === $this->get_option( 'testmode', 'no' );
86
87 if ( $this->sandbox_mode ) {
88 $this->client_id = $this->get_option( 'sandbox_client_id' );
89 $this->client_secret = $this->get_option( 'sandbox_client_secret' );
90 $this->webhook_id = $this->get_option( 'sandbox_webhook_id' );
91 } else {
92 $this->client_id = $this->get_option( 'client_id' );
93 $this->client_secret = $this->get_option( 'client_secret' );
94 $this->webhook_id = $this->get_option( 'webhook_id' );
95 }
96
97 // Set Webhook URL.
98 $this->update_option( 'webhook_url', $this->get_webhook_url() );
99
100 // Set API endpoint.
101 $this->api_endpoint = $this->sandbox_mode ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
102
103 // Store first instance as the singleton (WooCommerce creates it; blocks integration reuses it).
104 if ( null === self::$instance ) {
105 self::$instance = $this;
106 }
107
108 // Ensure PayPal mapping table exists (create if missing).
109 // This handles cases where plugin activation hook may have been skipped.
110 try {
111 PaypalDB::maybe_create_tables();
112 } catch ( \Throwable $e ) {
113 subscrpt_write_debug_log( 'PayPal DB ensure failed: ' . $e->getMessage() );
114 }
115
116 // Actions.
117 $this->init_actions();
118 }
119
120 /**
121 * Get the singleton gateway instance.
122 *
123 * Returns the WooCommerce-managed instance when available. Falls back to
124 * creating a new instance only if the gateway has not been loaded yet.
125 *
126 * @return self
127 */
128 public static function get_instance(): self {
129 if ( null === self::$instance ) {
130 self::$instance = new self();
131 }
132 return self::$instance;
133 }
134
135 /**
136 * Initialize actions for the gateway.
137 */
138 protected function init_actions() {
139 add_action( 'woocommerce_update_options_payment_gateways_' . $this->id, [ $this, 'process_admin_options' ] );
140
141 // Process order after payment.
142 add_action( 'woocommerce_thankyou', [ $this, 'order_received_page' ] );
143
144 // Hide gateway if no wp_subscription products are available.
145 add_filter( 'woocommerce_available_payment_gateways', [ $this,'remove_wp_subs_paypal_gateway' ] );
146
147 // WooCommerce webhook.
148 add_action( 'woocommerce_api_' . $this->id, [ $this, 'process_webhook' ] );
149
150 // Cancel subscription.
151 add_action( 'subscrpt_subscription_expired', [ $this, 'handle_subscription_cancellation' ] );
152 add_action( 'subscrpt_subscription_cancelled', [ $this, 'handle_subscription_cancellation' ] );
153 }
154
155 /**
156 * Initialize Gateway Settings Form Fields.
157 */
158 public function init_form_fields() {
159 // Gateway settings styles.
160 wp_enqueue_style( 'wp-subscription-gateway-settings', SUBSCRPT_ASSETS . '/css/gateway.css', [], SUBSCRPT_VERSION, 'all' );
161
162 // Settings JS.
163 wp_enqueue_script( 'wp-subscription-gateway-settings-script', SUBSCRPT_ASSETS . '/js/gateway.js', [ 'jquery' ], SUBSCRPT_VERSION, true );
164
165 // Live/Sandbox toggle script.
166 wp_enqueue_script( 'wp-subscription-gateway-settings-toggle-script', SUBSCRPT_ASSETS . '/js/gateway_options_toggler.js', [ 'jquery' ], SUBSCRPT_VERSION, true );
167
168 $this->form_fields = [
169 'enabled' => [
170 'title' => __( 'Enable/Disable', 'subscription' ),
171 'type' => 'checkbox',
172 'label' => __( 'Enable PayPal for WPSubscription', 'subscription' ),
173 'default' => 'no',
174 'description' => __( 'Enable or Disable PayPal for WPSubscription payment gateway', 'subscription' ),
175 'desc_tip' => true,
176 'class' => 'wpsubs-toggle',
177 ],
178 'testmode' => [
179 'title' => __( 'Test Mode', 'subscription' ),
180 'type' => 'checkbox',
181 'label' => __( 'Enable PayPal Sandbox', 'subscription' ),
182 'default' => 'no',
183 'description' => __( 'PayPal sandbox can be used to test payments without using real money.', 'subscription' ),
184 'desc_tip' => true,
185 'class' => 'wpsubs-toggle',
186 ],
187
188 'title' => [
189 'title' => __( 'Title', 'subscription' ),
190 'type' => 'text',
191 'description' => __( 'This controls the title which the user sees during checkout.', 'subscription' ),
192 'default' => __( 'PayPal', 'subscription' ),
193 'desc_tip' => true,
194 ],
195 'description' => [
196 'title' => __( 'Description', 'subscription' ),
197 'type' => 'textarea',
198 'description' => __( 'This controls the description which the user sees during checkout.', 'subscription' ),
199 'default' => __( 'Pay via PayPal; you can pay with your credit card if you do not have a PayPal account.', 'subscription' ),
200 'desc_tip' => true,
201 'css' => 'width: 400px; height: 75px;',
202 ],
203
204 'paypal_creds_title' => [
205 'title' => __( 'PayPal Credentials', 'subscription' ),
206 'type' => 'title',
207 'description' => '',
208 'class' => 'wpsubs-paypal-live-creds',
209 ],
210 'paypal_sandbox_creds_title' => [
211 'title' => __( 'PayPal Sandbox Credentials', 'subscription' ),
212 'type' => 'title',
213 'description' => '',
214 'class' => 'wpsubs-paypal-sandbox-creds',
215 ],
216
217 'paypal_creds_desc' => [
218 'title' => '',
219 'type' => 'title',
220 'description' => sprintf(
221 // Translators: %1$s is the link to PayPal developer account, %2$s is the link to My Apps & Credentials.
222 __( 'Create a <a href="%1$s" target="_blank">PayPal developer account</a>, go to <a href="%2$s" target="_blank">My Apps & Credentials</a>, select the toggle ( Sandbox or Live ), create an app, and copy <b>Client ID</b> and <b>Secret</b>.', 'subscription' ),
223 'https://developer.paypal.com',
224 'https://developer.paypal.com/dashboard/applications'
225 ),
226 ],
227 'email' => [
228 'title' => __( 'Email', 'subscription' ),
229 'type' => 'email',
230 'description' => __( 'PayPal Email Address (used to receive payments)', 'subscription' ),
231 'default' => '',
232 'desc_tip' => true,
233 ],
234
235 // Live Credentials.
236 'client_id' => [
237 'title' => __( 'Client ID', 'subscription' ),
238 'type' => 'password',
239 'description' => __( 'Enter your PayPal Client ID copied from PayPal Apps & Credentials.', 'subscription' ),
240 'default' => '',
241 'desc_tip' => true,
242 'class' => 'wpsubs-paypal-live-creds',
243 ],
244 'client_secret' => [
245 'title' => __( 'Secret', 'subscription' ),
246 'type' => 'password',
247 'description' => __( 'Enter your PayPal Secret copied from PayPal Apps & Credentials.', 'subscription' ),
248 'default' => '',
249 'desc_tip' => true,
250 'class' => 'wpsubs-paypal-live-creds',
251 ],
252 'webhook_id' => [
253 'title' => __( 'Webhook ID', 'subscription' ),
254 'type' => 'password',
255 'description' => __( 'Enter your Webhook ID copied from PayPal Apps & Credentials for webhook validation.', 'subscription' ),
256 'default' => '',
257 'desc_tip' => true,
258 'class' => 'wpsubs-paypal-live-creds',
259 ],
260
261 // Sandbox Credentials.
262 'sandbox_client_id' => [
263 'title' => __( 'Client ID', 'subscription' ),
264 'type' => 'password',
265 'description' => __( 'Enter your PayPal Client ID copied from PayPal Apps & Credentials.', 'subscription' ),
266 'default' => '',
267 'desc_tip' => true,
268 'class' => 'wpsubs-paypal-sandbox-creds',
269 ],
270 'sandbox_client_secret' => [
271 'title' => __( 'Secret', 'subscription' ),
272 'type' => 'password',
273 'description' => __( 'Enter your PayPal Secret copied from PayPal Apps & Credentials.', 'subscription' ),
274 'default' => '',
275 'desc_tip' => true,
276 'class' => 'wpsubs-paypal-sandbox-creds',
277 ],
278 'sandbox_webhook_id' => [
279 'title' => __( 'Webhook ID', 'subscription' ),
280 'type' => 'password',
281 'description' => __( 'Enter your Webhook ID copied from PayPal Apps & Credentials for webhook validation.', 'subscription' ),
282 'default' => '',
283 'desc_tip' => true,
284 'class' => 'wpsubs-paypal-sandbox-creds',
285 ],
286
287 'webhook_url' => [
288 'title' => __( 'Webhook URL', 'subscription' ),
289 'type' => 'text',
290 'description' => __( '<p>In the <strong style="color:#1d4ed8">Apps & Credentials</strong> page of PayPal developer account open the newly created application and click <strong style="color:#1d4ed8">Add Webhook</strong> button.<br> On the <strong>Webhook URL</strong> field use this webhook link', 'subscription' ),
291 'default' => $this->get_webhook_url(),
292 'disabled' => true,
293 'class' => 'wpsubs-webhook-url',
294 ],
295 ];
296 }
297
298 /**
299 * Check if paypal can be used for the currency selected in the store.
300 *
301 * @return boolean
302 */
303 public function is_currency_supported() {
304 return in_array(
305 get_woocommerce_currency(),
306 apply_filters(
307 'wp_subs_paypal_supported_currencies',
308 [ 'AUD', 'BRL', 'CAD', 'MXN', 'NZD', 'HKD', 'SGD', 'USD', 'EUR', 'JPY', 'NOK', 'CZK', 'DKK', 'HUF', 'ILS', 'MYR', 'PHP', 'PLN', 'SEK', 'CHF', 'TWD', 'THB', 'GBP', 'RUB', 'INR' ]
309 ),
310 true
311 );
312 }
313
314 /**
315 * Show admin options is valid for use.
316 *
317 * @since 1.0.0
318 */
319 public function admin_options() {
320 if ( $this->is_currency_supported() ) {
321 parent::admin_options();
322 } else {
323 $currency_not_supported_message = sprintf(
324 // Translators: %s is the title of the payment gateway.
325 __( '<strong>%s</strong> options are disabled. PayPal Standard does not support your store currency.', 'subscription' ),
326 $this->title
327 );
328
329 ?>
330 <div class="inline error">
331 <p>
332 <?php echo wp_kses_post( $currency_not_supported_message ); ?>
333 </p>
334 </div>
335 <?php
336 }
337 }
338
339 /**
340 * Get webhook URL for PayPal.
341 */
342 public function get_webhook_url(): string {
343 return add_query_arg( 'wc-api', $this->id, trailingslashit( get_home_url() ) );
344 }
345
346 /**
347 * Process order after payment received.
348 *
349 * @param int $order_id Order ID.
350 */
351 public function order_received_page( $order_id ) {
352 if ( ! is_order_received_page() || empty( $order_id ) ) {
353 return;
354 }
355
356 $order = wc_get_order( $order_id );
357
358 // Return if order is not valid.
359 if ( ! $order || empty( $order ) ) {
360 return;
361 }
362 // Return if the order is not using WPSUBS PayPal.
363 if ( $order->get_payment_method() !== $this->id ) {
364 return;
365 }
366
367 // Return if the order is already completed.
368 if ( 'completed' === $order->get_status() ) {
369 // Translators: %d is the order ID.
370 $log_message = sprintf( __( 'Order %d was already completed. Skipping PayPal check.', 'subscription' ), $order_id );
371 subscrpt_write_log( $log_message );
372 subscrpt_write_debug_log( $log_message );
373 return;
374 }
375
376 // phpcs:disable WordPress.Security.NonceVerification.Recommended
377 // ? We are checking $_GET parameters directly from PayPal redirect, nonce is not applicable here.
378 $paypal_subscription_id = isset( $_GET['subscription_id'] ) ? sanitize_text_field( wp_unslash( $_GET['subscription_id'] ) ) : '';
379 $paypal_ba_token = isset( $_GET['ba_token'] ) ? sanitize_text_field( wp_unslash( $_GET['ba_token'] ) ) : '';
380 $paypal_token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
381 // phpcs:enable WordPress.Security.NonceVerification.Recommended
382
383 $paypal_payment_approved = false;
384
385 if ( empty( $paypal_subscription_id ) ) {
386 $paypal_subscription_id = $order->get_meta( $this->get_meta_key( 'subscription_id' ), true );
387 }
388
389 // OLD key migration.
390 // If no data check if the data exists with the old key. And update if necessary.
391 // ? Dev note: Remove after JAN 1, 2026.
392 if ( empty( $paypal_subscription_id ) ) {
393 $paypal_subscription_id = $order->get_meta( '_wp_subs_paypal_subscription_id', true );
394
395 if ( ! empty( $paypal_subscription_id ) ) {
396 $order->update_meta_data( $this->get_meta_key( 'subscription_id' ), $paypal_subscription_id );
397 $order->save();
398 }
399 }
400
401 if ( ! empty( $paypal_subscription_id ) ) {
402 $paypal_subscription_data = $this->get_paypal_subscription( $paypal_subscription_id );
403
404 if ( $paypal_subscription_data && in_array( $paypal_subscription_data->status ?? '', [ 'ACTIVE', 'APPROVED' ], true ) ) {
405 $paypal_payment_approved = true;
406 }
407 }
408
409 // Fallback to check PayPal Order if Subscription is not available.
410 if ( ! $paypal_payment_approved && ! empty( $paypal_token ) ) {
411 $paypal_order_data = $this->get_paypal_order( $paypal_token );
412
413 if ( $paypal_order_data && in_array( $paypal_order_data->status ?? '', [ 'APPROVED', 'COMPLETED' ], true ) ) {
414 $paypal_payment_approved = true;
415 }
416 }
417
418 if ( $paypal_payment_approved ) {
419 $order->update_status( 'completed', __( 'PayPal payment completed successfully.', 'subscription' ) );
420 $order->save();
421
422 // Pre-populate mapping table so the first webhook can resolve without the slow order-meta fallback query.
423 if ( ! empty( $paypal_subscription_id ) ) {
424 $subscriptions = Helper::get_subscriptions_from_order( $order_id );
425 $subscription = ! empty( $subscriptions ) ? reset( $subscriptions ) : null;
426
427 if ( ! $subscription ) {
428 foreach ( $order->get_items() as $item ) {
429 $tmp = Helper::get_subscription_from_order_item_id( $item->get_id() );
430 if ( ! empty( $tmp ) ) {
431 $subscription = $tmp;
432 break;
433 }
434 }
435 }
436
437 if ( $subscription ) {
438 PaypalDB::upsert_mapping(
439 $paypal_subscription_id,
440 (int) $subscription->subscription_id,
441 (int) $order_id
442 );
443 }
444 }
445 }
446 }
447
448 /**
449 * Remove PayPal gateway if no wp_subscription products are in checkout.
450 *
451 * @param array $available_gateways Available gateways.
452 */
453 public function remove_wp_subs_paypal_gateway( $available_gateways ) {
454 if ( ! is_checkout() || ! is_array( $available_gateways ) || empty( $available_gateways ) ) {
455 return $available_gateways;
456 }
457
458 $has_subs_in_cart = false;
459 $cart_items = WC()->cart->cart_contents;
460 foreach ( $cart_items as $cart_item ) {
461 if (
462 isset( $cart_item['subscription'] ) ||
463 $cart_item['data']->get_meta( '_subscrpt_enabled' )
464 ) {
465 $has_subs_in_cart = true;
466 break;
467 }
468 }
469
470 if ( ! $has_subs_in_cart && isset( $available_gateways[ $this->id ] ) ) {
471 unset( $available_gateways[ $this->id ] );
472 }
473
474 return $available_gateways;
475 }
476
477 /**
478 * Process webhook from PayPal.
479 */
480 public function process_webhook() {
481 // Get raw webhook data.
482 $raw_body = file_get_contents( 'php://input' );
483 $headers = function_exists( 'getallheaders' ) ? getallheaders() : [];
484
485 if ( empty( $raw_body ) ) {
486 subscrpt_write_log( 'PayPal webhook data is empty.' );
487 subscrpt_write_debug_log( 'PayPal - process_webhook EMPTY' );
488 wp_die( 'PayPal webhook data is empty.', '400 Bad Request', [ 'response' => 400 ] );
489 }
490
491 // Verify webhook.
492 $this->verify_webhook( $headers, $raw_body );
493
494 // Decode webhook data.
495 $webhook_data = json_decode( $raw_body, true );
496
497 // Get event type from webhook data.
498 $event = isset( $webhook_data['event_type'] ) ? sanitize_text_field( $webhook_data['event_type'] ) : '';
499
500 // Supported transaction events.
501 $transaction_events = [
502 'PAYMENT.SALE.COMPLETED',
503 'PAYMENT.SALE.REFUNDED',
504 ];
505
506 // Supported subscription events.
507 $subscription_events = [
508 'BILLING.SUBSCRIPTION.ACTIVATED',
509 'BILLING.SUBSCRIPTION.UPDATED',
510 'BILLING.SUBSCRIPTION.EXPIRED',
511 'BILLING.SUBSCRIPTION.SUSPENDED',
512 'BILLING.SUBSCRIPTION.CANCELLED',
513 ];
514
515 // Get subscription ID from webhook data.
516 $paypal_subscription_id = isset( $webhook_data['resource']['billing_agreement_id'] )
517 ? sanitize_text_field( $webhook_data['resource']['billing_agreement_id'] )
518 : ( isset( $webhook_data['resource']['id'] ) ? sanitize_text_field( $webhook_data['resource']['id'] ) : null );
519
520 // Look up WP subscription ID from the PayPal mapping table.
521 $wpsubs_id = ! empty( $paypal_subscription_id ) ? PaypalDB::get_subscription_by_paypal_id( $paypal_subscription_id ) : null;
522
523 // If no WP subscription ID found, try to find the order using the PayPal subscription ID in order meta.
524 if ( empty( $wpsubs_id ) && ! empty( $paypal_subscription_id ) ) {
525 $chk_orders = wc_get_orders(
526 [
527 'meta_key' => $this->get_meta_key( 'subscription_id' ),
528 'meta_value' => $paypal_subscription_id,
529 'limit' => 1,
530 ]
531 );
532
533 $chk_order = ! empty( $chk_orders ) ? reset( $chk_orders ) : null;
534 $chk_subscriptions = $chk_order ? Helper::get_subscriptions_from_order( $chk_order->get_id() ) : null;
535 $chk_subscription = ! empty( $chk_subscriptions ) ? reset( $chk_subscriptions ) : null;
536
537 if ( ! $chk_subscription && $chk_order ) {
538 foreach ( $chk_order->get_items() as $item ) {
539 $tmp = Helper::get_subscription_from_order_item_id( $item->get_id() );
540 if ( ! empty( $tmp ) ) {
541 $chk_subscription = $tmp;
542 break;
543 }
544 }
545 }
546
547 // Update mapping table.
548 if ( ! empty( $chk_subscription ) ) {
549 PaypalDB::upsert_mapping(
550 $paypal_subscription_id,
551 (int) $chk_subscription->subscription_id,
552 (int) $chk_order->get_id()
553 );
554 $wpsubs_id = (int) $chk_subscription->subscription_id;
555 }
556 }
557
558 // Order object.
559 $order = null;
560
561 // Get transaction ID from webhook data.
562 $transaction_id = isset( $webhook_data['resource']['sale_id'] )
563 ? sanitize_text_field( $webhook_data['resource']['sale_id'] )
564 : ( isset( $webhook_data['resource']['id'] ) ? sanitize_text_field( $webhook_data['resource']['id'] ) : '' );
565
566 // Get order by Transaction ID.
567 if ( ! empty( $transaction_id ) ) {
568 $orders = wc_get_orders( [ 'transaction_id' => $transaction_id ] );
569
570 if ( ! empty( $orders ) ) {
571 $order = reset( $orders );
572 }
573 }
574
575 // Get parent order if the order is a refund order action.
576 if ( $order && strpos( get_class( $order ), 'OrderRefund' ) ) {
577 $parent_id = $order->get_parent_id() ?? null;
578
579 if ( $parent_id ) {
580 $order = wc_get_order( $parent_id );
581 }
582 }
583
584 // Gate ALL events when both $order and $wpsubs_id are unresolved — return 425 so PayPal
585 // retries. Previously this guard was subscription-events-only; transaction events (e.g.
586 // PAYMENT.SALE.COMPLETED) fell through and returned a 404 which PayPal treats as a
587 // permanent failure and never retries, leaving the renewal order in "pending" forever.
588 if ( empty( $order ) && empty( $wpsubs_id ) ) {
589 $log_message = sprintf(
590 // translators: %s: event name.
591 __( 'PayPal webhook received [%s]. Order not found. Queuing for retry.', 'subscription' ),
592 $event,
593 );
594 subscrpt_write_log( $log_message );
595 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
596 wp_die( esc_html( $log_message ), '425 Too Early', array( 'response' => 425 ) );
597 }
598
599 // Finally, handle the webhook.
600 if ( in_array( $event, $transaction_events, true ) ) {
601 $this->handle_transaction_event( $webhook_data, $order, $transaction_id, $paypal_subscription_id, $wpsubs_id );
602 } elseif ( in_array( $event, $subscription_events, true ) ) {
603 $this->handle_subscription_event( $webhook_data, $order, $transaction_id, $paypal_subscription_id, $wpsubs_id );
604 } else {
605 $log_message = sprintf(
606 // translators: %1$s: alert name; %2$s: order id.
607 __( 'PayPal webhook received [%s]. No actions taken.', 'subscription' ),
608 $event,
609 );
610 subscrpt_write_log( $log_message );
611 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
612 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
613 }
614 }
615
616 /**
617 * Verify webhook data from PayPal.
618 *
619 * @param array $headers Headers from the request.
620 * @param string $raw_body Webhook raw body from PayPal.
621 */
622 public function verify_webhook( array $headers, string $raw_body ) {
623 // Get PayPal Access Token.
624 $access_token = $this->get_paypal_access_token();
625 if ( ! $access_token ) {
626 subscrpt_write_log( 'PayPal webhook: Access Token unavailable.' );
627 wp_die( 'Error: Access token not available. Cannot verify webhook.', '401 Unauthorized', array( 'response' => 401 ) );
628 }
629
630 // Prepare the request data to verify the webhook.
631 $payload = [
632 'auth_algo' => $headers['PAYPAL-AUTH-ALGO'] ?? $headers['Paypal-Auth-Algo'] ?? '',
633 'cert_url' => $headers['PAYPAL-CERT-URL'] ?? $headers['Paypal-Cert-Url'] ?? '',
634 'transmission_id' => $headers['PAYPAL-TRANSMISSION-ID'] ?? $headers['Paypal-Transmission-Id'] ?? '',
635 'transmission_sig' => $headers['PAYPAL-TRANSMISSION-SIG'] ?? $headers['Paypal-Transmission-Sig'] ?? '',
636 'transmission_time' => $headers['PAYPAL-TRANSMISSION-TIME'] ?? $headers['Paypal-Transmission-Time'] ?? '',
637 'webhook_id' => $this->webhook_id ?? '',
638 'webhook_event' => $raw_body,
639 ];
640
641 // Verify webhook via REST API.
642 $verified = $this->verify_paypal_webhook_rest_api( $payload, $raw_body, $access_token );
643
644 if ( ! $verified ) {
645 // Fallback to manual method if REST API verification fails.
646 subscrpt_write_log( 'PayPal webhook REST API verification failed. Retrying with manual verification.' );
647
648 $verified = $this->verify_paypal_webhook_manual( $payload, $raw_body );
649 }
650
651 if ( ! $verified ) {
652 subscrpt_write_log( 'PayPal webhook verification failed.' );
653 subscrpt_write_debug_log( 'Webhook verification failed for data: ' . sanitize_text_field( $raw_body ) );
654 wp_die( 'Error: PayPal webhook verification failed.', '403 Forbidden', array( 'response' => 403 ) );
655 }
656 }
657
658 /**
659 * Verify PayPal webhook with REST API.
660 *
661 * @param array $payload Payload data for verification.
662 * @param string $raw_body Webhook raw body from PayPal.
663 * @param string $access_token PayPal Access Token.
664 */
665 protected function verify_paypal_webhook_rest_api( array $payload, string $raw_body, string $access_token ): bool {
666 // Fix the webhook_event to be an array.
667 $payload['webhook_event'] = json_decode( $raw_body, true );
668
669 // Verify webhook signature via PayPal REST API.
670 try {
671 $url = $this->api_endpoint . '/v1/notifications/verify-webhook-signature';
672 $args = [
673 'method' => 'POST',
674 'headers' => [
675 'Authorization' => 'Bearer ' . $access_token,
676 'Content-Type' => 'application/json',
677 ],
678 'body' => wp_json_encode( $payload ),
679 ];
680
681 $response = wp_remote_post( $url, $args );
682 $response_data = json_decode( wp_remote_retrieve_body( $response ), true );
683 $verification_status = $response_data['verification_status'] ?? null;
684
685 if ( empty( $verification_status ) || 'success' !== strtolower( $verification_status ) ) {
686 subscrpt_write_debug_log( 'PayPal Webhook Verification: ' . wp_json_encode( $response_data ) );
687 return false;
688 }
689
690 return ( 'success' === strtolower( $verification_status ) ) ? true : false;
691
692 } catch ( Exception $e ) {
693 $log_message = 'PayPal Webhook Verification Failed: ' . $e->getMessage();
694 subscrpt_write_log( $log_message );
695 subscrpt_write_debug_log( $log_message );
696 return false;
697 }
698 }
699
700 /**
701 * Verify PayPal webhook manually (self verification).
702 *
703 * @param array $payload Payload data for verification.
704 * @param string $raw_body Webhook raw body from PayPal.
705 */
706 protected function verify_paypal_webhook_manual( array $payload, string $raw_body ): bool {
707 // Enforce CRC32 for 32-bit systems (edge case)
708 $crc = sprintf( '%u', crc32( $raw_body ) );
709
710 // Build Message
711 $message = implode(
712 '|',
713 [
714 $payload['transmission_id'],
715 $payload['transmission_time'],
716 $payload['webhook_id'],
717 $crc,
718 ]
719 );
720
721 // Fetch & cache cert
722 $cert_url = esc_url_raw( $payload['cert_url'] );
723 $cache_key = 'paypal_cert_' . md5( $cert_url );
724
725 $cert_pem = get_transient( $cache_key );
726
727 if ( ! $cert_pem ) {
728 $response = wp_remote_get( $cert_url, [ 'timeout' => 20 ] );
729 if ( is_wp_error( $response ) ) {
730 return false;
731 }
732
733 $cert_pem = wp_remote_retrieve_body( $response );
734 set_transient( $cache_key, $cert_pem, DAY_IN_SECONDS );
735 }
736
737 if ( empty( $cert_pem ) ) {
738 return false;
739 }
740
741 // Signature
742 $signature = base64_decode( $payload['transmission_sig'], true );
743
744 if ( false === $signature ) {
745 return false;
746 }
747
748 // Final verification
749 $verified = openssl_verify(
750 $message,
751 $signature,
752 $cert_pem,
753 OPENSSL_ALGO_SHA256
754 );
755
756 return ( 1 === $verified );
757 }
758
759 /**
760 * Process Payment.
761 *
762 * @param int $order_id Order ID.
763 * @return array
764 */
765 public function process_payment( $order_id ) {
766 $order = wc_get_order( $order_id );
767
768 return $this->process_paypal_payment( $order );
769 }
770
771 /**
772 * Process payments in PayPal.
773 *
774 * @param WC_Order $order The order object.
775 */
776 protected function process_paypal_payment( WC_Order $order ): array {
777 // Get PayPal Access Token.
778 $access_token = $this->get_paypal_access_token();
779 if ( ! $access_token ) {
780 return [
781 'result' => 'error',
782 'redirect' => '',
783 'response' => 'PayPal payment failed. Please try again.',
784 ];
785 }
786
787 // Get the first order item.
788 // Based on the logic, the order sould contain only one subscription item.
789 $order_items = $order->get_items();
790 $order_item = ! empty( $order_items ) ? reset( $order_items ) : null;
791
792 // Get WooCommerce Product.
793 $wc_product_id = null;
794 $wc_variation_id = null;
795 $wc_product = null;
796 if ( $order_item && $order_item instanceof WC_Order_Item_Product ) {
797 $wc_product_id = $order_item->get_product_id();
798 $wc_variation_id = $order_item->get_variation_id();
799 $wc_product = wc_get_product( $wc_product_id );
800 }
801
802 if ( ! $wc_product ) {
803 return [
804 'result' => 'error',
805 'redirect' => '',
806 'response' => 'Invalid product in order. Please check the order details.',
807 ];
808 }
809
810 // Get PayPal Product ID.
811 $paypal_product_id = $this->get_paypal_product_id( $wc_product_id, $access_token );
812
813 if ( ! $paypal_product_id ) {
814 return [
815 'result' => 'error',
816 'redirect' => '',
817 'response' => 'PayPal payment failed. Please try again. (Failed to get PayPal product ID)',
818 ];
819 }
820
821 // Get PayPal Plan ID.
822 $paypal_plan_id = $this->get_paypal_plan_id( $wc_product_id, $wc_variation_id, $paypal_product_id, $access_token );
823
824 if ( ! $paypal_plan_id ) {
825 return [
826 'result' => 'error',
827 'redirect' => '',
828 'response' => 'PayPal payment failed. Please try again. (Failed to get PayPal plan ID)',
829 ];
830 }
831
832 // Get return URL.
833 $return_url = $this->get_return_url( $order );
834 $return_url = wp_http_validate_url( $return_url ) ? $return_url : home_url( $return_url );
835
836 // Create Subscription in PayPal.
837 $paypal_subscription_data = [
838 'plan_id' => $paypal_plan_id,
839 'application_context' => [
840 'return_url' => $return_url,
841 'cancel_url' => $order->get_cancel_order_url(),
842 ],
843 ];
844
845 $paypal_subscription = $this->create_paypal_subscription( $paypal_subscription_data, $access_token );
846
847 if ( empty( $paypal_subscription->id ?? null ) ) {
848 return [
849 'result' => 'error',
850 'redirect' => '',
851 'response' => 'PayPal payment failed. Please try again. (Failed to create PayPal subscription)',
852 ];
853 }
854
855 // Save PayPal Subscription ID in order meta.
856 $order->update_meta_data( $this->get_meta_key( 'subscription_id' ), $paypal_subscription->id );
857 $order->save();
858
859 // Get payment link.
860 $paypal_subscription_pay_link = null;
861 foreach ( ( $paypal_subscription->links ?? [] ) as $link_obj ) {
862 if ( 'approve' === $link_obj->rel ) {
863 $paypal_subscription_pay_link = $link_obj->href;
864 break;
865 }
866 }
867
868 if ( empty( $paypal_subscription_pay_link ) ) {
869 return [
870 'result' => 'error',
871 'redirect' => '',
872 'response' => 'PayPal payment failed. Please try again. (Failed to get PayPal subscription approval link)',
873 ];
874 } else {
875 return [
876 'result' => 'success',
877 'redirect' => $paypal_subscription_pay_link,
878 ];
879 }
880 }
881
882 /**
883 * Get PayPal product ID.
884 *
885 * @param int $wc_product_id WooCommerce Product ID.
886 * @param string $access_token PayPal Access Token.
887 */
888 public function get_paypal_product_id( int $wc_product_id, string $access_token ): ?string {
889 $wc_product = wc_get_product( $wc_product_id );
890
891 // Get data from product meta.
892 // TODO: add home_url, wc_product_id etc to avoid duplication in paypal.
893 $paypal_data = get_post_meta( $wc_product_id, $this->get_meta_key( 'product_data' ), true );
894
895 $paypal_product_id = $paypal_data['product_id'] ?? null;
896 $paypal_image_url = $paypal_data['image_url'] ?? null;
897
898 // If PayPal product ID is not available in meta, get or create a new PayPal product.
899 if ( ! $paypal_product_id ) {
900 $paypal_product = $this->get_or_create_paypal_product( $wc_product, $access_token );
901
902 if ( $paypal_product ) {
903 $paypal_product_id = $paypal_product->id;
904
905 // Save PayPal product ID in WooCommerce product meta.
906 $data = [
907 'product_id' => $paypal_product->id,
908 'image_url' => $paypal_product->image_url ?? '',
909 'home_url' => $product_data->home_url ?? '',
910 ];
911 update_post_meta( $wc_product_id, $this->get_meta_key( 'product_data' ), $data );
912 }
913 }
914
915 // TODO: add logic to update image url if changed.
916 // $current_image_url = $this->truncate_string( wp_get_attachment_url( $wc_product->get_image_id() ), 2000 );
917 // if ( $paypal_product_id && $paypal_image_url !== $current_image_url ) {}
918
919 // Return PayPal product ID or null if not found.
920 return $paypal_product_id;
921 }
922
923 /**
924 * Get PayPal plan ID.
925 *
926 * @param int $wc_product_id WooCommerce Product ID.
927 * @param int $wc_variation_id WooCommerce Variation ID.
928 * @param string $paypal_product_id PayPal Product ID.
929 * @param string $access_token PayPal Access Token.
930 */
931 public function get_paypal_plan_id( int $wc_product_id, int $wc_variation_id, string $paypal_product_id, string $access_token ): ?string {
932 $wc_product = wc_get_product( $wc_product_id );
933 if ( 0 !== $wc_variation_id ) {
934 $wc_product = wc_get_product( $wc_variation_id );
935 }
936
937 // Generate fingerprint of current critical billing fields (price, currency, interval, trial, signup fee, cycles).
938 $fingerprint = $this->generate_plan_fingerprint( $wc_product );
939
940 // Load stored plans array from product meta.
941 $stored_plans = get_post_meta( $wc_product_id, $this->get_meta_key( 'plans' ), true );
942 if ( ! is_array( $stored_plans ) ) {
943 $stored_plans = [];
944 }
945
946 // Return the existing plan whose fingerprint matches the current product configuration.
947 foreach ( $stored_plans as $plan_entry ) {
948 if ( isset( $plan_entry['fingerprint'] ) && $plan_entry['fingerprint'] === $fingerprint ) {
949 return $plan_entry['plan_id'];
950 }
951 }
952
953 // No matching plan found — create a new one for the current configuration.
954 $plan_data = $this->generate_plan_data( $wc_product, $paypal_product_id );
955 $paypal_plan = $this->create_paypal_plan( $plan_data, $access_token );
956
957 if ( $paypal_plan ) {
958 $stored_plans[] = [
959 'plan_id' => $paypal_plan->id,
960 'fingerprint' => $fingerprint,
961 ];
962 update_post_meta( $wc_product_id, $this->get_meta_key( 'plans' ), $stored_plans );
963 return $paypal_plan->id;
964 }
965
966 return null;
967 }
968
969 /**
970 * Get or create PayPal product.
971 *
972 * @param WC_Product $wc_product WooCommerce Product.
973 * @param string $access_token PayPal Access Token.
974 */
975 public function get_or_create_paypal_product( WC_Product $wc_product, string $access_token ) {
976 // Prepare product data.
977 $product_data = [
978 'name' => $this->truncate_string( $wc_product->get_name(), 126 ),
979 'description' => $this->truncate_string( $wc_product->get_short_description(), 256 ),
980 'type' => $wc_product->get_virtual() ? 'DIGITAL' : 'PHYSICAL',
981 'image_url' => $wc_product->get_image_id() ? $this->truncate_string( wp_get_attachment_url( $wc_product->get_image_id() ), 2000 ) : '',
982 'home_url' => $this->truncate_string( get_permalink( $wc_product->get_id() ), 2000 ),
983 ];
984
985 // TODO: implement logic to find existing PayPal product.
986 // $paypal_product = $this->find_paypal_product( $product_data, $access_token );
987
988 // If not found, create a new PayPal product.
989 $paypal_product = $this->create_paypal_product( $product_data, $access_token );
990
991 // Return PayPal product or null.
992 return $paypal_product;
993 }
994
995 /**
996 * Handle transaction event from PayPal.
997 *
998 * @param array $webhook_data Webhook data from PayPal.
999 * @param WC_Order|null $order Order object, or null if not yet resolved by transaction ID.
1000 * @param string|null $transaction_id Transaction ID from webhook data.
1001 * @param string|null $subscription_id PayPal subscription ID from webhook data.
1002 * @param int|null $wpsubs_id WP subscription post ID resolved from mapping table.
1003 */
1004 public function handle_transaction_event( array $webhook_data, ?WC_Order $order, ?string $transaction_id, ?string $subscription_id, ?int $wpsubs_id = null ) {
1005 // Get event type.
1006 $event = $webhook_data['event_type'] ?? 'N/A';
1007
1008 switch ( $event ) {
1009 case 'PAYMENT.SALE.COMPLETED':
1010 // If order was found by transaction_id and is already completed, this is a duplicate delivery.
1011 if ( $order && $order->has_status( 'completed' ) ) {
1012 $log_message = sprintf(
1013 // translators: %s: transaction id.
1014 __( 'Transaction webhook [PAYMENT.SALE.COMPLETED] already processed for transaction #%s. Skipping.', 'subscription' ),
1015 $transaction_id
1016 );
1017 subscrpt_write_log( $log_message );
1018 wp_die( esc_html( $log_message ), '200 Success', array( 'response' => 200 ) );
1019 }
1020
1021 // Resolve order via subscription when not found by transaction_id.
1022 if ( ! $order && $wpsubs_id ) {
1023 $related = Helper::get_related_orders( $wpsubs_id );
1024 $latest_row = ! empty( $related ) ? reset( $related ) : null;
1025 $latest_order = $latest_row ? wc_get_order( (int) $latest_row->order_id ) : null;
1026
1027 if ( $latest_order ) {
1028 $existing_txn = $latest_order->get_transaction_id();
1029
1030 if ( $existing_txn && $existing_txn === $transaction_id ) {
1031 // Same transaction already on the order — duplicate delivery.
1032 $log_message = sprintf(
1033 // translators: %s: transaction id.
1034 __( 'Transaction webhook [PAYMENT.SALE.COMPLETED] already processed for transaction #%s. Skipping.', 'subscription' ),
1035 $transaction_id
1036 );
1037 subscrpt_write_log( $log_message );
1038 wp_die( esc_html( $log_message ), '200 Success', array( 'response' => 200 ) );
1039 } elseif ( $existing_txn && $existing_txn !== $transaction_id ) {
1040 // Order already has a different transaction — this is a renewal payment.
1041 $order = Helper::create_renewal_order( $wpsubs_id );
1042 if ( $order ) {
1043 // translators: %s: transaction id.
1044 $order->add_order_note( sprintf( __( 'Renewal order created by PayPal webhook. Transaction ID: %s', 'subscription' ), $transaction_id ) );
1045 $order->save();
1046 }
1047 } else {
1048 // No transaction ID yet — initial payment arriving before or after thank-you page.
1049 $order = $latest_order;
1050 }
1051 }
1052 }
1053
1054 if ( ! $order ) {
1055 $log_message = sprintf(
1056 // translators: %1$s: event; %2$s: subscription id.
1057 __( 'Transaction webhook received [%1$s]. No order found for subscription #%2$s.', 'subscription' ),
1058 $event,
1059 $subscription_id
1060 );
1061 subscrpt_write_log( $log_message );
1062 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
1063 wp_die( esc_html( $log_message ), '404 not found', array( 'response' => 404 ) );
1064 }
1065
1066 if ( ! $order instanceof \WC_Order ) {
1067 $log_message = sprintf(
1068 // translators: %s: subscription id.
1069 __( 'Transaction webhook received [PAYMENT.SALE.COMPLETED]. Failed to create renewal order for subscription #%s.', 'subscription' ),
1070 $wpsubs_id
1071 );
1072 subscrpt_write_log( $log_message );
1073 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
1074 wp_die( esc_html( $log_message ), '500 Internal Error', array( 'response' => 500 ) );
1075 }
1076
1077 $order->set_transaction_id( $transaction_id );
1078
1079 // If already completed (e.g. thank-you page ran first), just record the transaction ID.
1080 if ( $order->has_status( 'completed' ) ) {
1081 $order->add_order_note( __( 'PayPal transaction ID recorded by webhook.', 'subscription' ) );
1082 $order->save();
1083
1084 // translators: %s: alert name.
1085 $log_message = sprintf( __( 'Transaction webhook received [%s]. Order already completed; transaction ID updated.', 'subscription' ), $event );
1086 subscrpt_write_log( $log_message );
1087 wp_die( esc_html( $log_message ), '200 Success', array( 'response' => 200 ) );
1088 }
1089
1090 if ( $order->update_status( 'completed' ) ) {
1091 $order->add_order_note( __( 'Payment completed by paypal webhook.', 'subscription' ) );
1092 $order->save();
1093
1094 // translators: %s: alert name.
1095 $log_message = sprintf( __( 'Transaction webhook received [%s]. Payment completed.', 'subscription' ), $event );
1096 subscrpt_write_log( $log_message );
1097 wp_die( esc_html( $log_message ), '200 Success', array( 'response' => 200 ) );
1098 } else {
1099 $order->add_order_note( __( 'Failed to complete payment. Requested by paypal webhook.', 'subscription' ) );
1100 $order->save();
1101
1102 // translators: %s: alert name.
1103 $log_message = sprintf( __( 'Transaction webhook received [%s]. Payment completion failed.', 'subscription' ), $event );
1104 subscrpt_write_log( $log_message );
1105 wp_die( esc_html( $log_message ), '506 Internal Error', array( 'response' => 506 ) );
1106 }
1107 break;
1108
1109 case 'PAYMENT.SALE.REFUNDED':
1110 if ( ! $order ) {
1111 $log_message = sprintf(
1112 // translators: %1$s: event; %2$s: subscription id.
1113 __( 'Transaction webhook received [%1$s]. No order found for subscription #%2$s.', 'subscription' ),
1114 $event,
1115 $subscription_id
1116 );
1117 subscrpt_write_log( $log_message );
1118 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
1119 wp_die( esc_html( $log_message ), '404 not found', array( 'response' => 404 ) );
1120 }
1121
1122 $refund_amount = (float) ( $webhook_data['resource']['amount']['total'] ?? 0 );
1123 $order_total = (float) $order->get_total();
1124 $is_full = $refund_amount >= $order_total;
1125
1126 if ( $is_full ) {
1127 $order->update_status( 'refunded' );
1128 }
1129
1130 $order->add_order_note(
1131 $is_full
1132 ? __( 'Full payment refunded by PayPal webhook.', 'subscription' )
1133 : sprintf(
1134 // translators: %s: refunded amount.
1135 __( 'Partial payment refunded by PayPal webhook. Amount: %s', 'subscription' ),
1136 wc_price( $refund_amount, [ 'currency' => $order->get_currency() ] )
1137 )
1138 );
1139 $order->save();
1140
1141 // translators: %s: alert name.
1142 $log_message = sprintf( __( 'Transaction webhook received [%s]. Payment refunded.', 'subscription' ), $event );
1143 subscrpt_write_log( $log_message );
1144 wp_die( esc_html( $log_message ), '200 Success', array( 'response' => 200 ) );
1145 break;
1146
1147 default:
1148 $log_message = sprintf(
1149 // translators: %s: alert name.
1150 __( 'Transaction webhook received [%s]. No actions taken.', 'subscription' ),
1151 $event,
1152 );
1153 subscrpt_write_log( $log_message );
1154 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
1155 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1156 }
1157 }
1158
1159 /**
1160 * Handle subscription event from PayPal.
1161 *
1162 * @param array $webhook_data Webhook data from PayPal.
1163 * @param WC_Order|null $order Order object, or null when resolved via $wpsubs_id.
1164 * @param string|null $transaction_id Transaction ID from webhook data.
1165 * @param string|null $paypal_subscription_id PayPal subscription ID from webhook data.
1166 * @param int|null $wpsubs_id WP subscription post ID resolved from mapping table.
1167 */
1168 public function handle_subscription_event( array $webhook_data, ?WC_Order $order, ?string $transaction_id, ?string $paypal_subscription_id, ?int $wpsubs_id = null ) {
1169 // Get event type.
1170 $event = $webhook_data['event_type'] ?? 'N/A';
1171
1172 if ( ! $wpsubs_id ) {
1173 // Subscription.
1174 $subscription = Helper::get_subscriptions_from_order( $order );
1175
1176 // If no subscription, try to get from order item.
1177 if ( empty( $subscription ) ) {
1178 $log_message = sprintf(
1179 // translators: %s: alert name.
1180 __( 'Subscription webhook received [%s]. Subscription not found. Attempting to get from order item.', 'subscription' ),
1181 $event
1182 );
1183 subscrpt_write_log( $log_message );
1184 subscrpt_write_debug_log( $log_message );
1185
1186 $order_items = $order->get_items();
1187 foreach ( $order_items as $item ) {
1188 $tmp_subs = Helper::get_subscription_from_order_item_id( $item->get_id() );
1189
1190 if ( ! empty( $tmp_subs ) ) {
1191 $subscription = $tmp_subs;
1192
1193 if ( ! empty( $subscription->subscription_id ?? null ) ) {
1194 $log_message = sprintf(
1195 // translators: %s: subscription id.
1196 __( 'Subscription found [ID: %s]. Processing webhook.', 'subscription' ),
1197 $subscription->subscription_id
1198 );
1199 subscrpt_write_log( $log_message );
1200 subscrpt_write_debug_log( $log_message );
1201 }
1202 break;
1203 }
1204 }
1205 }
1206
1207 // If still no subscription, exit.
1208 if ( empty( $subscription ) || empty( $subscription->subscription_id ?? null ) ) {
1209 $log_message = sprintf(
1210 // translators: %s: alert name.
1211 __( 'Subscription webhook received [%s]. Subscription not found. Stopping Process.', 'subscription' ),
1212 $event,
1213 );
1214 subscrpt_write_log( $log_message );
1215 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
1216 wp_die( esc_html( $log_message ), '404 not found', array( 'response' => 404 ) );
1217 }
1218
1219 $subscription_id = $subscription->subscription_id;
1220 } else {
1221 $subscription_id = $wpsubs_id;
1222 }
1223
1224 switch ( $event ) {
1225 case 'BILLING.SUBSCRIPTION.ACTIVATED':
1226 if ( ! in_array( get_post_status( $subscription_id ), [ 'active' ], true ) ) {
1227 Action::status( 'active', $subscription_id );
1228
1229 update_post_meta( $subscription_id, $this->get_meta_key( 'paypal_subs_status' ), 'active' );
1230
1231 $log_message = __( 'Subscription activated by PayPal webhook.', 'subscription' );
1232 subscrpt_write_log( $log_message );
1233 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1234 }
1235
1236 // translators: %s: alert name.
1237 $log_message = sprintf( __( 'Subscription webhook received [%s]. No actions taken.', 'subscription' ), $event );
1238 subscrpt_write_log( $log_message );
1239 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1240 break;
1241
1242 case 'BILLING.SUBSCRIPTION.EXPIRED':
1243 if ( in_array( get_post_status( $subscription_id ), [ 'active', 'pe_cancelled' ], true ) ) {
1244 Action::status( 'expired', $subscription_id );
1245
1246 update_post_meta( $subscription_id, $this->get_meta_key( 'paypal_subs_status' ), 'expired' );
1247
1248 $log_message = __( 'Subscription expired by PayPal webhook.', 'subscription' );
1249 subscrpt_write_log( $log_message );
1250 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1251 }
1252
1253 // translators: %s: alert name.
1254 $log_message = sprintf( __( 'Subscription webhook received [%s]. No actions taken.', 'subscription' ), $event );
1255 subscrpt_write_log( $log_message );
1256 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1257 break;
1258
1259 case 'BILLING.SUBSCRIPTION.CANCELLED':
1260 if ( ! in_array( get_post_status( $subscription_id ), [ 'cancelled', 'expired' ], true ) ) {
1261 Action::status( 'cancelled', $subscription_id );
1262
1263 update_post_meta( $subscription_id, $this->get_meta_key( 'paypal_subs_status' ), 'cancelled' );
1264
1265 $log_message = __( 'Subscription cancelled by PayPal webhook.', 'subscription' );
1266 subscrpt_write_log( $log_message );
1267 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1268 }
1269
1270 // translators: %s: alert name.
1271 $log_message = sprintf( __( 'Subscription webhook received [%s]. No actions taken.', 'subscription' ), $event );
1272 subscrpt_write_log( $log_message );
1273 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1274 break;
1275
1276 default:
1277 $log_message = sprintf(
1278 // translators: %s: alert name.
1279 __( 'Subscription webhook received [%s]. No actions taken.', 'subscription' ),
1280 $event,
1281 );
1282 subscrpt_write_log( $log_message );
1283 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
1284 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1285 break;
1286 }
1287 }
1288
1289 /**
1290 * Handle subscription cancellation.
1291 *
1292 * @param int $subscription_id Subscription ID.
1293 */
1294 public function handle_subscription_cancellation( int $subscription_id ) {
1295 $order_id = get_post_meta( $subscription_id, '_subscrpt_order_id', true );
1296 $order = wc_get_order( $order_id );
1297
1298 // Get order payment method
1299 $payment_method = $order->get_payment_method();
1300
1301 // Get paypal subscription status from subscription meta.
1302 $paypal_subs_status = get_post_meta( $subscription_id, $this->get_meta_key( 'paypal_subs_status' ), true );
1303
1304 // Only process if the payment method is PayPal and the subscription is not already cancelled.
1305 if ( ( $this->id !== $payment_method ) || ( ! empty( $paypal_subs_status ) && $paypal_subs_status === 'cancelled' ) ) {
1306 return;
1307 }
1308
1309 // Get paypal subscription ID from order meta.
1310 $paypal_subscription_id = $order->get_meta( $this->get_meta_key( 'subscription_id' ) );
1311
1312 if ( empty( $paypal_subscription_id ) ) {
1313 subscrpt_write_log( 'PayPal subscription ID not found in order meta. Attempting to get from order history.' );
1314
1315 global $wpdb;
1316 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
1317 $order_histories = $wpdb->get_results( // phpcs:ignore
1318 $wpdb->prepare(
1319 'SELECT * FROM %i WHERE subscription_id=%d ORDER BY order_id DESC',
1320 [ $table_name, $subscription_id ]
1321 )
1322 );
1323
1324 foreach ( $order_histories as $history ) {
1325 // Get order ID from history.
1326 $order_id = $history->order_id ?? null;
1327 $order = wc_get_order( $order_id );
1328
1329 // Get PayPal subscription ID from order meta.
1330 $tmp_paypal_subs_id = $order->get_meta( $this->get_meta_key( 'subscription_id' ) );
1331
1332 // OLD key migration.
1333 // If no data check if the data exists with the old key. And update if necessary.
1334 // ? Dev note: Remove after JAN 1, 2026.
1335 if ( empty( $tmp_paypal_subs_id ) ) {
1336 $tmp_paypal_subs_id = $order->get_meta( '_wp_subs_paypal_subscription_id', true );
1337
1338 if ( ! empty( $tmp_paypal_subs_id ) ) {
1339 $order->update_meta_data( $this->get_meta_key( 'subscription_id' ), $tmp_paypal_subs_id );
1340 $order->save();
1341 }
1342 }
1343
1344 if ( ! empty( $tmp_paypal_subs_id ) ) {
1345 $paypal_subscription_id = $tmp_paypal_subs_id;
1346 break;
1347 }
1348 }
1349 }
1350
1351 // Get PayPal Access Token.
1352 $access_token = $this->get_paypal_access_token();
1353 if ( ! $access_token ) {
1354 subscrpt_write_log( 'Access token not found. Retrying.' );
1355
1356 $access_token = $this->get_paypal_access_token();
1357
1358 if ( ! $access_token ) {
1359 subscrpt_write_log( 'Access token not found.' );
1360 subscrpt_write_log( "Failed to cancel subscription #{$subscription_id} in PayPal." );
1361 return;
1362 }
1363 }
1364
1365 // Cancel subscription in PayPal.
1366 $result = $this->cancel_paypal_subscription( $paypal_subscription_id, $access_token, 'Customer requested cancellation.' );
1367 if ( $result ) {
1368 update_post_meta( $subscription_id, $this->get_meta_key( 'paypal_subs_status' ), 'cancelled' );
1369
1370 subscrpt_write_log( "Subscription #{$subscription_id} cancelled successfully in PayPal." );
1371 } else {
1372 subscrpt_write_log( "Failed to cancel subscription #{$subscription_id} in PayPal." );
1373 }
1374 }
1375
1376 // * ------------------------------------------------------------------------ * //
1377 // * -------------------- Utility Methods [start] --------------------------- * //
1378
1379 /**
1380 * Truncate long string.
1381 *
1382 * @param string $long_string The long string to truncate.
1383 * @param int $max_length The maximum length of the string.
1384 * @return string The truncated string if it exceeds the maximum length, otherwise the original string
1385 */
1386 public function truncate_string( string $long_string, int $max_length = 48 ): string {
1387 return strlen( $long_string ) <= $max_length ? $long_string : substr( $long_string, 0, $max_length );
1388 }
1389
1390 /**
1391 * Get Prefixed Meta Key.
1392 * Prefix the key with '_wp_subs_' to avoid possible conflicts with other plugins.
1393 *
1394 * @param string $key The key to prefix.
1395 * @param string|null $mode_override Optional mode override (sandbox/live).
1396 */
1397 public function get_meta_key( string $key, ?string $mode_override = null ): string {
1398 $keys = [
1399 'product_data' => 'product_data',
1400 'plan_id' => 'plan_id',
1401 'plan_desc' => 'plan_description',
1402 'plans' => 'plans',
1403 'subscription_id' => 'subscription_id',
1404 'paypal_subs_status' => 'paypal_subs_status',
1405 ];
1406 $selected_key = $keys[ $key ] ?? $key;
1407
1408 $mode_string = $this->sandbox_mode ? 'sandbox_' : 'live_';
1409 if ( ! empty( $mode_override ) ) {
1410 $mode_string = 'sandbox' === $mode_override ? 'sandbox_' : 'live_';
1411 }
1412
1413 return '_wp_subs_paypal_' . $mode_string . $selected_key;
1414 }
1415
1416 /**
1417 * Convert a billing interval string to PayPal's uppercase singular format.
1418 * subscrpt_get_typos function of the plugin have translator on the intervals. PayPal will only accept english.
1419 *
1420 * @param string $interval Raw interval string (e.g. 'month', 'months', 'WEEK').
1421 * @return string PayPal interval constant: DAY, WEEK, MONTH, or YEAR.
1422 */
1423 private function convert_paypal_interval( string $interval ): string {
1424 switch ( strtolower( $interval ) ) {
1425 case 'day':
1426 case 'days':
1427 return 'DAY';
1428 case 'week':
1429 case 'weeks':
1430 return 'WEEK';
1431 case 'month':
1432 case 'months':
1433 return 'MONTH';
1434 case 'year':
1435 case 'years':
1436 return 'YEAR';
1437 default:
1438 return 'MONTH';
1439 }
1440 }
1441
1442 /**
1443 * Generate a fingerprint hash of all critical billing fields for a product.
1444 *
1445 * The fingerprint encodes every field that determines a distinct PayPal billing
1446 * plan (price, currency, interval, trial, signup fee, cycle count). Two products
1447 * with identical critical fields produce the same fingerprint and can share a plan.
1448 *
1449 * @param WC_Product $wc_product WooCommerce product (simple or variation).
1450 * @return string MD5 hash of the critical fields.
1451 */
1452 private function generate_plan_fingerprint( WC_Product $wc_product ): string {
1453 $wpsubs_product = Subscription::get_subs_product( $wc_product );
1454 $meta_cycles = $wc_product->get_meta( '_subscrpt_max_no_payment' );
1455 $total_cycles = $meta_cycles ? $meta_cycles : 0;
1456
1457 $data = [
1458 'price' => number_format( (float) wc_get_price_including_tax( $wc_product ), 2, '.', '' ),
1459 'currency' => get_woocommerce_currency(),
1460 'interval' => $this->convert_paypal_interval( $wpsubs_product->get_timing_option() ),
1461 'interval_count' => (int) $wpsubs_product->get_timing_per(),
1462 'trial_interval' => $this->convert_paypal_interval( $wpsubs_product->get_trial_timing_option() ),
1463 'trial_count' => (int) $wpsubs_product->get_trial_timing_per(),
1464 'signup_fee' => number_format( (float) $wpsubs_product->get_signup_fee(), 2, '.', '' ),
1465 'total_cycles' => (int) $total_cycles,
1466 ];
1467
1468 return md5( wp_json_encode( $data ) );
1469 }
1470
1471 /**
1472 * Generate PayPal Plan Data.
1473 *
1474 * @param WC_Product $wc_product WooCommerce Product.
1475 * @param string $paypal_product_id PayPal Product ID.
1476 */
1477 public function generate_plan_data( WC_Product $wc_product, string $paypal_product_id ): array {
1478 // Get WPSubscription wrapped product.
1479 // $wpsubs_product type WC_Product
1480 $wpsubs_product = Subscription::get_subs_product( $wc_product );
1481
1482 // Name.
1483 $name = $this->truncate_string( $wc_product->get_name(), 126 );
1484
1485 // Description.
1486 $description = $this->truncate_string( $wc_product->get_short_description(), 126 );
1487
1488 // Price.
1489 $price = wc_get_price_including_tax( $wc_product );
1490
1491 // Recurring Details.
1492 $plan_length = $wpsubs_product->get_timing_per();
1493 $plan_interval = $this->convert_paypal_interval( $wpsubs_product->get_timing_option() );
1494 $trial_length = $wpsubs_product->get_trial_timing_per();
1495 $trial_interval = $this->convert_paypal_interval( $wpsubs_product->get_trial_timing_option() );
1496 $signup_fee = $wpsubs_product->get_signup_fee();
1497
1498 // get value for total_cycles from _subscrpt_max_no_payment
1499 $meta_cycles = $wc_product->get_meta( '_subscrpt_max_no_payment' );
1500 $total_cycles = $meta_cycles ? $meta_cycles : 0;
1501
1502 // Billing Cycles.
1503 $billing_cycles = [];
1504
1505 // Add trial cycle in billing cycles if available.
1506 if ( (int) $trial_length > 0 ) {
1507 $billing_cycles[] = [
1508 'tenure_type' => 'TRIAL',
1509 'sequence' => 1,
1510 'total_cycles' => $total_cycles,
1511 'frequency' => [
1512 'interval_unit' => $trial_interval,
1513 'interval_count' => (int) $trial_length,
1514 ],
1515 ];
1516 }
1517
1518 // Add regular cycle in billing cycles.
1519 $billing_cycles[] = [
1520 'tenure_type' => 'REGULAR',
1521 'sequence' => count( $billing_cycles ) + 1,
1522 'total_cycles' => 0,
1523 'pricing_scheme' => [
1524 'fixed_price' => [
1525 'value' => number_format( (float) $price, 2, '.', '' ),
1526 'currency_code' => get_woocommerce_currency(),
1527 ],
1528 ],
1529 'frequency' => [
1530 'interval_unit' => $plan_interval,
1531 'interval_count' => (int) $plan_length,
1532 ],
1533 ];
1534
1535 // Payment Preferences.
1536 $payment_preferences = [
1537 'auto_bill_outstanding' => true,
1538 'setup_fee_failure_action' => 'CANCEL',
1539 'payment_failure_threshold' => 3,
1540 'setup_fee' => [
1541 'value' => number_format( (float) $signup_fee, 2, '.', '' ),
1542 'currency_code' => get_woocommerce_currency(),
1543 ],
1544 ];
1545
1546 // Final Data.
1547 $plan_data = [
1548 'product_id' => $paypal_product_id,
1549 'name' => $name,
1550 'description' => $description,
1551 'billing_cycles' => $billing_cycles,
1552 'quantity_supported' => false,
1553 'payment_preferences' => $payment_preferences,
1554 ];
1555 return $plan_data;
1556 }
1557
1558 // * -------------------- Utility Methods [end] --------------------------- * //
1559 // * ---------------------------------------------------------------------- * //
1560
1561
1562 // * ---------------------------------------------------------------- * //
1563 // * -------------------- API Operations [start] -------------------- * //
1564 // ? Keep this section strictly for API operations. No other logic like data extraction should be added here.
1565
1566 /**
1567 * Get PayPal Access Token.
1568 */
1569 private function get_paypal_access_token(): ?string {
1570 try {
1571 $url = $this->api_endpoint . '/v1/oauth2/token';
1572 $args = [
1573 'method' => 'POST',
1574 'headers' => [
1575 'Accept' => 'application/json',
1576 'Accept-Language' => 'en_US',
1577 'Authorization' => 'Basic ' . base64_encode( $this->client_id . ':' . $this->client_secret ), // phpcs:ignore
1578 ],
1579 'body' => [
1580 'grant_type' => 'client_credentials',
1581 ],
1582 ];
1583
1584 $response = wp_remote_post( $url, $args );
1585 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1586
1587 if ( isset( $response_data->error ) || ! isset( $response_data->access_token ) ) {
1588 $error_description = ! empty( $response_data ) ? $response_data->error_description ?? 'Unknown error' : 'Unknown error';
1589 $log_message = 'Gateway Error : PayPal access token - ' . $error_description;
1590 subscrpt_write_log( $log_message );
1591 subscrpt_write_debug_log( $log_message );
1592
1593 return null;
1594 }
1595
1596 return $response_data->access_token;
1597 } catch ( Exception $e ) {
1598 $log_message = $e->getMessage();
1599 subscrpt_write_log( $log_message );
1600 subscrpt_write_debug_log( $log_message );
1601
1602 return null;
1603 }
1604 }
1605
1606 /**
1607 * Create PayPal product.
1608 *
1609 * @param array $product_data Product data to create.
1610 * @param string $access_token PayPal Access Token.
1611 */
1612 private function create_paypal_product( array $product_data, string $access_token ): ?object {
1613 if ( empty( $product_data['name'] ?? null ) || empty( $product_data['type'] ?? null ) ) {
1614 $log_message = __( 'PayPal Product Creation Error: Product data is incomplete. Name and type are required.', 'subscription' );
1615 subscrpt_write_log( $log_message );
1616 subscrpt_write_debug_log( $log_message );
1617 return null;
1618 }
1619
1620 // Prepare the body for the API request.
1621 $body = [
1622 'name' => $product_data['name'],
1623 'type' => $product_data['type'],
1624 ];
1625 if ( ! empty( $product_data['description'] ?? null ) ) {
1626 $body['description'] = $product_data['description'];
1627 }
1628 if ( ! empty( $product_data['category'] ?? null ) ) {
1629 $body['category'] = $product_data['category'];
1630 }
1631 if ( ! empty( $product_data['image_url'] ?? null ) && ! strpos( $product_data['image_url'], '.test' ) ) {
1632 $body['image_url'] = $product_data['image_url'];
1633 }
1634 if ( ! empty( $product_data['home_url'] ?? null ) && ! strpos( $product_data['home_url'], '.test' ) ) {
1635 $body['home_url'] = $product_data['home_url'];
1636 }
1637
1638 try {
1639 $url = $this->api_endpoint . '/v1/catalogs/products';
1640 $args = [
1641 'method' => 'POST',
1642 'headers' => [
1643 'Authorization' => 'Bearer ' . $access_token,
1644 'Content-Type' => 'application/json',
1645 'Prefer' => 'return=representation',
1646 'PayPal-Request-Id' => uniqid( 'wp-subs-paypal-', true ),
1647 ],
1648 'body' => wp_json_encode( $body ),
1649 ];
1650
1651 $response = wp_remote_post( $url, $args );
1652 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1653
1654 if ( empty( $response_data->id ?? null ) ) {
1655 $log_message = 'Error creating PayPal product: ' . ( $response_data->error_description ?? 'Unknown error' );
1656 subscrpt_write_log( $log_message );
1657 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $response_data ) );
1658 return null;
1659 }
1660
1661 return $response_data;
1662 } catch ( Exception $e ) {
1663 $log_message = 'Error creating PayPal product: ' . $e->getMessage();
1664 subscrpt_write_log( $log_message );
1665 subscrpt_write_debug_log( $log_message );
1666 return null;
1667 }
1668 }
1669
1670 /**
1671 * Create PayPal plan.
1672 *
1673 * @param array $plan_data Plan data to create.
1674 * @param string $access_token PayPal Access Token.
1675 */
1676 private function create_paypal_plan( array $plan_data, string $access_token ): ?object {
1677 // Prepare the body for the API request.
1678 $body = [
1679 'product_id' => $plan_data['product_id'],
1680 'name' => $plan_data['name'],
1681 'billing_cycles' => $plan_data['billing_cycles'],
1682 'payment_preferences' => $plan_data['payment_preferences'],
1683 ];
1684 if ( ! empty( $plan_data['description'] ?? null ) ) {
1685 $body['description'] = $plan_data['description'];
1686 }
1687 if ( ! empty( $plan_data['quantity_supported'] ?? null ) ) {
1688 $body['quantity_supported'] = $plan_data['quantity_supported'];
1689 }
1690
1691 try {
1692 $url = $this->api_endpoint . '/v1/billing/plans';
1693 $args = [
1694 'method' => 'POST',
1695 'headers' => [
1696 'Authorization' => 'Bearer ' . $access_token,
1697 'Content-Type' => 'application/json',
1698 'Prefer' => 'return=representation',
1699 'PayPal-Request-Id' => uniqid( 'wp-subs-paypal-', true ),
1700 ],
1701 'body' => wp_json_encode( $body ),
1702 ];
1703
1704 $response = wp_remote_post( $url, $args );
1705 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1706
1707 if ( empty( $response_data->id ?? null ) ) {
1708 $log_message = 'Error creating PayPal plan: ' . ( $response_data->error_description ?? $response_data->message ?? 'Unknown error' );
1709 subscrpt_write_log( $log_message );
1710 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $response_data ) );
1711 return null;
1712 }
1713
1714 return $response_data;
1715 } catch ( Exception $e ) {
1716 $log_message = 'Error creating PayPal plan: ' . $e->getMessage();
1717 subscrpt_write_log( $log_message );
1718 subscrpt_write_debug_log( $log_message );
1719 return null;
1720 }
1721 }
1722
1723 /**
1724 * Create PayPal subscription.
1725 *
1726 * @param array $paypal_subscription_data PayPal subscription data.
1727 * @param string $access_token PayPal Access Token.
1728 */
1729 private function create_paypal_subscription( array $paypal_subscription_data, string $access_token ): ?object {
1730 // Prepare the body for the API request.
1731 $body = [
1732 'plan_id' => $paypal_subscription_data['plan_id'],
1733 'application_context' => $paypal_subscription_data['application_context'],
1734 ];
1735
1736 try {
1737 $url = $this->api_endpoint . '/v1/billing/subscriptions';
1738 $args = [
1739 'method' => 'POST',
1740 'headers' => [
1741 'Authorization' => 'Bearer ' . $access_token,
1742 'Content-Type' => 'application/json',
1743 'Prefer' => 'return=representation',
1744 'PayPal-Request-Id' => uniqid( 'wp-subs-paypal-', true ),
1745 ],
1746 'body' => wp_json_encode( $body ),
1747 ];
1748
1749 $response = wp_remote_post( $url, $args );
1750 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1751
1752 if ( empty( $response_data->id ?? null ) ) {
1753 $log_message = 'Error creating PayPal subscription: ' . ( $response_data->error_description ?? $response_data->message ?? 'Unknown error' );
1754 subscrpt_write_log( $log_message );
1755 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $response_data ) );
1756 return null;
1757 }
1758
1759 return $response_data;
1760 } catch ( Exception $e ) {
1761 $log_message = 'Error creating PayPal subscription: ' . $e->getMessage();
1762 subscrpt_write_log( $log_message );
1763 subscrpt_write_debug_log( $log_message );
1764 return null;
1765 }
1766 }
1767
1768 /**
1769 * Process a refund via PayPal Captures API.
1770 *
1771 * @param int $order_id WooCommerce order ID.
1772 * @param float $amount Amount to refund, or null for full refund.
1773 * @param string $reason Reason for refund.
1774 * @return bool|\WP_Error True on success, WP_Error on failure.
1775 */
1776 public function process_refund( $order_id, $amount = null, $reason = '' ) {
1777 $order = wc_get_order( $order_id );
1778 if ( ! $order ) {
1779 return new \WP_Error( 'invalid_order', __( 'Order not found.', 'subscription' ) );
1780 }
1781
1782 $capture_id = $order->get_transaction_id();
1783 if ( ! $capture_id ) {
1784 return new \WP_Error( 'no_capture_id', __( 'PayPal capture ID not found on this order.', 'subscription' ) );
1785 }
1786
1787 $access_token = $this->get_paypal_access_token();
1788 if ( ! $access_token ) {
1789 return new \WP_Error( 'no_access_token', __( 'Failed to get PayPal access token.', 'subscription' ) );
1790 }
1791
1792 try {
1793 $url = $this->api_endpoint . "/v1/payments/sale/{$capture_id}/refund";
1794 $body = [];
1795
1796 if ( null !== $amount ) {
1797 $body['amount'] = [
1798 'total' => number_format( (float) $amount, 2, '.', '' ),
1799 'currency' => $order->get_currency(),
1800 ];
1801 }
1802
1803 if ( ! empty( $reason ) ) {
1804 $body['description'] = substr( $reason, 0, 255 );
1805 }
1806
1807 $args = [
1808 'method' => 'POST',
1809 'headers' => [
1810 'Authorization' => 'Bearer ' . $access_token,
1811 'Content-Type' => 'application/json',
1812 'PayPal-Request-Id' => uniqid( 'wp-subs-refund-', true ),
1813 ],
1814 'body' => wp_json_encode( $body ),
1815 ];
1816
1817 $response = wp_remote_post( $url, $args );
1818 $response_code = (int) wp_remote_retrieve_response_code( $response );
1819 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1820
1821 if ( 201 === $response_code ) {
1822 $refund_id = $response_data->id ?? '';
1823 $order->add_order_note(
1824 sprintf(
1825 // translators: %s: PayPal refund ID.
1826 __( 'PayPal refund initiated. Refund ID: %s', 'subscription' ),
1827 $refund_id
1828 )
1829 );
1830 return true;
1831 }
1832
1833 $error_message = $response_data->message ?? $response_data->error_description ?? 'Unknown error';
1834 $log_message = 'PayPal refund failed: ' . $error_message;
1835 subscrpt_write_log( $log_message );
1836 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $response_data ) );
1837
1838 return new \WP_Error( 'paypal_refund_failed', $error_message );
1839
1840 } catch ( Exception $e ) {
1841 $log_message = 'PayPal refund exception: ' . $e->getMessage();
1842 subscrpt_write_log( $log_message );
1843 subscrpt_write_debug_log( $log_message );
1844 return new \WP_Error( 'paypal_refund_exception', $e->getMessage() );
1845 }
1846 }
1847
1848 /**
1849 * Cancel PayPal subscription.
1850 *
1851 * @param string $subscription_id PayPal Subscription ID.
1852 * @param string $access_token PayPal Access Token.
1853 * @param string $reason Reason for cancellation.
1854 */
1855 private function cancel_paypal_subscription( string $subscription_id, string $access_token, string $reason = 'admin cancel' ): bool {
1856 // Prepare the body for the API request.
1857 $body = [
1858 'reason' => $reason,
1859 ];
1860
1861 try {
1862 $url = $this->api_endpoint . "/v1/billing/subscriptions/$subscription_id/cancel";
1863
1864 $args = [
1865 'method' => 'POST',
1866 'headers' => [
1867 'Authorization' => 'Bearer ' . $access_token,
1868 'Content-Type' => 'application/json',
1869 ],
1870 'body' => wp_json_encode( $body ),
1871 ];
1872
1873 $response = wp_remote_post( $url, $args );
1874 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1875
1876 if ( ! empty( $response_data->message ?? null ) ) {
1877 $log_message = 'Error cancelling PayPal subscription: ' . ( $response_data->message ?? 'Unknown error' );
1878 subscrpt_write_log( $log_message );
1879 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $response_data ) );
1880 return false;
1881 }
1882
1883 return true;
1884 } catch ( Exception $e ) {
1885 $log_message = 'Error cancelling PayPal subscription: ' . $e->getMessage();
1886 subscrpt_write_log( $log_message );
1887 subscrpt_write_debug_log( $log_message );
1888 return false;
1889 }
1890 }
1891
1892 /**
1893 * Get PayPal order details.
1894 *
1895 * @param string $order_id PayPal Order ID.
1896 */
1897 public function get_paypal_order( string $order_id ) {
1898 // Get PayPal Access Token.
1899 $access_token = $this->get_paypal_access_token();
1900 if ( ! $access_token ) {
1901 subscrpt_write_log( 'Failed to get PayPal order; Access Token unavailable.' );
1902 return false;
1903 }
1904
1905 try {
1906 $url = $this->api_endpoint . "/v2/checkout/orders/$order_id";
1907 $args = [
1908 'method' => 'GET',
1909 'headers' => [
1910 'Authorization' => 'Bearer ' . $access_token,
1911 'Content-Type' => 'application/json',
1912 ],
1913 ];
1914
1915 $response = wp_remote_get( $url, $args );
1916 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1917
1918 if ( empty( $response_data->id ?? null ) ) {
1919 $log_message = 'Error getting PayPal order: ' . ( $response_data->error_description ?? $response_data->message ?? 'Unknown error' );
1920 subscrpt_write_log( $log_message );
1921 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $response_data ) );
1922 return null;
1923 }
1924
1925 return $response_data;
1926 } catch ( Exception $e ) {
1927 $log_message = 'Failed to get PayPal order; ' . $e->getMessage();
1928 subscrpt_write_log( $log_message );
1929 subscrpt_write_debug_log( $log_message );
1930 return false;
1931 }
1932 }
1933
1934 /**
1935 * Get PayPal subscription details.
1936 *
1937 * @param string $subscription_id PayPal Subscription ID.
1938 */
1939 public function get_paypal_subscription( string $subscription_id ): ?object {
1940 // Get PayPal Access Token.
1941 $access_token = $this->get_paypal_access_token();
1942 if ( ! $access_token ) {
1943 subscrpt_write_log( 'Failed to get PayPal Subscription; Access Token unavailable.' );
1944 return null;
1945 }
1946
1947 try {
1948 $url = $this->api_endpoint . "/v1/billing/subscriptions/$subscription_id";
1949 $args = [
1950 'method' => 'GET',
1951 'headers' => [
1952 'Authorization' => 'Bearer ' . $access_token,
1953 'Content-Type' => 'application/json',
1954 ],
1955 ];
1956
1957 $response = wp_remote_get( $url, $args );
1958 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1959
1960 if ( empty( $response_data->id ?? null ) ) {
1961 $log_message = 'Error getting PayPal subscription: ' . ( $response_data->error_description ?? $response_data->message ?? 'Unknown error' );
1962 subscrpt_write_log( $log_message );
1963 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $response_data ) );
1964 return null;
1965 }
1966
1967 return $response_data;
1968 } catch ( Exception $e ) {
1969 $log_message = 'Failed to get PayPal subscription; ' . $e->getMessage();
1970 subscrpt_write_log( $log_message );
1971 subscrpt_write_debug_log( $log_message );
1972 return null;
1973 }
1974 }
1975
1976 // * -------------------- API Operations [end] -------------------- * //
1977 // * -------------------------------------------------------------- * //
1978 }
1979