PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.1
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.1
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 4.1.7.3.2 4.2.0 All 139 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.4.1, at inc/gateways/paypal/class-lp-gateway-paypal.php

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