PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.3.7
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.3.7
4.4.9.1 4.4.9 4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 All 141 releases
learnpress / inc / gateways / paypal / class-lp-gateway-paypal.php

class-lp-gateway-paypal.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.3.7, at inc/gateways/paypal/class-lp-gateway-paypal.php

1,951 lines 63.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class Paypal Payment gateway.
4 *
5 * @author ThimPress
6 * @package LearnPress/Classes
7 * @since 3.0.0
8 * @version 3.0.3
9 */
10
11 use LearnPress\Helpers\Config;
12 use LearnPress\Helpers\Singleton;
13
14 /**
15 * Prevent loading this file directly
16 */
17 defined( 'ABSPATH' ) || exit();
18
19 if ( ! class_exists( 'LP_Gateway_Paypal' ) ) {
20 /**
21 * Class LP_Gateway_Paypal.
22 */
23 class LP_Gateway_Paypal extends LP_Gateway_Abstract {
24 use Singleton;
25
26 /**
27 * @var string
28 */
29 public $id = 'paypal';
30 /**
31 * @var null|string
32 */
33 protected $paypal_live_url = 'https://www.paypal.com/';
34 /**
35 * @var null|string
36 */
37 protected $paypal_payment_live_url = 'https://www.paypal.com/cgi-bin/webscr';
38 /**
39 * @var null|string
40 */
41 protected $paypal_sandbox_url = 'https://www.sandbox.paypal.com/';
42 /**
43 * @var null|string
44 */
45 protected $paypal_payment_sandbox_url = 'https://www.sandbox.paypal.com/cgi-bin/webscr';
46 /**
47 * @var string
48 */
49 protected $api_sandbox_url = 'https://api-m.sandbox.paypal.com/';
50 /**
51 * @var string
52 */
53 protected $api_live_url = 'https://api-m.paypal.com/';
54 /**
55 * @var string|null
56 */
57 protected $api_url = null;
58 /**
59 * @var null
60 */
61 protected $paypal_url = null;
62 /**
63 * @var null
64 */
65 protected $paypal_payment_url = null;
66 /**
67 * @var null
68 */
69 protected $paypal_email = '';
70 /**
71 * @var null
72 */
73 protected $settings = null;
74 /**
75 * @var null
76 */
77 protected $client_id = null;
78 /**
79 * @var null
80 */
81 protected $client_secret = null;
82 /**
83 * @var string
84 */
85 protected $subscription_webhook_id = '';
86
87 const PAYPAL_TOKEN = 'paypal_token';
88
89 /**
90 * LP_Gateway_Paypal constructor.
91 */
92 public function __construct() {
93 $this->method_title = esc_html__( 'PayPal', 'learnpress' );
94 $this->method_description = esc_html__( 'Make a payment via Paypal.', 'learnpress' );
95 $this->icon = LP_PLUGIN_URL . 'assets/images/paypal-logo-preview.png';
96
97 $this->title = esc_html__( 'PayPal', 'learnpress' );
98 $this->description = esc_html__( 'Pay with PayPal', 'learnpress' );
99
100 parent::__construct();
101
102 $this->init();
103 }
104
105 /**
106 * Init.
107 */
108 public function init() {
109 if ( $this->is_enabled() ) {
110 if ( $this->settings->get( 'paypal_sandbox', 'no' ) === 'no' ) {
111 $this->paypal_url = $this->paypal_live_url;
112 $this->paypal_payment_url = $this->paypal_payment_live_url;
113 $this->paypal_email = $this->settings->get( 'paypal_email' );
114 $this->api_url = $this->api_live_url; // use for PayPal rest api
115 } else {
116 $this->paypal_url = $this->paypal_sandbox_url;
117 $this->paypal_payment_url = $this->paypal_payment_sandbox_url;
118 $this->paypal_email = $this->settings->get( 'paypal_sandbox_email' );
119 $this->api_url = $this->api_sandbox_url; // use for PayPal rest api
120 }
121
122 // Use PayPal rest api
123 $this->client_id = $this->settings->get( 'app_client_id' );
124 $this->client_secret = $this->settings->get( 'app_client_secret' );
125 $this->subscription_webhook_id = (string) $this->settings->get( 'subscription_webhook_id', '' );
126 } else {
127 return;
128 }
129
130 $this->check_webhook_callback();
131 }
132
133 /**
134 * Check whether PayPal subscription mode is enabled in gateway settings.
135 *
136 * @return bool
137 */
138 public function is_subscription_enabled(): bool {
139
140 return $this->settings->get( 'enable_subscriptions', 'no' ) === 'yes';
141 }
142 /**
143 * Listen callback, webhook form PayPal.
144 */
145 public function check_webhook_callback() {
146 if ( ! isset( $_GET['paypay_express_checkout'] ) ) {
147 return;
148 }
149
150 $paypal_order_id = LP_Request::get_param( 'token' );
151 if ( empty( $paypal_order_id ) ) {
152 return;
153 }
154
155 $this->capture_payment_for_order( $paypal_order_id );
156 }
157
158 /**
159 * https://developer.paypal.com/api/nvp-soap/ipn/IPNImplementation/#link-ipnlistenerrequestresponseflow
160 * Check validate IPN.
161 *
162 * @return bool
163 */
164 public function validate_ipn(): bool {
165 $validate_ipn = array( 'cmd' => '_notify-validate' );
166 $validate_ipn += wp_unslash( $_POST );
167
168 $params = array(
169 'body' => $validate_ipn,
170 'timeout' => 60,
171 );
172
173 // Post back to get a response
174 $response = wp_remote_post( $this->paypal_payment_url, $params );
175
176 if ( ! is_wp_error( $response ) && $response['response']['code'] >= 200 && $response['response']['code'] < 300 ) {
177 $body = wp_remote_retrieve_body( $response );
178 if ( 'VERIFIED' === $body ) {
179 return true;
180 }
181 } else {
182 error_log( 'Error code paypal validate ipn: ' . $response['response']['code'] );
183 error_log( 'Error code paypal validate ipn: ' . $response->get_error_message() );
184 }
185
186 return false;
187 }
188
189 /**
190 * Handle payment.
191 *
192 * @param int $order_id
193 *
194 * @return array
195 * @throws Exception
196 */
197 public function process_payment( $order_id = 0 ): array {
198 $order = new LP_Order( $order_id );
199
200 $subscription_data = $this->resolve_subscription_payment_data( $order );
201
202 if ( ! empty( $subscription_data ) ) {
203 $subscription_res = $this->pay_subscription( $subscription_data );
204
205 update_post_meta( $order_id, self::META_SUBSCRIPTION_STATUS, 'pending' );
206 if ( ! empty( $subscription_res['subscription_id'] ) ) {
207 update_post_meta( $order_id, self::META_SUBSCRIPTION_ID, sanitize_text_field( (string) $subscription_res['subscription_id'] ) );
208 }
209
210 return array(
211 'result' => 'success',
212 'redirect' => esc_url_raw( (string) ( $subscription_res['redirect_url'] ?? '' ) ),
213 );
214 }
215
216 $data_token = $this->get_access_token();
217 $paypal_payment_url = $this->create_payment_url( $order, $data_token );
218
219 $result['result'] = 'success';
220 $result['redirect'] = $paypal_payment_url;
221
222 return $result;
223 }
224
225 /**
226 * Prepare args to send to PayPal
227 *
228 * @param LP_Order $order
229 *
230 * @return array
231 * @since 3.0.0
232 * @version 1.0.1
233 */
234 public function get_paypal_args( LP_Order $order ): array {
235 $checkout = LearnPress::instance()->checkout();
236 $custom = array(
237 'order_id' => $order->get_id(),
238 'order_key' => $order->get_order_key(),
239 'checkout_email' => $checkout->get_checkout_email(),
240 );
241 $lp_cart = LearnPress::instance()->get_cart();
242 $cart_total = $lp_cart->calculate_totals();
243 $item_arg = array(
244 'item_name_1' => $order->get_order_number(),
245 'quantity_1' => 1,
246 'amount_1' => $cart_total->total,
247 );
248 $args = array_merge(
249 array(
250 'cmd' => '_cart',
251 'business' => $this->paypal_email,
252 'no_note' => 1,
253 'currency_code' => learn_press_get_currency(),
254 'charset' => 'utf-8',
255 'rm' => is_ssl() ? 2 : 1,
256 'upload' => 1,
257 'return' => esc_url_raw( $this->get_return_url( $order ) ),
258 'cancel_return' => esc_url_raw( learn_press_is_enable_cart() ? learn_press_get_page_link( 'cart' ) : get_home_url() ),
259 'bn' => 'LearnPress_Cart',
260 'custom' => json_encode( $custom ),
261 'notify_url' => get_home_url() . '/?paypal_notify=1',
262 ),
263 $item_arg
264 );
265
266 return apply_filters( 'learn-press/paypal/args', $args );
267 }
268
269 /**
270 * Get access token from PayPal
271 *
272 * Check token expire time before get new token
273 *
274 * @throws Exception
275 * @since 4.2.4
276 * @version 1.0.1
277 */
278 public function get_access_token() {
279 $client_id = $this->client_id;
280 $client_secret = $this->client_secret;
281
282 if ( empty( $client_id ) ) {
283 throw new Exception( __( 'Paypal Client id is required.', 'learnpress' ) );
284 }
285
286 if ( ! $client_secret ) {
287 throw new Exception( __( 'Paypal Client secret is required', 'learnpress' ) );
288 }
289
290 // Check token expire
291 $token_exist = LP_Settings::get_option( self::PAYPAL_TOKEN );
292 if ( ! empty( $token_exist ) ) {
293 $token_exist = LP_Helper::json_decode( $token_exist );
294 $five_minutes_ago = time() - 5 * 60;
295 if ( ! empty( $token_exist->lp_time_end ) && $token_exist->lp_time_end > $five_minutes_ago ) {
296 return $token_exist;
297 }
298 }
299
300 $params = array( 'grant_type' => 'client_credentials' );
301 $response = wp_remote_post(
302 $this->api_url . 'v1/oauth2/token',
303 array(
304 'body' => $params,
305 'headers' => array(
306 'Authorization' => 'Basic ' . base64_encode( $client_id . ':' . $client_secret ),
307 ),
308 'timeout' => 60,
309 )
310 );
311
312 $data_token_str = wp_remote_retrieve_body( $response );
313 $data_token = LP_Helper::json_decode( $data_token_str );
314 $data_token->lp_time_end = time() + $data_token->expires_in;
315 $data_token_str = json_encode( $data_token );
316 if ( isset( $data_token->error ) ) {
317 throw new Exception( $data_token->error_description );
318 }
319
320 if ( empty( $data_token->access_token ) || empty( $data_token->token_type ) ) {
321 throw new Exception( __( 'Invalid PayPal access token', 'learnpress' ) );
322 }
323
324 LP_Settings::update_option( self::PAYPAL_TOKEN, $data_token_str );
325
326 return $data_token;
327 }
328
329 /**
330 * create args to create PayPal order
331 *
332 * @param LP_Order $order
333 *
334 * @return array
335 * @since 4.2.4
336 * @version 1.0.2
337 */
338 public function get_order_args( LP_Order $order ): array {
339 $lp_cart = LearnPress::instance()->get_cart();
340 $cart_total = $lp_cart->calculate_totals();
341 $order_id = $order->get_id();
342 $return_url = esc_url_raw(
343 add_query_arg( 'paypay_express_checkout', 1, $this->get_return_url( $order ) )
344 );
345 $cancel_url = esc_url_raw(
346 learn_press_is_enable_cart() ? learn_press_get_page_link( 'cart' ) : get_home_url()
347 );
348 $brand_name = ! empty( get_bloginfo() ) ? get_bloginfo() : 'LearnPress';
349 $data = array(
350 'intent' => 'CAPTURE',
351 'purchase_units' => array(
352 array(
353 'amount' => array(
354 'currency_code' => learn_press_get_currency(),
355 'value' => strval( round( $cart_total->total, 2 ) ),
356 ),
357 'custom_id' => $order_id,
358 ),
359 ),
360 'payment_source' => array(
361 'paypal' => array(
362 'experience_context' => array(
363 'payment_method_preference' => 'UNRESTRICTED',
364 'brand_name' => $brand_name,
365 'landing_page' => 'LOGIN',
366 'user_action' => 'PAY_NOW',
367 'return_url' => $return_url,
368 'cancel_url' => $cancel_url,
369 ),
370 ),
371 ),
372 );
373
374 return apply_filters( 'learn-press/paypal-rest/args', $data );
375 }
376
377 /**
378 * Create Order PayPal and get checkout url
379 *
380 * @param LP_Order $order
381 * @param object $data_token { scope, access_token, token_type, app_id, expires_in, nonce }
382 *
383 * @return string
384 * @throws Exception
385 * @since 4.2.4
386 * @version 1.0.0
387 */
388 public function create_payment_url( LP_Order $order, $data_token ): string {
389 $checkout_url = '';
390 $params = $this->get_order_args( $order );
391
392 if ( ! isset( $data_token->access_token ) || ! isset( $data_token->token_type ) ) {
393 throw new Exception( __( 'Invalid Paypal access token', 'learnpress' ) );
394 }
395
396 $response = wp_remote_post(
397 $this->api_url . 'v2/checkout/orders',
398 array(
399 'body' => json_encode( $params ),
400 'headers' => array(
401 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token,
402 'Content-Type' => 'application/json',
403 ),
404 'timeout' => 60,
405 )
406 );
407
408 $result = LP_Helper::json_decode( wp_remote_retrieve_body( $response ) );
409 if ( isset( $result->error ) ) {
410 throw new Exception( $result->error_description );
411 }
412
413 /**
414 * Error response
415 *
416 * https://developer.paypal.com/api/rest/reference/orders/v2/errors/
417 */
418 if ( isset( $result->name ) && isset( $result->details[0] ) ) {
419 throw new Exception( $result->details[0]->description );
420 }
421
422 if ( empty( $result->id ) ) {
423 throw new Exception( __( 'Invalid Paypal checkout', 'learnpress' ) );
424 }
425
426 foreach ( $result->links as $link ) {
427 if ( $link->rel === 'payer-action' ) {
428 $checkout_url = $link->href;
429 break;
430 }
431 }
432
433 if ( empty( $checkout_url ) ) {
434 throw new Exception( __( 'Invalid Paypal checkout url', 'learnpress' ) );
435 }
436
437 return $checkout_url;
438 }
439
440 /**
441 * Capture payment for order
442 *
443 * @param string $paypal_order_id
444 * https://developer.paypal.com/docs/api/orders/v2/#orders_capture
445 *
446 * @return bool True when capture is completed and order status is updated.
447 * @throws Exception
448 * @version 1.0.1
449 * @since 4.2.4
450 */
451 public function capture_payment_for_order( string $paypal_order_id ): bool {
452 $data_token = $this->get_access_token();
453 if ( ! isset( $data_token->access_token ) || ! isset( $data_token->token_type ) ) {
454 return false;
455 }
456
457 $response = wp_remote_post(
458 $this->api_url . 'v2/checkout/orders/' . $paypal_order_id . '/capture',
459 array(
460 'headers' => array(
461 'Content-Type' => 'application/json',
462 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token,
463 ),
464 'timeout' => 60,
465 )
466 );
467
468 if ( is_wp_error( $response ) ) {
469 return false;
470 }
471
472 $response_code = absint( $response['response']['code'] ?? 0 );
473 if ( 201 !== $response_code ) {
474 return false;
475 }
476
477 $body = wp_remote_retrieve_body( $response );
478 $transaction = LP_Helper::json_decode( $body );
479 if ( empty( $transaction ) || ! is_object( $transaction ) || ( $transaction->status ?? '' ) !== 'COMPLETED' ) {
480 return false;
481 }
482
483 $order_id = absint( $transaction->purchase_units[0]->payments->captures[0]->custom_id ?? 0 );
484 if ( $order_id <= 0 ) {
485 return false;
486 }
487
488 $lp_order = learn_press_get_order( $order_id );
489 if ( $lp_order instanceof LP_Order ) {
490 $lp_order->update_status( LP_ORDER_COMPLETED );
491 return true;
492 }
493
494 return false;
495 }
496
497 /**
498 * Validate subscription payload for PayPal checkout.
499 *
500 * This override keeps gateway-specific assumptions close to PayPal flow
501 * while still reusing the shared contract validation from abstract gateway.
502 *
503 * @param array $data
504 *
505 * @return array
506 * @throws Exception
507 */
508 protected function validate_subscription_payload( array $data ): array {
509 $data = parent::validate_subscription_payload( $data );
510
511 // PayPal subscription API expects a plan id string and positive quantity.
512 $data['price_id'] = (string) $data['price_id'];
513 $data['quantity'] = max( 1, absint( $data['quantity'] ) );
514
515 return $data;
516 }
517
518 /**
519 * Convert generic interval to PayPal interval unit.
520 *
521 * @param string $interval
522 *
523 * @return string
524 */
525 protected function map_paypal_interval_unit( string $interval ): string {
526
527 $map = array(
528 'day' => 'DAY',
529 'week' => 'WEEK',
530 'month' => 'MONTH',
531 'year' => 'YEAR',
532 );
533
534 return $map[ $interval ] ?? 'MONTH';
535 }
536
537 /**
538 * Convert PayPal interval unit to LearnPress interval slug.
539 *
540 * @param string $interval_unit
541 *
542 * @return string
543 */
544 protected function map_paypal_interval_slug( string $interval_unit ): string {
545
546 $map = array(
547 'DAY' => 'day',
548 'WEEK' => 'week',
549 'MONTH' => 'month',
550 'YEAR' => 'year',
551 );
552
553 $interval_unit = strtoupper( sanitize_text_field( $interval_unit ) );
554
555 return $map[ $interval_unit ] ?? 'month';
556 }
557
558 /**
559 * Build normalized summary from PayPal plan payload.
560 *
561 * @param object $plan_body
562 *
563 * @return array
564 */
565 protected function build_paypal_plan_summary( object $plan_body ): array {
566
567 $summary = array(
568 'plan_id' => (string) ( $plan_body->id ?? '' ),
569 'status' => strtolower( (string) ( $plan_body->status ?? '' ) ),
570 'amount' => 0.0,
571 'currency' => '',
572 'interval' => '',
573 'interval_count' => 1,
574 'setup_fee' => 0.0,
575 'product_id' => (string) ( $plan_body->product_id ?? '' ),
576 );
577
578 if ( ! empty( $plan_body->payment_preferences->setup_fee->value ) ) {
579 $summary['setup_fee'] = (float) $plan_body->payment_preferences->setup_fee->value;
580 }
581
582 if ( ! empty( $plan_body->billing_cycles ) && is_array( $plan_body->billing_cycles ) ) {
583 foreach ( $plan_body->billing_cycles as $billing_cycle ) {
584 if ( ! is_object( $billing_cycle ) ) {
585 continue;
586 }
587 if ( ( $billing_cycle->tenure_type ?? '' ) !== 'REGULAR' ) {
588 continue;
589 }
590
591 $summary['amount'] = (float) ( $billing_cycle->pricing_scheme->fixed_price->value ?? 0 );
592 $summary['currency'] = strtoupper( (string) ( $billing_cycle->pricing_scheme->fixed_price->currency_code ?? '' ) );
593 $summary['interval'] = $this->map_paypal_interval_slug( (string) ( $billing_cycle->frequency->interval_unit ?? '' ) );
594 $summary['interval_count'] = max( 1, absint( $billing_cycle->frequency->interval_count ?? 1 ) );
595 break;
596 }
597 }
598
599 return $summary;
600 }
601 /**
602 * Create PayPal billing plan resource (catalog product + billing plan).
603 *
604 * Returned `plan` object contains PayPal `plan_id` (`$plan->id`) for later
605 * subscription checkout via `pay_subscription()`.
606 *
607 * @param array $data
608 *
609 * @return array
610 * @throws Exception
611 */
612 public function create_plan( array $data ): array {
613
614 if ( ! $this->is_subscription_enabled() ) {
615 throw new Exception( __( 'PayPal subscriptions are disabled.', 'learnpress' ) );
616 }
617
618 $data = $this->validate_data_plan_payload( $data );
619
620 // PayPal-specific optional keys are normalized at gateway level.
621 $description = sanitize_text_field( wp_unslash( (string) ( $data['description'] ?? '' ) ) );
622 $trial_days = absint( $data['trial_days'] ?? 0 );
623 $setup_fee = (float) ( $data['setup_fee'] ?? 0 );
624
625 // PayPal billing plans require a plan name.
626 if ( empty( $data['name'] ) ) {
627 throw new Exception( __( 'Missing subscription plan name.', 'learnpress' ) );
628 }
629 $data_token = $this->get_access_token();
630 if ( empty( $data_token->access_token ) || empty( $data_token->token_type ) ) {
631 throw new Exception( __( 'Invalid Paypal access token', 'learnpress' ) );
632 }
633
634 $product_id = (string) $data['product_id'];
635 $product_body = (object) array( 'id' => $product_id );
636 if ( empty( $product_id ) ) {
637 $product_payload = array(
638 'name' => (string) $data['name'],
639 'type' => 'SERVICE',
640 );
641 if ( ! empty( $description ) ) {
642 $product_payload['description'] = (string) $description;
643 }
644 $product_response = wp_remote_post(
645 $this->api_url . 'v1/catalogs/products',
646 array(
647 'body' => wp_json_encode( $product_payload ),
648 'headers' => array(
649 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token,
650 'Content-Type' => 'application/json',
651 ),
652 'timeout' => 60,
653 )
654 );
655 if ( is_wp_error( $product_response ) ) {
656 throw new Exception( $product_response->get_error_message() );
657 }
658
659 $product_body = LP_Helper::json_decode( wp_remote_retrieve_body( $product_response ) );
660 if ( empty( $product_body->id ) ) {
661 $error_message = __( 'Invalid PayPal product response.', 'learnpress' );
662 if ( ! empty( $product_body->message ) ) {
663 $error_message = (string) $product_body->message;
664 }
665
666 throw new Exception( $error_message );
667 }
668
669 $product_id = (string) $product_body->id;
670 }
671
672 $currency_code = (string) $data['currency'];
673 $billing_cycles = array();
674 $sequence = 1;
675 if ( $trial_days > 0 ) {
676 $billing_cycles[] = array(
677 'frequency' => array(
678 'interval_unit' => 'DAY',
679 'interval_count' => $trial_days,
680 ),
681 'tenure_type' => 'TRIAL',
682 'sequence' => 1,
683 'total_cycles' => 1,
684 'pricing_scheme' => array(
685 'fixed_price' => array(
686 'value' => '0',
687 'currency_code' => $currency_code,
688 ),
689 ),
690 );
691 $sequence = 2;
692 }
693
694 $billing_cycles[] = array(
695 'frequency' => array(
696 'interval_unit' => $this->map_paypal_interval_unit( (string) $data['interval'] ),
697 'interval_count' => max( 1, absint( $data['interval_count'] ) ),
698 ),
699 'tenure_type' => 'REGULAR',
700 'sequence' => $sequence,
701 'total_cycles' => 0,
702 'pricing_scheme' => array(
703 'fixed_price' => array(
704 'value' => number_format( (float) $data['amount'], 2, '.', '' ),
705 'currency_code' => $currency_code,
706 ),
707 ),
708 );
709
710 $plan_payload = array(
711 'product_id' => $product_id,
712 'name' => (string) $data['name'],
713 'status' => 'ACTIVE',
714 'billing_cycles' => $billing_cycles,
715 'payment_preferences' => array(
716 'auto_bill_outstanding' => true,
717 'setup_fee' => array(
718 'value' => number_format( $setup_fee, 2, '.', '' ),
719 'currency_code' => $currency_code,
720 ),
721 'setup_fee_failure_action' => 'CONTINUE',
722 'payment_failure_threshold' => 3,
723 ),
724 );
725 if ( ! empty( $description ) ) {
726 $plan_payload['description'] = (string) $description;
727 }
728 $plan_response = wp_remote_post(
729 $this->api_url . 'v1/billing/plans',
730 array(
731 'body' => wp_json_encode( $plan_payload ),
732 'headers' => array(
733 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token,
734 'Content-Type' => 'application/json',
735 ),
736 'timeout' => 60,
737 )
738 );
739 if ( is_wp_error( $plan_response ) ) {
740 throw new Exception( $plan_response->get_error_message() );
741 }
742
743 $plan_body = LP_Helper::json_decode( wp_remote_retrieve_body( $plan_response ) );
744 if ( empty( $plan_body->id ) ) {
745 $error_message = __( 'Invalid PayPal plan response.', 'learnpress' );
746 if ( ! empty( $plan_body->message ) ) {
747 $error_message = (string) $plan_body->message;
748 }
749
750 throw new Exception( $error_message );
751 }
752
753 return array(
754 'status' => 'success',
755 'product' => $product_body,
756 'plan' => $plan_body,
757 'message' => __( 'PayPal subscription plan created.', 'learnpress' ),
758 );
759 }
760
761 /**
762 * List PayPal billing plans.
763 *
764 * @param array $args Supported: page, page_size, total_required, product_id.
765 *
766 * @return array
767 * @throws Exception
768 */
769 public function list_plans( array $args = array() ): array {
770
771 if ( ! $this->is_subscription_enabled() ) {
772 throw new Exception( __( 'PayPal subscriptions are disabled.', 'learnpress' ) );
773 }
774
775 $args = wp_parse_args(
776 $args,
777 array(
778 'page' => 1,
779 'page_size' => 20,
780 'total_required' => false,
781 'product_id' => '',
782 )
783 );
784
785 $query_args = array(
786 'page' => max( 1, absint( $args['page'] ) ),
787 'page_size' => max( 1, min( 20, absint( $args['page_size'] ) ) ),
788 'total_required' => ! empty( $args['total_required'] ) ? 'true' : 'false',
789 );
790 if ( ! empty( $args['product_id'] ) ) {
791 $query_args['product_id'] = sanitize_text_field( wp_unslash( (string) $args['product_id'] ) );
792 }
793
794 $data_token = $this->get_access_token();
795 if ( empty( $data_token->access_token ) || empty( $data_token->token_type ) ) {
796 throw new Exception( __( 'Invalid Paypal access token', 'learnpress' ) );
797 }
798
799 $plans_response = wp_remote_get(
800 $this->api_url . 'v1/billing/plans?' . http_build_query( $query_args ),
801 array(
802 'headers' => array(
803 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token,
804 'Content-Type' => 'application/json',
805 ),
806 'timeout' => 60,
807 )
808 );
809
810 if ( is_wp_error( $plans_response ) ) {
811 throw new Exception( $plans_response->get_error_message() );
812 }
813
814 $plans_body = LP_Helper::json_decode( wp_remote_retrieve_body( $plans_response ) );
815 if ( ! is_object( $plans_body ) ) {
816 throw new Exception( __( 'Invalid PayPal plans response.', 'learnpress' ) );
817 }
818
819 $plans = array();
820 $summaries = array();
821 if ( ! empty( $plans_body->plans ) && is_array( $plans_body->plans ) ) {
822 foreach ( $plans_body->plans as $plan ) {
823 if ( ! is_object( $plan ) ) {
824 continue;
825 }
826
827 $plans[] = $plan;
828 $summaries[] = $this->build_paypal_plan_summary( $plan );
829 }
830 }
831
832 return array(
833 'status' => 'success',
834 'plans' => $plans,
835 'summaries' => $summaries,
836 'total_items' => absint( $plans_body->total_items ?? count( $plans ) ),
837 'total_pages' => absint( $plans_body->total_pages ?? 1 ),
838 'message' => __( 'PayPal subscription plans fetched.', 'learnpress' ),
839 );
840 }
841
842 /**
843 * Get PayPal billing plan details by plan id.
844 *
845 * Returned `summary` is normalized for integration checks when external
846 * code compares local subscription config with remote provider plan values.
847 *
848 * @param string $plan_id
849 *
850 * @return array
851 * @throws Exception
852 */
853 public function get_plan( string $plan_id ): array {
854
855 if ( ! $this->is_subscription_enabled() ) {
856 throw new Exception( __( 'PayPal subscriptions are disabled.', 'learnpress' ) );
857 }
858
859 $plan_id = sanitize_text_field( wp_unslash( $plan_id ) );
860 if ( empty( $plan_id ) ) {
861 throw new Exception( __( 'Missing subscription plan id.', 'learnpress' ) );
862 }
863
864 $data_token = $this->get_access_token();
865 if ( empty( $data_token->access_token ) || empty( $data_token->token_type ) ) {
866 throw new Exception( __( 'Invalid Paypal access token', 'learnpress' ) );
867 }
868
869 $plan_response = wp_remote_get(
870 $this->api_url . 'v1/billing/plans/' . rawurlencode( $plan_id ),
871 array(
872 'headers' => array(
873 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token,
874 'Content-Type' => 'application/json',
875 ),
876 'timeout' => 60,
877 )
878 );
879
880 if ( is_wp_error( $plan_response ) ) {
881 throw new Exception( $plan_response->get_error_message() );
882 }
883
884 $plan_body = LP_Helper::json_decode( wp_remote_retrieve_body( $plan_response ) );
885 if ( empty( $plan_body->id ) ) {
886 $error_message = __( 'Invalid PayPal plan response.', 'learnpress' );
887 if ( ! empty( $plan_body->message ) ) {
888 $error_message = (string) $plan_body->message;
889 }
890
891 throw new Exception( $error_message );
892 }
893
894 $summary = $this->build_paypal_plan_summary( $plan_body );
895
896 return array(
897 'status' => 'success',
898 'plan' => $plan_body,
899 'summary' => $summary,
900 'message' => __( 'PayPal subscription plan fetched.', 'learnpress' ),
901 );
902 }
903
904 /**
905 * Update PayPal billing plan values.
906 *
907 * Supported update fields:
908 * - name, description, status, setup_fee
909 * - amount (uses update-pricing-schemes endpoint for REGULAR cycle)
910 *
911 * @param string $plan_id
912 * @param array $data
913 *
914 * @return array
915 * @throws Exception
916 */
917 public function update_plan( string $plan_id, array $data ): array {
918
919 if ( ! $this->is_subscription_enabled() ) {
920 throw new Exception( __( 'PayPal subscriptions are disabled.', 'learnpress' ) );
921 }
922
923 $plan_id = sanitize_text_field( wp_unslash( $plan_id ) );
924 if ( empty( $plan_id ) ) {
925 throw new Exception( __( 'Missing subscription plan id.', 'learnpress' ) );
926 }
927
928 $current = $this->get_plan( $plan_id );
929 $current_plan = $current['plan'];
930 $current_sum = $current['summary'];
931
932 $data = wp_parse_args(
933 $data,
934 array(
935 'name' => null,
936 'description' => null,
937 'status' => null,
938 'amount' => null,
939 'currency' => '',
940 'interval' => '',
941 'interval_count' => null,
942 'setup_fee' => null,
943 )
944 );
945
946 $data_token = $this->get_access_token();
947 if ( empty( $data_token->access_token ) || empty( $data_token->token_type ) ) {
948 throw new Exception( __( 'Invalid Paypal access token', 'learnpress' ) );
949 }
950
951 $patches = array();
952 if ( null !== $data['name'] ) {
953 $patches[] = array(
954 'op' => 'replace',
955 'path' => '/name',
956 'value' => sanitize_text_field( wp_unslash( (string) $data['name'] ) ),
957 );
958 }
959 if ( null !== $data['description'] ) {
960 $patches[] = array(
961 'op' => 'replace',
962 'path' => '/description',
963 'value' => sanitize_text_field( wp_unslash( (string) $data['description'] ) ),
964 );
965 }
966 if ( null !== $data['status'] ) {
967 $status = strtoupper( sanitize_text_field( wp_unslash( (string) $data['status'] ) ) );
968 if ( ! in_array( $status, array( 'ACTIVE', 'INACTIVE' ), true ) ) {
969 throw new Exception( __( 'Invalid PayPal subscription plan status.', 'learnpress' ) );
970 }
971 $patches[] = array(
972 'op' => 'replace',
973 'path' => '/status',
974 'value' => $status,
975 );
976 }
977 if ( null !== $data['setup_fee'] ) {
978 $setup_fee = (float) $data['setup_fee'];
979 if ( $setup_fee < 0 ) {
980 throw new Exception( __( 'Invalid subscription setup fee.', 'learnpress' ) );
981 }
982 $patches[] = array(
983 'op' => 'replace',
984 'path' => '/payment_preferences/setup_fee',
985 'value' => array(
986 'value' => number_format( $setup_fee, 2, '.', '' ),
987 'currency_code' => (string) $current_sum['currency'],
988 ),
989 );
990 }
991
992 if ( ! empty( $patches ) ) {
993 $patch_response = wp_remote_request(
994 $this->api_url . 'v1/billing/plans/' . rawurlencode( $plan_id ),
995 array(
996 'method' => 'PATCH',
997 'body' => wp_json_encode( $patches ),
998 'headers' => array(
999 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token,
1000 'Content-Type' => 'application/json',
1001 ),
1002 'timeout' => 60,
1003 )
1004 );
1005 if ( is_wp_error( $patch_response ) ) {
1006 throw new Exception( $patch_response->get_error_message() );
1007 }
1008 }
1009
1010 if ( null !== $data['interval_count'] || ! empty( $data['interval'] ) ) {
1011 $new_interval = ! empty( $data['interval'] ) ? strtolower( sanitize_key( (string) $data['interval'] ) ) : (string) $current_sum['interval'];
1012 $new_interval_count = null !== $data['interval_count'] ? max( 1, absint( $data['interval_count'] ) ) : (int) $current_sum['interval_count'];
1013 if ( $new_interval !== (string) $current_sum['interval'] || $new_interval_count !== (int) $current_sum['interval_count'] ) {
1014 throw new Exception( __( 'PayPal plan interval cannot be changed. Create a new plan instead.', 'learnpress' ) );
1015 }
1016 }
1017
1018 if ( null !== $data['amount'] ) {
1019 $new_amount = (float) $data['amount'];
1020 if ( $new_amount <= 0 ) {
1021 throw new Exception( __( 'Invalid subscription amount.', 'learnpress' ) );
1022 }
1023
1024 $currency_code = ! empty( $data['currency'] ) ? strtoupper( sanitize_text_field( wp_unslash( (string) $data['currency'] ) ) ) : (string) $current_sum['currency'];
1025 if ( empty( $currency_code ) ) {
1026 throw new Exception( __( 'Missing subscription currency.', 'learnpress' ) );
1027 }
1028
1029 $regular_sequence = 1;
1030 if ( ! empty( $current_plan->billing_cycles ) && is_array( $current_plan->billing_cycles ) ) {
1031 foreach ( $current_plan->billing_cycles as $billing_cycle ) {
1032 if ( ! is_object( $billing_cycle ) ) {
1033 continue;
1034 }
1035 if ( ( $billing_cycle->tenure_type ?? '' ) === 'REGULAR' ) {
1036 $regular_sequence = max( 1, absint( $billing_cycle->sequence ?? 1 ) );
1037 break;
1038 }
1039 }
1040 }
1041
1042 $pricing_response = wp_remote_post(
1043 $this->api_url . 'v1/billing/plans/' . rawurlencode( $plan_id ) . '/update-pricing-schemes',
1044 array(
1045 'body' => wp_json_encode(
1046 array(
1047 'pricing_schemes' => array(
1048 array(
1049 'billing_cycle_sequence' => $regular_sequence,
1050 'pricing_scheme' => array(
1051 'fixed_price' => array(
1052 'value' => number_format( $new_amount, 2, '.', '' ),
1053 'currency_code' => $currency_code,
1054 ),
1055 ),
1056 ),
1057 ),
1058 )
1059 ),
1060 'headers' => array(
1061 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token,
1062 'Content-Type' => 'application/json',
1063 ),
1064 'timeout' => 60,
1065 )
1066 );
1067 if ( is_wp_error( $pricing_response ) ) {
1068 throw new Exception( $pricing_response->get_error_message() );
1069 }
1070 }
1071
1072 $updated = $this->get_plan( $plan_id );
1073 $updated['message'] = __( 'PayPal subscription plan updated.', 'learnpress' );
1074
1075 return $updated;
1076 }
1077
1078 /**
1079 * Deactivate PayPal billing plan.
1080 *
1081 * PayPal does not support hard-delete for plans; this operation marks
1082 * plan status as INACTIVE.
1083 *
1084 * @param string $plan_id
1085 *
1086 * @return array
1087 * @throws Exception
1088 */
1089 public function delete_plan( string $plan_id ): array {
1090
1091 $deleted = $this->update_plan(
1092 $plan_id,
1093 array(
1094 'status' => 'INACTIVE',
1095 )
1096 );
1097 $deleted['message'] = __( 'PayPal subscription plan deactivated.', 'learnpress' );
1098
1099 return $deleted;
1100 }
1101
1102 /**
1103 * Create PayPal subscription checkout and return redirect payload.
1104 *
1105 * Request is sent to PayPal Billing Subscriptions API using:
1106 * - plan_id = subscription price/plan id configured in PayPal.
1107 * - custom_id = LearnPress parent order id for reconciliation.
1108 * - return/cancel URLs = checkout callbacks.
1109 *
1110 * @param array $data Normalized payload from get_subscription_context().
1111 *
1112 * @return array{
1113 * status:string,
1114 * redirect_url:string,
1115 * provider_reference:string,
1116 * subscription_id:string,
1117 * message:string
1118 * }
1119 * @throws Exception
1120 */
1121 public function pay_subscription( array $data ): array {
1122 if ( ! $this->is_subscription_enabled() ) {
1123 throw new Exception( __( 'PayPal subscriptions are disabled.', 'learnpress' ) );
1124 }
1125
1126 $data = $this->validate_subscription_payload( $data );
1127
1128 $data_token = $this->get_access_token();
1129 if ( empty( $data_token->access_token ) || empty( $data_token->token_type ) ) {
1130 throw new Exception( __( 'Invalid Paypal access token', 'learnpress' ) );
1131 }
1132
1133 $metadata = array_map(
1134 function ( $value ) {
1135 if ( is_scalar( $value ) || is_null( $value ) ) {
1136 return (string) $value;
1137 }
1138 return wp_json_encode( $value );
1139 },
1140 (array) $data['metadata']
1141 );
1142 $order_subscription_id = absint( $metadata['lp_order_id'] ?? 0 );
1143
1144 $request_body = array(
1145 'plan_id' => (string) $data['price_id'],
1146 'quantity' => (string) max( 1, absint( $data['quantity'] ) ),
1147 'custom_id' => ! empty( $order_subscription_id ) ? (string) $order_subscription_id : '',
1148 'application_context' => array(
1149 'brand_name' => ! empty( get_bloginfo() ) ? get_bloginfo() : 'LearnPress',
1150 'return_url' => esc_url_raw( (string) $data['success_url'] ),
1151 'cancel_url' => esc_url_raw( (string) $data['cancel_url'] ),
1152 ),
1153 );
1154
1155 $response = wp_remote_post(
1156 $this->api_url . 'v1/billing/subscriptions',
1157 array(
1158 'body' => wp_json_encode( $request_body ),
1159 'headers' => array(
1160 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token,
1161 'Content-Type' => 'application/json',
1162 ),
1163 'timeout' => 60,
1164 )
1165 );
1166
1167 if ( is_wp_error( $response ) ) {
1168 throw new Exception( $response->get_error_message() );
1169 }
1170
1171 $body = wp_remote_retrieve_body( $response );
1172 $data = LP_Helper::json_decode( $body );
1173
1174 if ( empty( $data->id ) ) {
1175 $error_message = __( 'Invalid PayPal subscription response.', 'learnpress' );
1176 if ( ! empty( $data->message ) ) {
1177 $error_message = $data->message;
1178 }
1179 throw new Exception( $error_message );
1180 }
1181
1182 $approve_url = '';
1183 if ( ! empty( $data->links ) && is_array( $data->links ) ) {
1184 foreach ( $data->links as $link ) {
1185 if ( ! empty( $link->rel ) && 'approve' === $link->rel ) {
1186 $approve_url = $link->href;
1187 break;
1188 }
1189 }
1190 }
1191
1192 if ( empty( $approve_url ) ) {
1193 throw new Exception( __( 'Invalid PayPal subscription approve URL.', 'learnpress' ) );
1194 }
1195
1196 return array(
1197 'status' => 'success',
1198 'redirect_url' => esc_url_raw( $approve_url ),
1199 'provider_reference' => (string) $data->id,
1200 'subscription_id' => (string) $data->id,
1201 'message' => __( 'Redirecting to PayPal subscription checkout.', 'learnpress' ),
1202 );
1203 }
1204
1205 /**
1206 * Create PayPal subscription checkout and return redirect payload.
1207 *
1208 * Request is sent to PayPal Billing Subscriptions API using:
1209 * - plan_id = subscription price/plan id configured in PayPal.
1210 * - custom_id = LearnPress parent order id for reconciliation.
1211 * - return/cancel URLs = checkout callbacks.
1212 *
1213 * @param array $data Normalized payload from get_subscription_context().
1214 *
1215 * @return array{
1216 * status:string,
1217 * redirect_url:string,
1218 * provider_reference:string,
1219 * subscription_id:string,
1220 * message:string
1221 * }
1222 * @throws Exception
1223 */
1224 public function pay_via_subscription( array $data ): array {
1225 if ( ! $this->is_subscription_enabled() ) {
1226 throw new Exception( __( 'PayPal subscriptions are disabled.', 'learnpress' ) );
1227 }
1228
1229 $plan_id = $data['plan_id'] ?? '';
1230 if ( empty( $plan_id ) ) {
1231 throw new Exception( __( 'PayPal subscription plan ID is invalid.', 'learnpress' ) );
1232 }
1233
1234 $lp_order_id = $data['lp_order_id'] ?? '';
1235 if ( empty( $lp_order_id ) ) {
1236 throw new Exception( __( 'LearnPress order ID is invalid.', 'learnpress' ) );
1237 }
1238
1239 $lp_order = learn_press_get_order( (int) $lp_order_id );
1240 if ( ! $lp_order ) {
1241 throw new Exception( __( 'LearnPress order is invalid.', 'learnpress' ) );
1242 }
1243
1244 $data_token = $this->get_access_token();
1245
1246 $request_body = array(
1247 'plan_id' => (string) ( $data['plan_id'] ?? '' ),
1248 'quantity' => (string) max( 1, absint( $data['quantity'] ?? 0 ) ),
1249 'custom_id' => (string) ( $data['lp_order_id'] ?? '' ),
1250 'application_context' => array(
1251 'brand_name' => ! empty( get_bloginfo() ) ? get_bloginfo() : 'LearnPress',
1252 'return_url' => esc_url_raw( (string) ( $data['success_url'] ?? '' ) ),
1253 'cancel_url' => esc_url_raw( (string) ( $data['cancel_url'] ?? '' ) ),
1254 ),
1255 );
1256
1257 // If the user already completed a trial for this plan on a previous order.
1258 $user_has_trial_done = get_user_meta( $lp_order->get_user_id(), 'lp_subscription_trial', true );
1259 if ( $user_has_trial_done ) {
1260 // Fetch plan details to find the REGULAR billing cycle and its pricing.
1261 $plan_response = wp_remote_get(
1262 $this->api_url . 'v1/billing/plans/' . rawurlencode( $plan_id ),
1263 array(
1264 'headers' => array(
1265 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token,
1266 'Content-Type' => 'application/json',
1267 ),
1268 'timeout' => 60,
1269 )
1270 );
1271
1272 if ( ! is_wp_error( $plan_response ) ) {
1273 $plan_body = LP_Helper::json_decode( wp_remote_retrieve_body( $plan_response ), true );
1274 $plan_billing_cycles = $plan_body['billing_cycles'] ?? array();
1275
1276 LP_Debug::log_to_comment( 'Plan: ' . json_encode( $plan_body, JSON_UNESCAPED_UNICODE ) );
1277
1278 $regular_cycle = null;
1279 foreach ( $plan_billing_cycles as $cycle ) {
1280 if ( ( $cycle['tenure_type'] ?? '' ) === 'REGULAR' ) {
1281 $regular_cycle = $cycle;
1282 break;
1283 }
1284 }
1285
1286 if ( $regular_cycle ) {
1287 // Override billing_cycles with only the REGULAR cycle at sequence=1,
1288 // effectively skipping any trial cycle for this subscription.
1289 $override_cycle = array(
1290 'sequence' => 1,
1291 );
1292 if ( ! empty( $regular_cycle['pricing_scheme'] ) ) {
1293 $override_cycle['pricing_scheme'] = $regular_cycle['pricing_scheme'];
1294 }
1295 $request_body['plan']['billing_cycles'] = array( $override_cycle );
1296 //$request_body['plan']['payment_preferences'] = $plan_body['payment_preferences'] ?? '';
1297 }
1298 }
1299 }
1300
1301 LP_Debug::log_to_comment( 'Payment param send: ' . json_encode( $request_body, JSON_UNESCAPED_UNICODE ) );
1302
1303 $response = wp_remote_post(
1304 $this->api_url . 'v1/billing/subscriptions',
1305 array(
1306 'body' => wp_json_encode( $request_body ),
1307 'headers' => array(
1308 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token,
1309 'Content-Type' => 'application/json',
1310 ),
1311 'timeout' => 60,
1312 )
1313 );
1314
1315 if ( is_wp_error( $response ) ) {
1316 throw new Exception( $response->get_error_message(), $response->get_error_code() );
1317 }
1318
1319 $body = wp_remote_retrieve_body( $response );
1320 $response_data = LP_Helper::json_decode( $body, true );
1321
1322 //error_log( 'Pay subscription: ' . $body );
1323 LP_Debug::log_to_comment( 'Pay subscription: ' . $body );
1324
1325 // Error return from PayPal
1326 if ( ! empty( $response_data['debug_id'] ) ) {
1327 throw new Exception( $response_data['details'][0]['description'] );
1328 }
1329
1330 if ( empty( $response_data['id'] ) ) {
1331 throw new Exception( __( 'Invalid PayPal subscription response.', 'learnpress' ) );
1332 }
1333
1334 // Update info for LP Order
1335 update_post_meta( $lp_order_id, self::META_SUBSCRIPTION_ID, $response_data['id'] );
1336 update_post_meta( $lp_order_id, self::META_SUBSCRIPTION_PLAN_ID, $plan_id );
1337
1338 $response_data['redirect_url'] = $response_data['links'][0]['href'];
1339
1340 return $response_data;
1341 }
1342
1343 /**
1344 * Reverse verify PayPal subscription webhook before processing.
1345 *
1346 * Verification uses PayPal /verify-webhook-signature endpoint and requires:
1347 * - configured webhook id from merchant settings,
1348 * - required PayPal transmission headers,
1349 * - raw JSON payload as webhook_event.
1350 *
1351 * @param array $webhook_data Webhook payload + headers extracted by listener.
1352 *
1353 * @return array Verified webhook payload array on success.
1354 * @throws Exception
1355 */
1356 public function verify_subscription_webhook( array $webhook_data ): array {
1357
1358 if ( empty( $this->subscription_webhook_id ) ) {
1359 throw new Exception( __( 'PayPal subscription webhook id is missing.', 'learnpress' ), 500 );
1360 }
1361
1362 $required_headers = array(
1363 'paypal-auth-algo',
1364 'paypal-cert-url',
1365 'paypal-transmission-id',
1366 'paypal-transmission-sig',
1367 'paypal-transmission-time',
1368 );
1369 $this->validate_webhook_data_contract( $webhook_data, array( 'body', 'headers' ), $required_headers );
1370
1371 $payload = $webhook_data['body'] ?? null;
1372 if ( is_string( $payload ) ) {
1373 $payload = json_decode( $payload, true );
1374 }
1375 if ( empty( $payload ) || ! is_array( $payload ) ) {
1376 throw new Exception( __( 'Invalid PayPal webhook payload.', 'learnpress' ), 400 );
1377 }
1378
1379 $headers_map = is_array( $webhook_data['headers'] ?? null ) ? $webhook_data['headers'] : array();
1380 foreach ( $required_headers as $required_header ) {
1381 $header_value = sanitize_text_field( (string) ( $headers_map[ $required_header ] ?? '' ) );
1382 if ( '' === $header_value ) {
1383 throw new Exception( __( 'Invalid PayPal webhook headers.', 'learnpress' ), 400 );
1384 }
1385 $headers_map[ $required_header ] = $header_value;
1386 }
1387 $cert_url = esc_url_raw( $headers_map['paypal-cert-url'] );
1388 $cert_host = wp_parse_url( $cert_url, PHP_URL_HOST );
1389 if (
1390 empty( $cert_url ) ||
1391 stripos( $cert_url, 'https://' ) !== 0 ||
1392 empty( $cert_host ) ||
1393 ! preg_match( '/(^|\.)paypal\.com$/i', (string) $cert_host )
1394 ) {
1395 throw new Exception( __( 'Invalid PayPal webhook certificate URL.', 'learnpress' ), 400 );
1396 }
1397
1398 $data_token = $this->get_access_token();
1399 if ( empty( $data_token->access_token ) || empty( $data_token->token_type ) ) {
1400 throw new Exception( __( 'Invalid Paypal access token', 'learnpress' ) );
1401 }
1402
1403 $verify_payload = array(
1404 'auth_algo' => $headers_map['paypal-auth-algo'],
1405 'cert_url' => $cert_url,
1406 'transmission_id' => $headers_map['paypal-transmission-id'],
1407 'transmission_sig' => $headers_map['paypal-transmission-sig'],
1408 'transmission_time' => $headers_map['paypal-transmission-time'],
1409 'webhook_id' => (string) $this->subscription_webhook_id,
1410 'webhook_event' => $payload,
1411 );
1412
1413 $response = wp_remote_post(
1414 $this->api_url . 'v1/notifications/verify-webhook-signature',
1415 array(
1416 'body' => wp_json_encode( $verify_payload ),
1417 'headers' => array(
1418 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token,
1419 'Content-Type' => 'application/json',
1420 ),
1421 'timeout' => 60,
1422 )
1423 );
1424
1425 if ( is_wp_error( $response ) ) {
1426 throw new Exception( $response->get_error_message(), 400 );
1427 }
1428
1429 $verify_result = LP_Helper::json_decode( wp_remote_retrieve_body( $response ), true );
1430 $is_verified = ! empty( $verify_result['verification_status'] ) && 'SUCCESS' === strtoupper( $verify_result['verification_status'] );
1431 if ( ! $is_verified ) {
1432 throw new Exception( __( 'PayPal webhook verification failed.', 'learnpress' ), 400 );
1433 }
1434
1435 return $payload;
1436 }
1437
1438 /**
1439 * Normalize PayPal webhook event into LearnPress subscription event schema.
1440 *
1441 * Maps PayPal event names into LP canonical event_type values and extracts
1442 * identifiers needed by Subscription Manager (event_id, subscription_id,
1443 * parent_order_id, renewal_key, amount/currency).
1444 *
1445 * @param array|object $provider_event
1446 *
1447 * @return array
1448 */
1449 public function normalize_subscription_event( $provider_event ): array {
1450 $event = parent::normalize_subscription_event( $provider_event );
1451 if ( is_object( $provider_event ) ) {
1452 $provider_event = (array) $provider_event;
1453 }
1454
1455 $event['event_id'] = (string) ( $provider_event['id'] ?? '' );
1456 $paypal_event_type = (string) ( $provider_event['event_type'] ?? '' );
1457 $resource = (array) ( $provider_event['resource'] ?? array() );
1458 $event['metadata'] = array();
1459
1460 $order_subscription_id = '';
1461 if ( ! empty( $resource['custom_id'] ) ) {
1462 $order_subscription_id = (string) $resource['custom_id'];
1463 } elseif ( ! empty( $resource['custom'] ) ) {
1464 // Some PayPal sale resources provide `custom` instead of `custom_id`.
1465 $order_subscription_id = (string) $resource['custom'];
1466 }
1467 if ( '' !== $order_subscription_id ) {
1468 $event['metadata']['lp_order_id'] = $order_subscription_id;
1469 $event['parent_order_id'] = absint( $order_subscription_id );
1470 }
1471
1472 switch ( $paypal_event_type ) {
1473 case 'BILLING.SUBSCRIPTION.ACTIVATED':
1474 $event['event_type'] = 'subscription_activated';
1475 $event['subscription_id'] = (string) ( $resource['id'] ?? '' );
1476 break;
1477 case 'BILLING.SUBSCRIPTION.UPDATED':
1478 // This callback does not add state transition value for LP flow.
1479 // Keep ignored intentionally to avoid ambiguous status handling.
1480 $event['event_type'] = 'ignored';
1481 break;
1482 case 'BILLING.SUBSCRIPTION.CANCELLED':
1483 $event['event_type'] = 'subscription_cancelled';
1484 $event['subscription_id'] = (string) ( $resource['id'] ?? '' );
1485 break;
1486 case 'BILLING.SUBSCRIPTION.SUSPENDED':
1487 $event['event_type'] = 'subscription_suspended';
1488 $event['subscription_id'] = (string) ( $resource['id'] ?? '' );
1489 break;
1490 case 'BILLING.SUBSCRIPTION.EXPIRED':
1491 // EXPPIRED is usually for fixed-term plans (total_cycles > 0),
1492 // not the common auto-renew membership plan (total_cycles = 0).
1493 $event['event_type'] = 'subscription_expired';
1494 $event['subscription_id'] = (string) ( $resource['id'] ?? '' );
1495 break;
1496 case 'PAYMENT.SALE.COMPLETED':
1497 $event['event_type'] = 'renewal_payment_succeeded';
1498 $event['subscription_id'] = (string) ( $resource['billing_agreement_id'] ?? '' );
1499 $event['transaction_id'] = (string) ( $resource['id'] ?? '' );
1500 if ( ! empty( $resource['id'] ) ) {
1501 $event['renewal_key'] = 'paypal_sale_' . sanitize_text_field( (string) $resource['id'] );
1502 }
1503 if ( ! empty( $resource['amount'] ) ) {
1504 $amount = (array) $resource['amount'];
1505 $event['amount'] = (float) ( $amount['total'] ?? ( $amount['value'] ?? 0 ) );
1506 $event['currency'] = strtoupper( (string) ( $amount['currency'] ?? ( $amount['currency_code'] ?? '' ) ) );
1507 }
1508 break;
1509 case 'PAYMENT.SALE.DENIED':
1510 $event['event_type'] = 'renewal_payment_failed';
1511 $event['subscription_id'] = (string) ( $resource['billing_agreement_id'] ?? '' );
1512 $event['transaction_id'] = (string) ( $resource['id'] ?? '' );
1513 if ( ! empty( $resource['id'] ) ) {
1514 $event['renewal_key'] = 'paypal_sale_' . sanitize_text_field( (string) $resource['id'] );
1515 }
1516 break;
1517 case 'BILLING.SUBSCRIPTION.PAYMENT.FAILED':
1518 $event['event_type'] = 'renewal_payment_failed';
1519 $event['subscription_id'] = (string) ( $resource['id'] ?? ( $resource['billing_agreement_id'] ?? '' ) );
1520 $event['transaction_id'] = (string) ( $resource['sale_id'] ?? ( $resource['transaction_id'] ?? '' ) );
1521 break;
1522 default:
1523 $event['event_type'] = 'ignored';
1524 break;
1525 }
1526
1527 return $event;
1528 }
1529
1530 /**
1531 * Verify, normalize, and dispatch PayPal subscription webhook event.
1532 *
1533 * Returns ignored status for unsupported event types and delegates valid
1534 * mapped events to LP_Subscription_Manager for idempotent processing.
1535 *
1536 * @param WP_REST_Request $request
1537 *
1538 * @return array
1539 * @throws Exception
1540 */
1541 public function listen_webhook_subscription( WP_REST_Request $request ): array {
1542
1543 $required_headers = array(
1544 'paypal-auth-algo',
1545 'paypal-cert-url',
1546 'paypal-transmission-id',
1547 'paypal-transmission-sig',
1548 'paypal-transmission-time',
1549 );
1550 $webhook_data = $this->build_webhook_data_from_request( $request, $required_headers, true );
1551
1552 $verified_event = $this->verify_subscription_webhook( $webhook_data );
1553 $event = $this->normalize_subscription_event( $verified_event );
1554 if ( empty( $event['event_type'] ) || 'ignored' === $event['event_type'] ) {
1555 return array(
1556 'status' => 'ignored',
1557 'event_id' => $event['event_id'] ?? '',
1558 'event_type' => $event['event_type'] ?? 'ignored',
1559 'status_code' => 200,
1560 );
1561 }
1562
1563 if ( ! class_exists( 'LP_Subscription_Manager' ) ) {
1564 throw new Exception( __( 'Subscription manager is not available.', 'learnpress' ), 500 );
1565 }
1566
1567 return LP_Subscription_Manager::instance()->process_webhook_event( $this, $event );
1568 }
1569
1570 /**
1571 * Receive data from webhook.
1572 * Verify, normalize, and dispatch PayPal subscription webhook event.
1573 *
1574 * Flow for a plan purchase lifecycle:
1575 * First purchase: BILLING.SUBSCRIPTION.CREATED -> BILLING.SUBSCRIPTION.ACTIVATED -> PAYMENT.SALE.COMPLETED
1576 * Renewal: PAYMENT.SALE.COMPLETED
1577 *
1578 * @param WP_REST_Request $request
1579 *
1580 * @throws Exception
1581 * @since 4.3.7
1582 * @version 1.0.0
1583 */
1584 public function capture_subscription_webhook( WP_REST_Request $request ) {
1585 $webhook_data = LP_Helper::json_decode( $request->get_body(), true );
1586
1587 // Verify data from webhook
1588 $webhook_data_verify = [
1589 'auth_algo' => $request->get_header( 'PAYPAL-AUTH-ALGO' ) ?? '',
1590 'cert_url' => $request->get_header( 'PAYPAL-CERT-URL' ) ?? '',
1591 'transmission_id' => $request->get_header( 'PAYPAL-TRANSMISSION-ID' ) ?? '',
1592 'transmission_sig' => $request->get_header( 'PAYPAL-TRANSMISSION-SIG' ) ?? '',
1593 'transmission_time' => $request->get_header( 'PAYPAL-TRANSMISSION-TIME' ) ?? '',
1594 'webhook_id' => $this->subscription_webhook_id,
1595 'webhook_event' => $webhook_data,
1596 ];
1597 $this->verify_data_from_webhook_subscription( $webhook_data_verify );
1598
1599 // Check lp order exists
1600 // SUBSCRIPTION return custom_id, PAYMENT.SALE return custom
1601 $lp_order_id = $webhook_data['resource']['custom'] ?? $webhook_data['resource']['custom_id'] ?? '';
1602 $lp_order = learn_press_get_order( $lp_order_id );
1603 if ( ! $lp_order ) {
1604 error_log( 'LearnPress order is invalid: ' . json_encode( $webhook_data, JSON_UNESCAPED_UNICODE ) );
1605 return;
1606 }
1607 $webhook_data['lp_order_id'] = $lp_order_id;
1608
1609 // Webhook billing subscription create
1610 $is_billing_subscription_created = $this->capture_billing_subscription_create( $lp_order, $webhook_data );
1611 if ( $is_billing_subscription_created ) {
1612 return;
1613 }
1614
1615 // Capture payment setup fee or renewal
1616 $this->capturePaymentSetupFeeOrRenewal( $lp_order, $webhook_data );
1617 // Capture subscription data
1618 $this->capture_subscription_data( $webhook_data );
1619
1620 $lp_payment_success = $lp_order->get_meta( self::META_SUBSCRIPTION_DATA_PAYMENT_SUCCESS );
1621 $lp_subscription_status_tmp = $lp_order->get_meta( self::META_SUBSCRIPTION_STATUS_TMP );
1622 $lp_subscription_status = $lp_order->get_meta( self::META_SUBSCRIPTION_STATUS );
1623
1624 // Check lp order status is activated will renew
1625 if ( $lp_order->is_completed() ) {
1626 if ( $lp_subscription_status === LP_Subscription_Manager::STATUS_ACTIVATED ) {
1627 LP_Debug::log_to_comment( 'Activated to renew' );
1628 $lp_subscription_status_set_to_handle = LP_Subscription_Manager::STATUS_RENEWED;
1629 }
1630 } elseif ( ! empty( $lp_payment_success )
1631 && $lp_subscription_status_tmp === LP_Subscription_Manager::STATUS_ACTIVATED ) {
1632 // If payment success and subscription status tmp is not empty
1633 LP_Debug::log_to_comment( 'Payment success and subscription status tmp is not empty' );
1634 $lp_subscription_status_set_to_handle = LP_Subscription_Manager::STATUS_ACTIVATED;
1635 } elseif ( $webhook_data['lp_subscription_status'] !== LP_Subscription_Manager::STATUS_ACTIVATED ) {
1636 // If subscription status is not activated, use subscription status from webhook
1637 $lp_subscription_status_set_to_handle = $webhook_data['lp_subscription_status'] ?? '';
1638 LP_Debug::log_to_comment( 'Subscription status is not activated: ' . $lp_subscription_status_set_to_handle );
1639 }
1640
1641 if ( empty( $lp_subscription_status_set_to_handle ) ) {
1642 return;
1643 }
1644
1645 // Dispatch webhook
1646 $this->process_subscription_by_status( $lp_order, $lp_subscription_status_set_to_handle, $webhook_data );
1647 }
1648
1649 /**
1650 * Reverse verify PayPal subscription webhook before processing.
1651 * Doc: https://developer.paypal.com/docs/api/webhooks/v1/#verify-webhook-signature_post
1652 *
1653 * @param array $webhook_data
1654 *
1655 * @throws Exception
1656 */
1657 public function verify_data_from_webhook_subscription( array $webhook_data ) {
1658 $data_token = $this->get_access_token();
1659
1660 $response = wp_remote_post(
1661 $this->api_url . 'v1/notifications/verify-webhook-signature',
1662 array(
1663 'body' => wp_json_encode( $webhook_data ),
1664 'headers' => array(
1665 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token,
1666 'Content-Type' => 'application/json',
1667 ),
1668 'timeout' => 60,
1669 )
1670 );
1671
1672 if ( is_wp_error( $response ) ) {
1673 throw new Exception( $response->get_error_message(), $response->get_error_code() );
1674 }
1675
1676 $verify_result = LP_Helper::json_decode( wp_remote_retrieve_body( $response ), true );
1677 $is_verified = ! empty( $verify_result['verification_status'] )
1678 && 'SUCCESS' === strtoupper( $verify_result['verification_status'] );
1679 if ( ! $is_verified ) {
1680 throw new Exception( __( 'PayPal webhook verification failed.', 'learnpress' ), 400 );
1681 }
1682 }
1683
1684 /**
1685 * Capture PayPal subscription state from webhook data.
1686 *
1687 * This method only handles PayPal webhook payloads whose resource type is
1688 * `subscription`. Other webhook events can still be sent to the same endpoint,
1689 * so non-subscription resources, missing resources, and completed parent orders
1690 * are ignored instead of treated as errors.
1691 *
1692 * For `BILLING.SUBSCRIPTION.ACTIVATED`, the PayPal billing cycle data is used
1693 * to decide whether the LearnPress subscription status should be trial or
1694 * activated. Cancelled, suspended, and expired events are mapped to the matching
1695 * LearnPress subscription statuses. The resolved status is written back into
1696 * `$webhook_data['lp_subscription_status']` for later dispatch.
1697 *
1698 * @param array $webhook_data
1699 *
1700 * @return void
1701 * @throws Exception When the LearnPress order is invalid, the PayPal event type is missing, or no subscription status can be resolved.
1702 * @since 4.3.7
1703 * @version 1.0.0
1704 */
1705 public function capture_subscription_data( array &$webhook_data = [] ) {
1706 parent::normalize_subscription_data( $webhook_data );
1707
1708 $lp_order_id = $webhook_data['lp_order_id'] ?? 0;
1709 $lp_order = learn_press_get_order( $lp_order_id );
1710 if ( ! $lp_order ) {
1711 throw new Exception( __( 'LearnPress order is invalid.', 'learnpress' ), 400 );
1712 }
1713
1714 if ( empty( $webhook_data['resource'] ) || ! is_array( $webhook_data['resource'] ) ) {
1715 return;
1716 }
1717
1718 $resource = $webhook_data['resource'];
1719 $resource_type = $webhook_data['resource_type'] ?? '';
1720 if ( $resource_type !== 'subscription' ) {
1721 return;
1722 }
1723
1724 /*
1725 * If order is completed, skip if is data webhook subscription
1726 * Because if order completed with subscription activated and payment success
1727 * The next for renewal only get via payment, not return subscription
1728 */
1729 if ( $lp_order->is_completed() ) {
1730 throw new Exception( __( 'Ignore lp order completed with subscription activated.', 'learnpress' ), 400 );
1731 }
1732
1733 $event_type = $webhook_data['event_type'] ?? '';
1734 if ( empty( $event_type ) ) {
1735 throw new Exception( 'PayPal subscription event type is invalid.', 400 );
1736 }
1737
1738 $lp_subscription_status = '';
1739 switch ( $event_type ) {
1740 case 'BILLING.SUBSCRIPTION.ACTIVATED':
1741 // Check is trial or active
1742 $billing_info = $resource['billing_info'] ?? false;
1743 if ( ! empty( $billing_info ) ) {
1744 $cycle_executions = $billing_info['cycle_executions'] ?? [];
1745 if ( ! empty( $cycle_executions ) ) {
1746 $trialCycle = null;
1747 $regularCycle = null;
1748 foreach ( $cycle_executions as $cycle ) {
1749 if ( $cycle['tenure_type'] === 'TRIAL' ) {
1750 $trialCycle = $cycle;
1751 } elseif ( $cycle['tenure_type'] === 'REGULAR' ) {
1752 $regularCycle = $cycle;
1753 }
1754 }
1755
1756 // 1. Check if it's the first time in Trial
1757 // If trial is running (remaining > 0)
1758 // Or the trial just completed its last charge (completed == total_cycles) but has not yet transitioned to the next REGULAR cycle
1759 if ( $trialCycle ) {
1760 $trialCycleRemaining = $trialCycle['cycles_remaining'] ?? 0;
1761 $trialCycleCompleted = $trialCycle['cycles_completed'] ?? 0;
1762 $trialCycleTotal = $trialCycle['total_cycles'] ?? 0;
1763
1764 if ( $trialCycleRemaining > 0 ||
1765 ( $trialCycleTotal && $trialCycleCompleted === $trialCycleTotal ) ) {
1766 $lp_subscription_status = LP_Subscription_Manager::STATUS_TRIAL;
1767 }
1768 }
1769
1770 // 2. Check not trial
1771 if ( empty( $lp_subscription_status ) && $regularCycle ) {
1772 $lp_subscription_status = LP_Subscription_Manager::STATUS_ACTIVATED;
1773 // Key tmp for check if payment success and has key will set lp_subscription_status = value of subscription_status_tmp
1774 update_post_meta( $lp_order->get_id(), self::META_SUBSCRIPTION_STATUS_TMP, $lp_subscription_status );
1775 }
1776 }
1777 }
1778 break;
1779 case 'BILLING.SUBSCRIPTION.CANCELLED':
1780 $lp_subscription_status = LP_Subscription_Manager::STATUS_CANCELLED;
1781 break;
1782 case 'BILLING.SUBSCRIPTION.SUSPENDED':
1783 $lp_subscription_status = LP_Subscription_Manager::STATUS_SUSPENDED;
1784 break;
1785 case 'BILLING.SUBSCRIPTION.EXPIRED':
1786 $lp_subscription_status = LP_Subscription_Manager::STATUS_EXPIRED;
1787 break;
1788 default:
1789 $lp_subscription_status = $event_type;
1790 break;
1791 }
1792
1793 if ( empty( $lp_subscription_status ) ) {
1794 error_log( 'Empty subscription status: ' . json_encode( $webhook_data ) );
1795 throw new Exception( __( 'LP PayPal get status from webhook is invalid.', 'learnpress' ), 400 );
1796 }
1797
1798 $webhook_data['lp_subscription_status'] = $lp_subscription_status;
1799
1800 LP_Debug::log_to_comment(
1801 'LP PayPal normalize subscription data' . json_encode( $webhook_data, JSON_UNESCAPED_UNICODE )
1802 );
1803 }
1804
1805 /**
1806 * Capture PayPal setup-fee or renewal payment data.
1807 *
1808 * This method handles `PAYMENT.SALE.COMPLETED` webhook payloads whose resource
1809 * type is `sale`. It verifies that the PayPal billing agreement matches the
1810 * subscription id stored on the LearnPress parent order, stores the successful
1811 * payment payload for first-payment coordination when the parent order is not
1812 * completed yet, and appends normalized amount/currency values to `$webhook_data`.
1813 *
1814 * @param LP_Order $lp_order
1815 * @param array $webhook_data
1816 *
1817 * @return true|void
1818 * @throws Exception When the sale resource is invalid or does not match the stored subscription id.
1819 * @since 4.3.7
1820 * @version 1.0.0
1821 */
1822 public function capturePaymentSetupFeeOrRenewal( $lp_order, array &$webhook_data ) {
1823 $resource_type = $webhook_data['resource_type'] ?? '';
1824 $event_type = $webhook_data['event_type'] ?? '';
1825 if ( 'sale' !== $resource_type || 'PAYMENT.SALE.COMPLETED' !== $event_type ) {
1826 return;
1827 }
1828
1829 if ( empty( $webhook_data['resource'] ) || ! is_array( $webhook_data['resource'] ) ) {
1830 throw new Exception( __( 'PayPal setup fee/renew payment resource is invalid.', 'learnpress' ), 400 );
1831 }
1832
1833 $resource = $webhook_data['resource'];
1834 $billing_agreement_id = $resource['billing_agreement_id'] ?? '';
1835 if ( empty( $billing_agreement_id ) ) {
1836 throw new Exception( __( 'PayPal billing agreement ID is invalid.', 'learnpress' ), 400 );
1837 }
1838
1839 // Verify subscription id receiver sample with subscription id on LP Order
1840 $subscription_id = get_post_meta( $lp_order->get_id(), self::META_SUBSCRIPTION_ID, true );
1841 if ( $billing_agreement_id !== $subscription_id ) {
1842 throw new Exception( __( 'PayPal billing subscription not same with subscription on LP Order.', 'learnpress' ), 400 );
1843 }
1844
1845 // Save payment data to combine with subscription status defined by LP later, not for renewal
1846 if ( ! $lp_order->is_completed() ) {
1847 update_post_meta(
1848 $lp_order->get_id(),
1849 self::META_SUBSCRIPTION_DATA_PAYMENT_SUCCESS,
1850 wp_json_encode( $webhook_data, JSON_UNESCAPED_UNICODE )
1851 );
1852 }
1853
1854 $webhook_data['lp_subscription_amount'] = $resource['amount']['total'] ?? 0;
1855 $webhook_data['lp_subscription_currency'] = $resource['amount']['currency'] ?? '';
1856
1857 // Add note to order
1858 $lp_order->add_note(
1859 sprintf(
1860 'LP Order: %s %s: %s. %s',
1861 sprintf(
1862 '<a href="%s">%s</a>',
1863 $lp_order->get_edit_link(),
1864 $lp_order->get_order_number()
1865 ),
1866 __( 'PayPal payment setup fee/renew success created at', 'learnpress' ),
1867 $webhook_data['create_time'] ?? '',
1868 sprintf(
1869 __( 'Subscription ID: %s', 'learnpress' ),
1870 $subscription_id
1871 )
1872 )
1873 );
1874
1875 return true;
1876 }
1877
1878 /**
1879 * Capture PayPal billing subscription creation webhook.
1880 *
1881 * `BILLING.SUBSCRIPTION.CREATED` is acknowledged by adding an order note and
1882 * returning true so the caller can stop processing. The actual order completion
1883 * and subscription status update happen later when activation/payment webhooks
1884 * are received.
1885 *
1886 * @param LP_Order $lp_order
1887 * @param array $webhook_data
1888 *
1889 * @return bool
1890 * @since 4.3.7
1891 * @version 1.0.0
1892 */
1893 public function capture_billing_subscription_create( $lp_order, array $webhook_data ): bool {
1894 // If billing created, create subscription
1895 $event_type = $webhook_data['event_type'] ?? '';
1896 if ( $event_type === 'BILLING.SUBSCRIPTION.CREATED' ) {
1897 if ( $lp_order ) {
1898 // Add note to order
1899 $lp_order->add_note(
1900 sprintf(
1901 'LP Order: %s %s: %s. %s',
1902 sprintf(
1903 '<a href="%s">%s</a>',
1904 $lp_order->get_edit_link(),
1905 $lp_order->get_order_number()
1906 ),
1907 __( 'PayPal subscription created at', 'learnpress' ),
1908 $webhook_data['create_time'] ?? '',
1909 sprintf(
1910 __( 'Link payment: %s', 'learnpress' ),
1911 $webhook_data['resource']['links'][0]['href'] ?? ''
1912 )
1913 )
1914 );
1915 }
1916
1917 return true;
1918 }
1919
1920 return false;
1921 }
1922
1923 /**
1924 * Build manage-subscription URL shown in LearnPress order/profile context.
1925 *
1926 * @param LP_Order $order
1927 *
1928 * @return string
1929 */
1930 public function get_manage_subscription_url( LP_Order $order ): string {
1931 $subscription_id = get_post_meta( $order->get_id(), self::META_SUBSCRIPTION_ID, true );
1932 if ( empty( $subscription_id ) ) {
1933 return parent::get_manage_subscription_url( $order );
1934 }
1935
1936 $manage_url = trailingslashit( $this->paypal_url ) . 'myaccount/autopay/';
1937
1938 return (string) apply_filters( 'learn-press/paypal/subscription/manage-url', $manage_url, $order, $subscription_id, $this );
1939 }
1940
1941 /**
1942 * Settings form fields for this gateway
1943 *
1944 * @return array
1945 */
1946 public function get_settings(): array {
1947 return Config::instance()->get( $this->id, 'settings/gateway' );
1948 }
1949 }
1950 }
1951