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