PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 1.10.0
Subscriptions for WooCommerce with Stripe Recurring Payments v1.10.0
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.10.0, at includes/Illuminate/Gateways/Paypal/Paypal.php

1,977 lines 68.9 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/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 subscription events only when both $order and $wpsubs_id are unresolved.
585 // Transaction events resolve $order internally; subscription events can use $wpsubs_id directly.
586 if ( empty( $order ) && empty( $wpsubs_id ) && in_array( $event, $subscription_events, true ) ) {
587 $log_message = sprintf(
588 // translators: %s: event name.
589 __( 'PayPal webhook received [%s]. Order not found. Queuing for retry.', 'subscription' ),
590 $event,
591 );
592 subscrpt_write_log( $log_message );
593 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
594 wp_die( esc_html( $log_message ), '425 Too Early', array( 'response' => 425 ) );
595 }
596
597 // Finally, handle the webhook.
598 if ( in_array( $event, $transaction_events, true ) ) {
599 $this->handle_transaction_event( $webhook_data, $order, $transaction_id, $paypal_subscription_id, $wpsubs_id );
600 } elseif ( in_array( $event, $subscription_events, true ) ) {
601 $this->handle_subscription_event( $webhook_data, $order, $transaction_id, $paypal_subscription_id, $wpsubs_id );
602 } else {
603 $log_message = sprintf(
604 // translators: %1$s: alert name; %2$s: order id.
605 __( 'PayPal webhook received [%s]. No actions taken.', 'subscription' ),
606 $event,
607 );
608 subscrpt_write_log( $log_message );
609 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
610 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
611 }
612 }
613
614 /**
615 * Verify webhook data from PayPal.
616 *
617 * @param array $headers Headers from the request.
618 * @param string $raw_body Webhook raw body from PayPal.
619 */
620 public function verify_webhook( array $headers, string $raw_body ) {
621 // Get PayPal Access Token.
622 $access_token = $this->get_paypal_access_token();
623 if ( ! $access_token ) {
624 subscrpt_write_log( 'PayPal webhook: Access Token unavailable.' );
625 wp_die( 'Error: Access token not available. Cannot verify webhook.', '401 Unauthorized', array( 'response' => 401 ) );
626 }
627
628 // Prepare the request data to verify the webhook.
629 $payload = [
630 'auth_algo' => $headers['PAYPAL-AUTH-ALGO'] ?? $headers['Paypal-Auth-Algo'] ?? '',
631 'cert_url' => $headers['PAYPAL-CERT-URL'] ?? $headers['Paypal-Cert-Url'] ?? '',
632 'transmission_id' => $headers['PAYPAL-TRANSMISSION-ID'] ?? $headers['Paypal-Transmission-Id'] ?? '',
633 'transmission_sig' => $headers['PAYPAL-TRANSMISSION-SIG'] ?? $headers['Paypal-Transmission-Sig'] ?? '',
634 'transmission_time' => $headers['PAYPAL-TRANSMISSION-TIME'] ?? $headers['Paypal-Transmission-Time'] ?? '',
635 'webhook_id' => $this->webhook_id ?? '',
636 'webhook_event' => $raw_body,
637 ];
638
639 // Verify webhook via REST API.
640 $verified = $this->verify_paypal_webhook_rest_api( $payload, $raw_body, $access_token );
641
642 if ( ! $verified ) {
643 // Fallback to manual method if REST API verification fails.
644 subscrpt_write_log( 'PayPal webhook REST API verification failed. Retrying with manual verification.' );
645
646 $verified = $this->verify_paypal_webhook_manual( $payload, $raw_body );
647 }
648
649 if ( ! $verified ) {
650 subscrpt_write_log( 'PayPal webhook verification failed.' );
651 subscrpt_write_debug_log( 'Webhook verification failed for data: ' . sanitize_text_field( $raw_body ) );
652 wp_die( 'Error: PayPal webhook verification failed.', '403 Forbidden', array( 'response' => 403 ) );
653 }
654 }
655
656 /**
657 * Verify PayPal webhook with REST API.
658 *
659 * @param array $payload Payload data for verification.
660 * @param string $raw_body Webhook raw body from PayPal.
661 * @param string $access_token PayPal Access Token.
662 */
663 protected function verify_paypal_webhook_rest_api( array $payload, string $raw_body, string $access_token ): bool {
664 // Fix the webhook_event to be an array.
665 $payload['webhook_event'] = json_decode( $raw_body, true );
666
667 // Verify webhook signature via PayPal REST API.
668 try {
669 $url = $this->api_endpoint . '/v1/notifications/verify-webhook-signature';
670 $args = [
671 'method' => 'POST',
672 'headers' => [
673 'Authorization' => 'Bearer ' . $access_token,
674 'Content-Type' => 'application/json',
675 ],
676 'body' => wp_json_encode( $payload ),
677 ];
678
679 $response = wp_remote_post( $url, $args );
680 $response_data = json_decode( wp_remote_retrieve_body( $response ), true );
681 $verification_status = $response_data['verification_status'] ?? null;
682
683 if ( empty( $verification_status ) || 'success' !== strtolower( $verification_status ) ) {
684 subscrpt_write_debug_log( 'PayPal Webhook Verification: ' . wp_json_encode( $response_data ) );
685 return false;
686 }
687
688 return ( 'success' === strtolower( $verification_status ) ) ? true : false;
689
690 } catch ( Exception $e ) {
691 $log_message = 'PayPal Webhook Verification Failed: ' . $e->getMessage();
692 subscrpt_write_log( $log_message );
693 subscrpt_write_debug_log( $log_message );
694 return false;
695 }
696 }
697
698 /**
699 * Verify PayPal webhook manually (self verification).
700 *
701 * @param array $payload Payload data for verification.
702 * @param string $raw_body Webhook raw body from PayPal.
703 */
704 protected function verify_paypal_webhook_manual( array $payload, string $raw_body ): bool {
705 // Enforce CRC32 for 32-bit systems (edge case)
706 $crc = sprintf( '%u', crc32( $raw_body ) );
707
708 // Build Message
709 $message = implode(
710 '|',
711 [
712 $payload['transmission_id'],
713 $payload['transmission_time'],
714 $payload['webhook_id'],
715 $crc,
716 ]
717 );
718
719 // Fetch & cache cert
720 $cert_url = esc_url_raw( $payload['cert_url'] );
721 $cache_key = 'paypal_cert_' . md5( $cert_url );
722
723 $cert_pem = get_transient( $cache_key );
724
725 if ( ! $cert_pem ) {
726 $response = wp_remote_get( $cert_url, [ 'timeout' => 20 ] );
727 if ( is_wp_error( $response ) ) {
728 return false;
729 }
730
731 $cert_pem = wp_remote_retrieve_body( $response );
732 set_transient( $cache_key, $cert_pem, DAY_IN_SECONDS );
733 }
734
735 if ( empty( $cert_pem ) ) {
736 return false;
737 }
738
739 // Signature
740 $signature = base64_decode( $payload['transmission_sig'], true );
741
742 if ( false === $signature ) {
743 return false;
744 }
745
746 // Final verification
747 $verified = openssl_verify(
748 $message,
749 $signature,
750 $cert_pem,
751 OPENSSL_ALGO_SHA256
752 );
753
754 return ( 1 === $verified );
755 }
756
757 /**
758 * Process Payment.
759 *
760 * @param int $order_id Order ID.
761 * @return array
762 */
763 public function process_payment( $order_id ) {
764 $order = wc_get_order( $order_id );
765
766 return $this->process_paypal_payment( $order );
767 }
768
769 /**
770 * Process payments in PayPal.
771 *
772 * @param WC_Order $order The order object.
773 */
774 protected function process_paypal_payment( WC_Order $order ): array {
775 // Get PayPal Access Token.
776 $access_token = $this->get_paypal_access_token();
777 if ( ! $access_token ) {
778 return [
779 'result' => 'error',
780 'redirect' => '',
781 'response' => 'PayPal payment failed. Please try again.',
782 ];
783 }
784
785 // Get the first order item.
786 // Based on the logic, the order sould contain only one subscription item.
787 $order_items = $order->get_items();
788 $order_item = ! empty( $order_items ) ? reset( $order_items ) : null;
789
790 // Get WooCommerce Product.
791 $wc_product_id = null;
792 $wc_variation_id = null;
793 $wc_product = null;
794 if ( $order_item && $order_item instanceof WC_Order_Item_Product ) {
795 $wc_product_id = $order_item->get_product_id();
796 $wc_variation_id = $order_item->get_variation_id();
797 $wc_product = wc_get_product( $wc_product_id );
798 }
799
800 if ( ! $wc_product ) {
801 return [
802 'result' => 'error',
803 'redirect' => '',
804 'response' => 'Invalid product in order. Please check the order details.',
805 ];
806 }
807
808 // Get PayPal Product ID.
809 $paypal_product_id = $this->get_paypal_product_id( $wc_product_id, $access_token );
810
811 if ( ! $paypal_product_id ) {
812 return [
813 'result' => 'error',
814 'redirect' => '',
815 'response' => 'PayPal payment failed. Please try again. (Failed to get PayPal product ID)',
816 ];
817 }
818
819 // Get PayPal Plan ID.
820 $paypal_plan_id = $this->get_paypal_plan_id( $wc_product_id, $wc_variation_id, $paypal_product_id, $access_token );
821
822 if ( ! $paypal_plan_id ) {
823 return [
824 'result' => 'error',
825 'redirect' => '',
826 'response' => 'PayPal payment failed. Please try again. (Failed to get PayPal plan ID)',
827 ];
828 }
829
830 // Get return URL.
831 $return_url = $this->get_return_url( $order );
832 $return_url = wp_http_validate_url( $return_url ) ? $return_url : home_url( $return_url );
833
834 // Create Subscription in PayPal.
835 $paypal_subscription_data = [
836 'plan_id' => $paypal_plan_id,
837 'application_context' => [
838 'return_url' => $return_url,
839 'cancel_url' => $order->get_cancel_order_url(),
840 ],
841 ];
842
843 $paypal_subscription = $this->create_paypal_subscription( $paypal_subscription_data, $access_token );
844
845 if ( empty( $paypal_subscription->id ?? null ) ) {
846 return [
847 'result' => 'error',
848 'redirect' => '',
849 'response' => 'PayPal payment failed. Please try again. (Failed to create PayPal subscription)',
850 ];
851 }
852
853 // Save PayPal Subscription ID in order meta.
854 $order->update_meta_data( $this->get_meta_key( 'subscription_id' ), $paypal_subscription->id );
855 $order->save();
856
857 // Get payment link.
858 $paypal_subscription_pay_link = null;
859 foreach ( ( $paypal_subscription->links ?? [] ) as $link_obj ) {
860 if ( 'approve' === $link_obj->rel ) {
861 $paypal_subscription_pay_link = $link_obj->href;
862 break;
863 }
864 }
865
866 if ( empty( $paypal_subscription_pay_link ) ) {
867 return [
868 'result' => 'error',
869 'redirect' => '',
870 'response' => 'PayPal payment failed. Please try again. (Failed to get PayPal subscription approval link)',
871 ];
872 } else {
873 return [
874 'result' => 'success',
875 'redirect' => $paypal_subscription_pay_link,
876 ];
877 }
878 }
879
880 /**
881 * Get PayPal product ID.
882 *
883 * @param int $wc_product_id WooCommerce Product ID.
884 * @param string $access_token PayPal Access Token.
885 */
886 public function get_paypal_product_id( int $wc_product_id, string $access_token ): ?string {
887 $wc_product = wc_get_product( $wc_product_id );
888
889 // Get data from product meta.
890 // TODO: add home_url, wc_product_id etc to avoid duplication in paypal.
891 $paypal_data = get_post_meta( $wc_product_id, $this->get_meta_key( 'product_data' ), true );
892
893 $paypal_product_id = $paypal_data['product_id'] ?? null;
894 $paypal_image_url = $paypal_data['image_url'] ?? null;
895
896 // If PayPal product ID is not available in meta, get or create a new PayPal product.
897 if ( ! $paypal_product_id ) {
898 $paypal_product = $this->get_or_create_paypal_product( $wc_product, $access_token );
899
900 if ( $paypal_product ) {
901 $paypal_product_id = $paypal_product->id;
902
903 // Save PayPal product ID in WooCommerce product meta.
904 $data = [
905 'product_id' => $paypal_product->id,
906 'image_url' => $paypal_product->image_url ?? '',
907 'home_url' => $product_data->home_url ?? '',
908 ];
909 update_post_meta( $wc_product_id, $this->get_meta_key( 'product_data' ), $data );
910 }
911 }
912
913 // TODO: add logic to update image url if changed.
914 // $current_image_url = $this->truncate_string( wp_get_attachment_url( $wc_product->get_image_id() ), 2000 );
915 // if ( $paypal_product_id && $paypal_image_url !== $current_image_url ) {}
916
917 // Return PayPal product ID or null if not found.
918 return $paypal_product_id;
919 }
920
921 /**
922 * Get PayPal plan ID.
923 *
924 * @param int $wc_product_id WooCommerce Product ID.
925 * @param int $wc_variation_id WooCommerce Variation ID.
926 * @param string $paypal_product_id PayPal Product ID.
927 * @param string $access_token PayPal Access Token.
928 */
929 public function get_paypal_plan_id( int $wc_product_id, int $wc_variation_id, string $paypal_product_id, string $access_token ): ?string {
930 $wc_product = wc_get_product( $wc_product_id );
931 if ( 0 !== $wc_variation_id ) {
932 $wc_product = wc_get_product( $wc_variation_id );
933 }
934
935 // Generate fingerprint of current critical billing fields (price, currency, interval, trial, signup fee, cycles).
936 $fingerprint = $this->generate_plan_fingerprint( $wc_product );
937
938 // Load stored plans array from product meta.
939 $stored_plans = get_post_meta( $wc_product_id, $this->get_meta_key( 'plans' ), true );
940 if ( ! is_array( $stored_plans ) ) {
941 $stored_plans = [];
942 }
943
944 // Return the existing plan whose fingerprint matches the current product configuration.
945 foreach ( $stored_plans as $plan_entry ) {
946 if ( isset( $plan_entry['fingerprint'] ) && $plan_entry['fingerprint'] === $fingerprint ) {
947 return $plan_entry['plan_id'];
948 }
949 }
950
951 // No matching plan found — create a new one for the current configuration.
952 $plan_data = $this->generate_plan_data( $wc_product, $paypal_product_id );
953 $paypal_plan = $this->create_paypal_plan( $plan_data, $access_token );
954
955 if ( $paypal_plan ) {
956 $stored_plans[] = [
957 'plan_id' => $paypal_plan->id,
958 'fingerprint' => $fingerprint,
959 ];
960 update_post_meta( $wc_product_id, $this->get_meta_key( 'plans' ), $stored_plans );
961 return $paypal_plan->id;
962 }
963
964 return null;
965 }
966
967 /**
968 * Get or create PayPal product.
969 *
970 * @param WC_Product $wc_product WooCommerce Product.
971 * @param string $access_token PayPal Access Token.
972 */
973 public function get_or_create_paypal_product( WC_Product $wc_product, string $access_token ) {
974 // Prepare product data.
975 $product_data = [
976 'name' => $this->truncate_string( $wc_product->get_name(), 126 ),
977 'description' => $this->truncate_string( $wc_product->get_short_description(), 256 ),
978 'type' => $wc_product->get_virtual() ? 'DIGITAL' : 'PHYSICAL',
979 'image_url' => $wc_product->get_image_id() ? $this->truncate_string( wp_get_attachment_url( $wc_product->get_image_id() ), 2000 ) : '',
980 'home_url' => $this->truncate_string( get_permalink( $wc_product->get_id() ), 2000 ),
981 ];
982
983 // TODO: implement logic to find existing PayPal product.
984 // $paypal_product = $this->find_paypal_product( $product_data, $access_token );
985
986 // If not found, create a new PayPal product.
987 $paypal_product = $this->create_paypal_product( $product_data, $access_token );
988
989 // Return PayPal product or null.
990 return $paypal_product;
991 }
992
993 /**
994 * Handle transaction event from PayPal.
995 *
996 * @param array $webhook_data Webhook data from PayPal.
997 * @param WC_Order|null $order Order object, or null if not yet resolved by transaction ID.
998 * @param string|null $transaction_id Transaction ID from webhook data.
999 * @param string|null $subscription_id PayPal subscription ID from webhook data.
1000 * @param int|null $wpsubs_id WP subscription post ID resolved from mapping table.
1001 */
1002 public function handle_transaction_event( array $webhook_data, ?WC_Order $order, ?string $transaction_id, ?string $subscription_id, ?int $wpsubs_id = null ) {
1003 // Get event type.
1004 $event = $webhook_data['event_type'] ?? 'N/A';
1005
1006 switch ( $event ) {
1007 case 'PAYMENT.SALE.COMPLETED':
1008 // If order was found by transaction_id and is already completed, this is a duplicate delivery.
1009 if ( $order && $order->has_status( 'completed' ) ) {
1010 $log_message = sprintf(
1011 // translators: %s: transaction id.
1012 __( 'Transaction webhook [PAYMENT.SALE.COMPLETED] already processed for transaction #%s. Skipping.', 'subscription' ),
1013 $transaction_id
1014 );
1015 subscrpt_write_log( $log_message );
1016 wp_die( esc_html( $log_message ), '200 Success', array( 'response' => 200 ) );
1017 }
1018
1019 // Resolve order via subscription when not found by transaction_id.
1020 if ( ! $order && $wpsubs_id ) {
1021 $related = Helper::get_related_orders( $wpsubs_id );
1022 $latest_row = ! empty( $related ) ? reset( $related ) : null;
1023 $latest_order = $latest_row ? wc_get_order( (int) $latest_row->order_id ) : null;
1024
1025 if ( $latest_order ) {
1026 $existing_txn = $latest_order->get_transaction_id();
1027
1028 if ( $existing_txn && $existing_txn === $transaction_id ) {
1029 // Same transaction already on the order — duplicate delivery.
1030 $log_message = sprintf(
1031 // translators: %s: transaction id.
1032 __( 'Transaction webhook [PAYMENT.SALE.COMPLETED] already processed for transaction #%s. Skipping.', 'subscription' ),
1033 $transaction_id
1034 );
1035 subscrpt_write_log( $log_message );
1036 wp_die( esc_html( $log_message ), '200 Success', array( 'response' => 200 ) );
1037 } elseif ( $existing_txn && $existing_txn !== $transaction_id ) {
1038 // Order already has a different transaction — this is a renewal payment.
1039 $order = Helper::create_renewal_order( $wpsubs_id );
1040 if ( $order ) {
1041 // translators: %s: transaction id.
1042 $order->add_order_note( sprintf( __( 'Renewal order created by PayPal webhook. Transaction ID: %s', 'subscription' ), $transaction_id ) );
1043 $order->save();
1044 }
1045 } else {
1046 // No transaction ID yet — initial payment arriving before or after thank-you page.
1047 $order = $latest_order;
1048 }
1049 }
1050 }
1051
1052 if ( ! $order ) {
1053 $log_message = sprintf(
1054 // translators: %1$s: event; %2$s: subscription id.
1055 __( 'Transaction webhook received [%1$s]. No order found for subscription #%2$s.', 'subscription' ),
1056 $event,
1057 $subscription_id
1058 );
1059 subscrpt_write_log( $log_message );
1060 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
1061 wp_die( esc_html( $log_message ), '404 not found', array( 'response' => 404 ) );
1062 }
1063
1064 if ( ! $order instanceof \WC_Order ) {
1065 $log_message = sprintf(
1066 // translators: %s: subscription id.
1067 __( 'Transaction webhook received [PAYMENT.SALE.COMPLETED]. Failed to create renewal order for subscription #%s.', 'subscription' ),
1068 $wpsubs_id
1069 );
1070 subscrpt_write_log( $log_message );
1071 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
1072 wp_die( esc_html( $log_message ), '500 Internal Error', array( 'response' => 500 ) );
1073 }
1074
1075 $order->set_transaction_id( $transaction_id );
1076
1077 // If already completed (e.g. thank-you page ran first), just record the transaction ID.
1078 if ( $order->has_status( 'completed' ) ) {
1079 $order->add_order_note( __( 'PayPal transaction ID recorded by webhook.', 'subscription' ) );
1080 $order->save();
1081
1082 // translators: %s: alert name.
1083 $log_message = sprintf( __( 'Transaction webhook received [%s]. Order already completed; transaction ID updated.', 'subscription' ), $event );
1084 subscrpt_write_log( $log_message );
1085 wp_die( esc_html( $log_message ), '200 Success', array( 'response' => 200 ) );
1086 }
1087
1088 if ( $order->update_status( 'completed' ) ) {
1089 $order->add_order_note( __( 'Payment completed by paypal webhook.', 'subscription' ) );
1090 $order->save();
1091
1092 // translators: %s: alert name.
1093 $log_message = sprintf( __( 'Transaction webhook received [%s]. Payment completed.', 'subscription' ), $event );
1094 subscrpt_write_log( $log_message );
1095 wp_die( esc_html( $log_message ), '200 Success', array( 'response' => 200 ) );
1096 } else {
1097 $order->add_order_note( __( 'Failed to complete payment. Requested by paypal webhook.', 'subscription' ) );
1098 $order->save();
1099
1100 // translators: %s: alert name.
1101 $log_message = sprintf( __( 'Transaction webhook received [%s]. Payment completion failed.', 'subscription' ), $event );
1102 subscrpt_write_log( $log_message );
1103 wp_die( esc_html( $log_message ), '506 Internal Error', array( 'response' => 506 ) );
1104 }
1105 break;
1106
1107 case 'PAYMENT.SALE.REFUNDED':
1108 if ( ! $order ) {
1109 $log_message = sprintf(
1110 // translators: %1$s: event; %2$s: subscription id.
1111 __( 'Transaction webhook received [%1$s]. No order found for subscription #%2$s.', 'subscription' ),
1112 $event,
1113 $subscription_id
1114 );
1115 subscrpt_write_log( $log_message );
1116 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
1117 wp_die( esc_html( $log_message ), '404 not found', array( 'response' => 404 ) );
1118 }
1119
1120 $refund_amount = (float) ( $webhook_data['resource']['amount']['total'] ?? 0 );
1121 $order_total = (float) $order->get_total();
1122 $is_full = $refund_amount >= $order_total;
1123
1124 if ( $is_full ) {
1125 $order->update_status( 'refunded' );
1126 }
1127
1128 $order->add_order_note(
1129 $is_full
1130 ? __( 'Full payment refunded by PayPal webhook.', 'subscription' )
1131 : sprintf(
1132 // translators: %s: refunded amount.
1133 __( 'Partial payment refunded by PayPal webhook. Amount: %s', 'subscription' ),
1134 wc_price( $refund_amount, [ 'currency' => $order->get_currency() ] )
1135 )
1136 );
1137 $order->save();
1138
1139 // translators: %s: alert name.
1140 $log_message = sprintf( __( 'Transaction webhook received [%s]. Payment refunded.', 'subscription' ), $event );
1141 subscrpt_write_log( $log_message );
1142 wp_die( esc_html( $log_message ), '200 Success', array( 'response' => 200 ) );
1143 break;
1144
1145 default:
1146 $log_message = sprintf(
1147 // translators: %s: alert name.
1148 __( 'Transaction webhook received [%s]. No actions taken.', 'subscription' ),
1149 $event,
1150 );
1151 subscrpt_write_log( $log_message );
1152 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
1153 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1154 }
1155 }
1156
1157 /**
1158 * Handle subscription event from PayPal.
1159 *
1160 * @param array $webhook_data Webhook data from PayPal.
1161 * @param WC_Order|null $order Order object, or null when resolved via $wpsubs_id.
1162 * @param string|null $transaction_id Transaction ID from webhook data.
1163 * @param string|null $paypal_subscription_id PayPal subscription ID from webhook data.
1164 * @param int|null $wpsubs_id WP subscription post ID resolved from mapping table.
1165 */
1166 public function handle_subscription_event( array $webhook_data, ?WC_Order $order, ?string $transaction_id, ?string $paypal_subscription_id, ?int $wpsubs_id = null ) {
1167 // Get event type.
1168 $event = $webhook_data['event_type'] ?? 'N/A';
1169
1170 if ( ! $wpsubs_id ) {
1171 // Subscription.
1172 $subscription = Helper::get_subscriptions_from_order( $order );
1173
1174 // If no subscription, try to get from order item.
1175 if ( empty( $subscription ) ) {
1176 $log_message = sprintf(
1177 // translators: %s: alert name.
1178 __( 'Subscription webhook received [%s]. Subscription not found. Attempting to get from order item.', 'subscription' ),
1179 $event
1180 );
1181 subscrpt_write_log( $log_message );
1182 subscrpt_write_debug_log( $log_message );
1183
1184 $order_items = $order->get_items();
1185 foreach ( $order_items as $item ) {
1186 $tmp_subs = Helper::get_subscription_from_order_item_id( $item->get_id() );
1187
1188 if ( ! empty( $tmp_subs ) ) {
1189 $subscription = $tmp_subs;
1190
1191 if ( ! empty( $subscription->subscription_id ?? null ) ) {
1192 $log_message = sprintf(
1193 // translators: %s: subscription id.
1194 __( 'Subscription found [ID: %s]. Processing webhook.', 'subscription' ),
1195 $subscription->subscription_id
1196 );
1197 subscrpt_write_log( $log_message );
1198 subscrpt_write_debug_log( $log_message );
1199 }
1200 break;
1201 }
1202 }
1203 }
1204
1205 // If still no subscription, exit.
1206 if ( empty( $subscription ) || empty( $subscription->subscription_id ?? null ) ) {
1207 $log_message = sprintf(
1208 // translators: %s: alert name.
1209 __( 'Subscription webhook received [%s]. Subscription not found. Stopping Process.', 'subscription' ),
1210 $event,
1211 );
1212 subscrpt_write_log( $log_message );
1213 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
1214 wp_die( esc_html( $log_message ), '404 not found', array( 'response' => 404 ) );
1215 }
1216
1217 $subscription_id = $subscription->subscription_id;
1218 } else {
1219 $subscription_id = $wpsubs_id;
1220 }
1221
1222 switch ( $event ) {
1223 case 'BILLING.SUBSCRIPTION.ACTIVATED':
1224 if ( ! in_array( get_post_status( $subscription_id ), [ 'active' ], true ) ) {
1225 Action::status( 'active', $subscription_id );
1226
1227 update_post_meta( $subscription_id, $this->get_meta_key( 'paypal_subs_status' ), 'active' );
1228
1229 $log_message = __( 'Subscription activated by PayPal webhook.', 'subscription' );
1230 subscrpt_write_log( $log_message );
1231 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1232 }
1233
1234 // translators: %s: alert name.
1235 $log_message = sprintf( __( 'Subscription webhook received [%s]. No actions taken.', 'subscription' ), $event );
1236 subscrpt_write_log( $log_message );
1237 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1238 break;
1239
1240 case 'BILLING.SUBSCRIPTION.EXPIRED':
1241 if ( in_array( get_post_status( $subscription_id ), [ 'active', 'pe_cancelled' ], true ) ) {
1242 Action::status( 'expired', $subscription_id );
1243
1244 update_post_meta( $subscription_id, $this->get_meta_key( 'paypal_subs_status' ), 'expired' );
1245
1246 $log_message = __( 'Subscription expired by PayPal webhook.', 'subscription' );
1247 subscrpt_write_log( $log_message );
1248 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1249 }
1250
1251 // translators: %s: alert name.
1252 $log_message = sprintf( __( 'Subscription webhook received [%s]. No actions taken.', 'subscription' ), $event );
1253 subscrpt_write_log( $log_message );
1254 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1255 break;
1256
1257 case 'BILLING.SUBSCRIPTION.CANCELLED':
1258 if ( ! in_array( get_post_status( $subscription_id ), [ 'cancelled', 'expired' ], true ) ) {
1259 Action::status( 'cancelled', $subscription_id );
1260
1261 update_post_meta( $subscription_id, $this->get_meta_key( 'paypal_subs_status' ), 'cancelled' );
1262
1263 $log_message = __( 'Subscription cancelled by PayPal webhook.', 'subscription' );
1264 subscrpt_write_log( $log_message );
1265 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1266 }
1267
1268 // translators: %s: alert name.
1269 $log_message = sprintf( __( 'Subscription webhook received [%s]. No actions taken.', 'subscription' ), $event );
1270 subscrpt_write_log( $log_message );
1271 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1272 break;
1273
1274 default:
1275 $log_message = sprintf(
1276 // translators: %s: alert name.
1277 __( 'Subscription webhook received [%s]. No actions taken.', 'subscription' ),
1278 $event,
1279 );
1280 subscrpt_write_log( $log_message );
1281 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $webhook_data ) );
1282 wp_die( esc_html( $log_message ), '200 success', array( 'response' => 200 ) );
1283 break;
1284 }
1285 }
1286
1287 /**
1288 * Handle subscription cancellation.
1289 *
1290 * @param int $subscription_id Subscription ID.
1291 */
1292 public function handle_subscription_cancellation( int $subscription_id ) {
1293 $order_id = get_post_meta( $subscription_id, '_subscrpt_order_id', true );
1294 $order = wc_get_order( $order_id );
1295
1296 // Get order payment method
1297 $payment_method = $order->get_payment_method();
1298
1299 // Get paypal subscription status from subscription meta.
1300 $paypal_subs_status = get_post_meta( $subscription_id, $this->get_meta_key( 'paypal_subs_status' ), true );
1301
1302 // Only process if the payment method is PayPal and the subscription is not already cancelled.
1303 if ( ( $this->id !== $payment_method ) || ( ! empty( $paypal_subs_status ) && $paypal_subs_status === 'cancelled' ) ) {
1304 return;
1305 }
1306
1307 // Get paypal subscription ID from order meta.
1308 $paypal_subscription_id = $order->get_meta( $this->get_meta_key( 'subscription_id' ) );
1309
1310 if ( empty( $paypal_subscription_id ) ) {
1311 subscrpt_write_log( 'PayPal subscription ID not found in order meta. Attempting to get from order history.' );
1312
1313 global $wpdb;
1314 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
1315 $order_histories = $wpdb->get_results( // phpcs:ignore
1316 $wpdb->prepare(
1317 'SELECT * FROM %i WHERE subscription_id=%d ORDER BY order_id DESC',
1318 [ $table_name, $subscription_id ]
1319 )
1320 );
1321
1322 foreach ( $order_histories as $history ) {
1323 // Get order ID from history.
1324 $order_id = $history->order_id ?? null;
1325 $order = wc_get_order( $order_id );
1326
1327 // Get PayPal subscription ID from order meta.
1328 $tmp_paypal_subs_id = $order->get_meta( $this->get_meta_key( 'subscription_id' ) );
1329
1330 // OLD key migration.
1331 // If no data check if the data exists with the old key. And update if necessary.
1332 // ? Dev note: Remove after JAN 1, 2026.
1333 if ( empty( $tmp_paypal_subs_id ) ) {
1334 $tmp_paypal_subs_id = $order->get_meta( '_wp_subs_paypal_subscription_id', true );
1335
1336 if ( ! empty( $tmp_paypal_subs_id ) ) {
1337 $order->update_meta_data( $this->get_meta_key( 'subscription_id' ), $tmp_paypal_subs_id );
1338 $order->save();
1339 }
1340 }
1341
1342 if ( ! empty( $tmp_paypal_subs_id ) ) {
1343 $paypal_subscription_id = $tmp_paypal_subs_id;
1344 break;
1345 }
1346 }
1347 }
1348
1349 // Get PayPal Access Token.
1350 $access_token = $this->get_paypal_access_token();
1351 if ( ! $access_token ) {
1352 subscrpt_write_log( 'Access token not found. Retrying.' );
1353
1354 $access_token = $this->get_paypal_access_token();
1355
1356 if ( ! $access_token ) {
1357 subscrpt_write_log( 'Access token not found.' );
1358 subscrpt_write_log( "Failed to cancel subscription #{$subscription_id} in PayPal." );
1359 return;
1360 }
1361 }
1362
1363 // Cancel subscription in PayPal.
1364 $result = $this->cancel_paypal_subscription( $paypal_subscription_id, $access_token, 'Customer requested cancellation.' );
1365 if ( $result ) {
1366 update_post_meta( $subscription_id, $this->get_meta_key( 'paypal_subs_status' ), 'cancelled' );
1367
1368 subscrpt_write_log( "Subscription #{$subscription_id} cancelled successfully in PayPal." );
1369 } else {
1370 subscrpt_write_log( "Failed to cancel subscription #{$subscription_id} in PayPal." );
1371 }
1372 }
1373
1374 // * ------------------------------------------------------------------------ * //
1375 // * -------------------- Utility Methods [start] --------------------------- * //
1376
1377 /**
1378 * Truncate long string.
1379 *
1380 * @param string $long_string The long string to truncate.
1381 * @param int $max_length The maximum length of the string.
1382 * @return string The truncated string if it exceeds the maximum length, otherwise the original string
1383 */
1384 public function truncate_string( string $long_string, int $max_length = 48 ): string {
1385 return strlen( $long_string ) <= $max_length ? $long_string : substr( $long_string, 0, $max_length );
1386 }
1387
1388 /**
1389 * Get Prefixed Meta Key.
1390 * Prefix the key with '_wp_subs_' to avoid possible conflicts with other plugins.
1391 *
1392 * @param string $key The key to prefix.
1393 * @param string|null $mode_override Optional mode override (sandbox/live).
1394 */
1395 public function get_meta_key( string $key, ?string $mode_override = null ): string {
1396 $keys = [
1397 'product_data' => 'product_data',
1398 'plan_id' => 'plan_id',
1399 'plan_desc' => 'plan_description',
1400 'plans' => 'plans',
1401 'subscription_id' => 'subscription_id',
1402 'paypal_subs_status' => 'paypal_subs_status',
1403 ];
1404 $selected_key = $keys[ $key ] ?? $key;
1405
1406 $mode_string = $this->sandbox_mode ? 'sandbox_' : 'live_';
1407 if ( ! empty( $mode_override ) ) {
1408 $mode_string = 'sandbox' === $mode_override ? 'sandbox_' : 'live_';
1409 }
1410
1411 return '_wp_subs_paypal_' . $mode_string . $selected_key;
1412 }
1413
1414 /**
1415 * Convert a billing interval string to PayPal's uppercase singular format.
1416 * subscrpt_get_typos function of the plugin have translator on the intervals. PayPal will only accept english.
1417 *
1418 * @param string $interval Raw interval string (e.g. 'month', 'months', 'WEEK').
1419 * @return string PayPal interval constant: DAY, WEEK, MONTH, or YEAR.
1420 */
1421 private function convert_paypal_interval( string $interval ): string {
1422 switch ( strtolower( $interval ) ) {
1423 case 'day':
1424 case 'days':
1425 return 'DAY';
1426 case 'week':
1427 case 'weeks':
1428 return 'WEEK';
1429 case 'month':
1430 case 'months':
1431 return 'MONTH';
1432 case 'year':
1433 case 'years':
1434 return 'YEAR';
1435 default:
1436 return 'MONTH';
1437 }
1438 }
1439
1440 /**
1441 * Generate a fingerprint hash of all critical billing fields for a product.
1442 *
1443 * The fingerprint encodes every field that determines a distinct PayPal billing
1444 * plan (price, currency, interval, trial, signup fee, cycle count). Two products
1445 * with identical critical fields produce the same fingerprint and can share a plan.
1446 *
1447 * @param WC_Product $wc_product WooCommerce product (simple or variation).
1448 * @return string MD5 hash of the critical fields.
1449 */
1450 private function generate_plan_fingerprint( WC_Product $wc_product ): string {
1451 $wpsubs_product = Subscription::get_subs_product( $wc_product );
1452 $meta_cycles = $wc_product->get_meta( '_subscrpt_max_no_payment' );
1453 $total_cycles = $meta_cycles ? $meta_cycles : 0;
1454
1455 $data = [
1456 'price' => number_format( (float) wc_get_price_including_tax( $wc_product ), 2, '.', '' ),
1457 'currency' => get_woocommerce_currency(),
1458 'interval' => $this->convert_paypal_interval( $wpsubs_product->get_timing_option() ),
1459 'interval_count' => (int) $wpsubs_product->get_timing_per(),
1460 'trial_interval' => $this->convert_paypal_interval( $wpsubs_product->get_trial_timing_option() ),
1461 'trial_count' => (int) $wpsubs_product->get_trial_timing_per(),
1462 'signup_fee' => number_format( (float) $wpsubs_product->get_signup_fee(), 2, '.', '' ),
1463 'total_cycles' => (int) $total_cycles,
1464 ];
1465
1466 return md5( wp_json_encode( $data ) );
1467 }
1468
1469 /**
1470 * Generate PayPal Plan Data.
1471 *
1472 * @param WC_Product $wc_product WooCommerce Product.
1473 * @param string $paypal_product_id PayPal Product ID.
1474 */
1475 public function generate_plan_data( WC_Product $wc_product, string $paypal_product_id ): array {
1476 // Get WPSubscription wrapped product.
1477 // $wpsubs_product type WC_Product
1478 $wpsubs_product = Subscription::get_subs_product( $wc_product );
1479
1480 // Name.
1481 $name = $this->truncate_string( $wc_product->get_name(), 126 );
1482
1483 // Description.
1484 $description = $this->truncate_string( $wc_product->get_short_description(), 126 );
1485
1486 // Price.
1487 $price = wc_get_price_including_tax( $wc_product );
1488
1489 // Recurring Details.
1490 $plan_length = $wpsubs_product->get_timing_per();
1491 $plan_interval = $this->convert_paypal_interval( $wpsubs_product->get_timing_option() );
1492 $trial_length = $wpsubs_product->get_trial_timing_per();
1493 $trial_interval = $this->convert_paypal_interval( $wpsubs_product->get_trial_timing_option() );
1494 $signup_fee = $wpsubs_product->get_signup_fee();
1495
1496 // get value for total_cycles from _subscrpt_max_no_payment
1497 $meta_cycles = $wc_product->get_meta( '_subscrpt_max_no_payment' );
1498 $total_cycles = $meta_cycles ? $meta_cycles : 0;
1499
1500 // Billing Cycles.
1501 $billing_cycles = [];
1502
1503 // Add trial cycle in billing cycles if available.
1504 if ( (int) $trial_length > 0 ) {
1505 $billing_cycles[] = [
1506 'tenure_type' => 'TRIAL',
1507 'sequence' => 1,
1508 'total_cycles' => $total_cycles,
1509 'frequency' => [
1510 'interval_unit' => $trial_interval,
1511 'interval_count' => (int) $trial_length,
1512 ],
1513 ];
1514 }
1515
1516 // Add regular cycle in billing cycles.
1517 $billing_cycles[] = [
1518 'tenure_type' => 'REGULAR',
1519 'sequence' => count( $billing_cycles ) + 1,
1520 'total_cycles' => 0,
1521 'pricing_scheme' => [
1522 'fixed_price' => [
1523 'value' => number_format( (float) $price, 2, '.', '' ),
1524 'currency_code' => get_woocommerce_currency(),
1525 ],
1526 ],
1527 'frequency' => [
1528 'interval_unit' => $plan_interval,
1529 'interval_count' => (int) $plan_length,
1530 ],
1531 ];
1532
1533 // Payment Preferences.
1534 $payment_preferences = [
1535 'auto_bill_outstanding' => true,
1536 'setup_fee_failure_action' => 'CANCEL',
1537 'payment_failure_threshold' => 3,
1538 'setup_fee' => [
1539 'value' => number_format( (float) $signup_fee, 2, '.', '' ),
1540 'currency_code' => get_woocommerce_currency(),
1541 ],
1542 ];
1543
1544 // Final Data.
1545 $plan_data = [
1546 'product_id' => $paypal_product_id,
1547 'name' => $name,
1548 'description' => $description,
1549 'billing_cycles' => $billing_cycles,
1550 'quantity_supported' => false,
1551 'payment_preferences' => $payment_preferences,
1552 ];
1553 return $plan_data;
1554 }
1555
1556 // * -------------------- Utility Methods [end] --------------------------- * //
1557 // * ---------------------------------------------------------------------- * //
1558
1559
1560 // * ---------------------------------------------------------------- * //
1561 // * -------------------- API Operations [start] -------------------- * //
1562 // ? Keep this section strictly for API operations. No other logic like data extraction should be added here.
1563
1564 /**
1565 * Get PayPal Access Token.
1566 */
1567 private function get_paypal_access_token(): ?string {
1568 try {
1569 $url = $this->api_endpoint . '/v1/oauth2/token';
1570 $args = [
1571 'method' => 'POST',
1572 'headers' => [
1573 'Accept' => 'application/json',
1574 'Accept-Language' => 'en_US',
1575 'Authorization' => 'Basic ' . base64_encode( $this->client_id . ':' . $this->client_secret ), // phpcs:ignore
1576 ],
1577 'body' => [
1578 'grant_type' => 'client_credentials',
1579 ],
1580 ];
1581
1582 $response = wp_remote_post( $url, $args );
1583 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1584
1585 if ( isset( $response_data->error ) || ! isset( $response_data->access_token ) ) {
1586 $error_description = ! empty( $response_data ) ? $response_data->error_description ?? 'Unknown error' : 'Unknown error';
1587 $log_message = 'Gateway Error : PayPal access token - ' . $error_description;
1588 subscrpt_write_log( $log_message );
1589 subscrpt_write_debug_log( $log_message );
1590
1591 return null;
1592 }
1593
1594 return $response_data->access_token;
1595 } catch ( Exception $e ) {
1596 $log_message = $e->getMessage();
1597 subscrpt_write_log( $log_message );
1598 subscrpt_write_debug_log( $log_message );
1599
1600 return null;
1601 }
1602 }
1603
1604 /**
1605 * Create PayPal product.
1606 *
1607 * @param array $product_data Product data to create.
1608 * @param string $access_token PayPal Access Token.
1609 */
1610 private function create_paypal_product( array $product_data, string $access_token ): ?object {
1611 if ( empty( $product_data['name'] ?? null ) || empty( $product_data['type'] ?? null ) ) {
1612 $log_message = __( 'PayPal Product Creation Error: Product data is incomplete. Name and type are required.', 'subscription' );
1613 subscrpt_write_log( $log_message );
1614 subscrpt_write_debug_log( $log_message );
1615 return null;
1616 }
1617
1618 // Prepare the body for the API request.
1619 $body = [
1620 'name' => $product_data['name'],
1621 'type' => $product_data['type'],
1622 ];
1623 if ( ! empty( $product_data['description'] ?? null ) ) {
1624 $body['description'] = $product_data['description'];
1625 }
1626 if ( ! empty( $product_data['category'] ?? null ) ) {
1627 $body['category'] = $product_data['category'];
1628 }
1629 if ( ! empty( $product_data['image_url'] ?? null ) && ! strpos( $product_data['image_url'], '.test' ) ) {
1630 $body['image_url'] = $product_data['image_url'];
1631 }
1632 if ( ! empty( $product_data['home_url'] ?? null ) && ! strpos( $product_data['home_url'], '.test' ) ) {
1633 $body['home_url'] = $product_data['home_url'];
1634 }
1635
1636 try {
1637 $url = $this->api_endpoint . '/v1/catalogs/products';
1638 $args = [
1639 'method' => 'POST',
1640 'headers' => [
1641 'Authorization' => 'Bearer ' . $access_token,
1642 'Content-Type' => 'application/json',
1643 'Prefer' => 'return=representation',
1644 'PayPal-Request-Id' => uniqid( 'wp-subs-paypal-', true ),
1645 ],
1646 'body' => wp_json_encode( $body ),
1647 ];
1648
1649 $response = wp_remote_post( $url, $args );
1650 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1651
1652 if ( empty( $response_data->id ?? null ) ) {
1653 $log_message = 'Error creating PayPal product: ' . ( $response_data->error_description ?? 'Unknown error' );
1654 subscrpt_write_log( $log_message );
1655 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $response_data ) );
1656 return null;
1657 }
1658
1659 return $response_data;
1660 } catch ( Exception $e ) {
1661 $log_message = 'Error creating PayPal product: ' . $e->getMessage();
1662 subscrpt_write_log( $log_message );
1663 subscrpt_write_debug_log( $log_message );
1664 return null;
1665 }
1666 }
1667
1668 /**
1669 * Create PayPal plan.
1670 *
1671 * @param array $plan_data Plan data to create.
1672 * @param string $access_token PayPal Access Token.
1673 */
1674 private function create_paypal_plan( array $plan_data, string $access_token ): ?object {
1675 // Prepare the body for the API request.
1676 $body = [
1677 'product_id' => $plan_data['product_id'],
1678 'name' => $plan_data['name'],
1679 'billing_cycles' => $plan_data['billing_cycles'],
1680 'payment_preferences' => $plan_data['payment_preferences'],
1681 ];
1682 if ( ! empty( $plan_data['description'] ?? null ) ) {
1683 $body['description'] = $plan_data['description'];
1684 }
1685 if ( ! empty( $plan_data['quantity_supported'] ?? null ) ) {
1686 $body['quantity_supported'] = $plan_data['quantity_supported'];
1687 }
1688
1689 try {
1690 $url = $this->api_endpoint . '/v1/billing/plans';
1691 $args = [
1692 'method' => 'POST',
1693 'headers' => [
1694 'Authorization' => 'Bearer ' . $access_token,
1695 'Content-Type' => 'application/json',
1696 'Prefer' => 'return=representation',
1697 'PayPal-Request-Id' => uniqid( 'wp-subs-paypal-', true ),
1698 ],
1699 'body' => wp_json_encode( $body ),
1700 ];
1701
1702 $response = wp_remote_post( $url, $args );
1703 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1704
1705 if ( empty( $response_data->id ?? null ) ) {
1706 $log_message = 'Error creating PayPal plan: ' . ( $response_data->error_description ?? $response_data->message ?? 'Unknown error' );
1707 subscrpt_write_log( $log_message );
1708 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $response_data ) );
1709 return null;
1710 }
1711
1712 return $response_data;
1713 } catch ( Exception $e ) {
1714 $log_message = 'Error creating PayPal plan: ' . $e->getMessage();
1715 subscrpt_write_log( $log_message );
1716 subscrpt_write_debug_log( $log_message );
1717 return null;
1718 }
1719 }
1720
1721 /**
1722 * Create PayPal subscription.
1723 *
1724 * @param array $paypal_subscription_data PayPal subscription data.
1725 * @param string $access_token PayPal Access Token.
1726 */
1727 private function create_paypal_subscription( array $paypal_subscription_data, string $access_token ): ?object {
1728 // Prepare the body for the API request.
1729 $body = [
1730 'plan_id' => $paypal_subscription_data['plan_id'],
1731 'application_context' => $paypal_subscription_data['application_context'],
1732 ];
1733
1734 try {
1735 $url = $this->api_endpoint . '/v1/billing/subscriptions';
1736 $args = [
1737 'method' => 'POST',
1738 'headers' => [
1739 'Authorization' => 'Bearer ' . $access_token,
1740 'Content-Type' => 'application/json',
1741 'Prefer' => 'return=representation',
1742 'PayPal-Request-Id' => uniqid( 'wp-subs-paypal-', true ),
1743 ],
1744 'body' => wp_json_encode( $body ),
1745 ];
1746
1747 $response = wp_remote_post( $url, $args );
1748 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1749
1750 if ( empty( $response_data->id ?? null ) ) {
1751 $log_message = 'Error creating PayPal subscription: ' . ( $response_data->error_description ?? $response_data->message ?? 'Unknown error' );
1752 subscrpt_write_log( $log_message );
1753 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $response_data ) );
1754 return null;
1755 }
1756
1757 return $response_data;
1758 } catch ( Exception $e ) {
1759 $log_message = 'Error creating PayPal subscription: ' . $e->getMessage();
1760 subscrpt_write_log( $log_message );
1761 subscrpt_write_debug_log( $log_message );
1762 return null;
1763 }
1764 }
1765
1766 /**
1767 * Process a refund via PayPal Captures API.
1768 *
1769 * @param int $order_id WooCommerce order ID.
1770 * @param float $amount Amount to refund, or null for full refund.
1771 * @param string $reason Reason for refund.
1772 * @return bool|\WP_Error True on success, WP_Error on failure.
1773 */
1774 public function process_refund( $order_id, $amount = null, $reason = '' ) {
1775 $order = wc_get_order( $order_id );
1776 if ( ! $order ) {
1777 return new \WP_Error( 'invalid_order', __( 'Order not found.', 'subscription' ) );
1778 }
1779
1780 $capture_id = $order->get_transaction_id();
1781 if ( ! $capture_id ) {
1782 return new \WP_Error( 'no_capture_id', __( 'PayPal capture ID not found on this order.', 'subscription' ) );
1783 }
1784
1785 $access_token = $this->get_paypal_access_token();
1786 if ( ! $access_token ) {
1787 return new \WP_Error( 'no_access_token', __( 'Failed to get PayPal access token.', 'subscription' ) );
1788 }
1789
1790 try {
1791 $url = $this->api_endpoint . "/v1/payments/sale/{$capture_id}/refund";
1792 $body = [];
1793
1794 if ( null !== $amount ) {
1795 $body['amount'] = [
1796 'total' => number_format( (float) $amount, 2, '.', '' ),
1797 'currency' => $order->get_currency(),
1798 ];
1799 }
1800
1801 if ( ! empty( $reason ) ) {
1802 $body['description'] = substr( $reason, 0, 255 );
1803 }
1804
1805 $args = [
1806 'method' => 'POST',
1807 'headers' => [
1808 'Authorization' => 'Bearer ' . $access_token,
1809 'Content-Type' => 'application/json',
1810 'PayPal-Request-Id' => uniqid( 'wp-subs-refund-', true ),
1811 ],
1812 'body' => wp_json_encode( $body ),
1813 ];
1814
1815 $response = wp_remote_post( $url, $args );
1816 $response_code = (int) wp_remote_retrieve_response_code( $response );
1817 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1818
1819 if ( 201 === $response_code ) {
1820 $refund_id = $response_data->id ?? '';
1821 $order->add_order_note(
1822 sprintf(
1823 // translators: %s: PayPal refund ID.
1824 __( 'PayPal refund initiated. Refund ID: %s', 'subscription' ),
1825 $refund_id
1826 )
1827 );
1828 return true;
1829 }
1830
1831 $error_message = $response_data->message ?? $response_data->error_description ?? 'Unknown error';
1832 $log_message = 'PayPal refund failed: ' . $error_message;
1833 subscrpt_write_log( $log_message );
1834 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $response_data ) );
1835
1836 return new \WP_Error( 'paypal_refund_failed', $error_message );
1837
1838 } catch ( Exception $e ) {
1839 $log_message = 'PayPal refund exception: ' . $e->getMessage();
1840 subscrpt_write_log( $log_message );
1841 subscrpt_write_debug_log( $log_message );
1842 return new \WP_Error( 'paypal_refund_exception', $e->getMessage() );
1843 }
1844 }
1845
1846 /**
1847 * Cancel PayPal subscription.
1848 *
1849 * @param string $subscription_id PayPal Subscription ID.
1850 * @param string $access_token PayPal Access Token.
1851 * @param string $reason Reason for cancellation.
1852 */
1853 private function cancel_paypal_subscription( string $subscription_id, string $access_token, string $reason = 'admin cancel' ): bool {
1854 // Prepare the body for the API request.
1855 $body = [
1856 'reason' => $reason,
1857 ];
1858
1859 try {
1860 $url = $this->api_endpoint . "/v1/billing/subscriptions/$subscription_id/cancel";
1861
1862 $args = [
1863 'method' => 'POST',
1864 'headers' => [
1865 'Authorization' => 'Bearer ' . $access_token,
1866 'Content-Type' => 'application/json',
1867 ],
1868 'body' => wp_json_encode( $body ),
1869 ];
1870
1871 $response = wp_remote_post( $url, $args );
1872 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1873
1874 if ( ! empty( $response_data->message ?? null ) ) {
1875 $log_message = 'Error cancelling PayPal subscription: ' . ( $response_data->message ?? 'Unknown error' );
1876 subscrpt_write_log( $log_message );
1877 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $response_data ) );
1878 return false;
1879 }
1880
1881 return true;
1882 } catch ( Exception $e ) {
1883 $log_message = 'Error cancelling PayPal subscription: ' . $e->getMessage();
1884 subscrpt_write_log( $log_message );
1885 subscrpt_write_debug_log( $log_message );
1886 return false;
1887 }
1888 }
1889
1890 /**
1891 * Get PayPal order details.
1892 *
1893 * @param string $order_id PayPal Order ID.
1894 */
1895 public function get_paypal_order( string $order_id ) {
1896 // Get PayPal Access Token.
1897 $access_token = $this->get_paypal_access_token();
1898 if ( ! $access_token ) {
1899 subscrpt_write_log( 'Failed to get PayPal order; Access Token unavailable.' );
1900 return false;
1901 }
1902
1903 try {
1904 $url = $this->api_endpoint . "/v2/checkout/orders/$order_id";
1905 $args = [
1906 'method' => 'GET',
1907 'headers' => [
1908 'Authorization' => 'Bearer ' . $access_token,
1909 'Content-Type' => 'application/json',
1910 ],
1911 ];
1912
1913 $response = wp_remote_get( $url, $args );
1914 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1915
1916 if ( empty( $response_data->id ?? null ) ) {
1917 $log_message = 'Error getting PayPal order: ' . ( $response_data->error_description ?? $response_data->message ?? 'Unknown error' );
1918 subscrpt_write_log( $log_message );
1919 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $response_data ) );
1920 return null;
1921 }
1922
1923 return $response_data;
1924 } catch ( Exception $e ) {
1925 $log_message = 'Failed to get PayPal order; ' . $e->getMessage();
1926 subscrpt_write_log( $log_message );
1927 subscrpt_write_debug_log( $log_message );
1928 return false;
1929 }
1930 }
1931
1932 /**
1933 * Get PayPal subscription details.
1934 *
1935 * @param string $subscription_id PayPal Subscription ID.
1936 */
1937 public function get_paypal_subscription( string $subscription_id ): ?object {
1938 // Get PayPal Access Token.
1939 $access_token = $this->get_paypal_access_token();
1940 if ( ! $access_token ) {
1941 subscrpt_write_log( 'Failed to get PayPal Subscription; Access Token unavailable.' );
1942 return null;
1943 }
1944
1945 try {
1946 $url = $this->api_endpoint . "/v1/billing/subscriptions/$subscription_id";
1947 $args = [
1948 'method' => 'GET',
1949 'headers' => [
1950 'Authorization' => 'Bearer ' . $access_token,
1951 'Content-Type' => 'application/json',
1952 ],
1953 ];
1954
1955 $response = wp_remote_get( $url, $args );
1956 $response_data = json_decode( wp_remote_retrieve_body( $response ) );
1957
1958 if ( empty( $response_data->id ?? null ) ) {
1959 $log_message = 'Error getting PayPal subscription: ' . ( $response_data->error_description ?? $response_data->message ?? 'Unknown error' );
1960 subscrpt_write_log( $log_message );
1961 subscrpt_write_debug_log( $log_message . ' ' . wp_json_encode( $response_data ) );
1962 return null;
1963 }
1964
1965 return $response_data;
1966 } catch ( Exception $e ) {
1967 $log_message = 'Failed to get PayPal subscription; ' . $e->getMessage();
1968 subscrpt_write_log( $log_message );
1969 subscrpt_write_debug_log( $log_message );
1970 return null;
1971 }
1972 }
1973
1974 // * -------------------- API Operations [end] -------------------- * //
1975 // * -------------------------------------------------------------- * //
1976 }
1977