PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.4.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.4.0
2.4.0 2.3.0 2.2.0 2.1.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 All 65 releases
subscription / includes / Illuminate / Gateways / Paypal / Paypal.php

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

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