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 +1862 -149 4.1.7 → 4.4.10 View file →
@@ -4,11 +4,14 @@
4 4 *
5 5 * @author ThimPress
6 6 * @package LearnPress/Classes
7 7 * @since 3.0.0
8 - * @version 3.0.1
8 + * @version 3.0.3
9 9 */
10 10
11 +use LearnPress\Helpers\Config;
12 +use LearnPress\Helpers\Singleton;
13 +
11 14 /**
12 15 * Prevent loading this file directly
13 16 */
14 17 defined( 'ABSPATH' ) || exit();
@@ -17,8 +20,10 @@
17 20 /**
18 21 * Class LP_Gateway_Paypal.
19 22 */
20 23 class LP_Gateway_Paypal extends LP_Gateway_Abstract {
24 + use Singleton;
25 +
21 26 /**
22 27 * @var string
23 28 */
24 29 public $id = 'paypal';
@@ -32,13 +37,8 @@
32 37 protected $paypal_payment_live_url = 'https://www.paypal.com/cgi-bin/webscr';
33 38 /**
34 39 * @var null|string
35 40 */
36 - protected $paypal_nvp_api_live_url = 'https://api-3t.paypal.com/nvp';
37 -
38 - /**
39 - * @var null|string
40 - */
41 41 protected $paypal_sandbox_url = 'https://www.sandbox.paypal.com/';
42 42 /**
43 43 * @var null|string
44 44 */
@@ -43,72 +43,69 @@
43 43 * @var null|string
44 44 */
45 45 protected $paypal_payment_sandbox_url = 'https://www.sandbox.paypal.com/cgi-bin/webscr';
46 46 /**
47 - * @var null
47 + * @var string
48 48 */
49 - protected $paypal_nvp_api_sandbox_url = 'https://api-3t.sandbox.paypal.com/nvp';
50 -
49 + protected $api_sandbox_url = 'https://api-m.sandbox.paypal.com/';
51 50 /**
52 51 * @var string
53 52 */
54 - protected $method = '';
55 -
53 + protected $api_live_url = 'https://api-m.paypal.com/';
56 54 /**
55 + * @var string|null
56 + */
57 + protected $api_url = null;
58 + /**
57 59 * @var null
58 60 */
59 61 protected $paypal_url = null;
60 -
61 62 /**
62 63 * @var null
63 64 */
64 65 protected $paypal_payment_url = null;
65 -
66 66 /**
67 67 * @var null
68 68 */
69 - protected $paypal_nvp_api_url = null;
70 -
69 + protected $paypal_email = '';
71 70 /**
72 71 * @var null
73 72 */
74 - protected $paypal_email = '';
75 -
73 + protected $settings = null;
76 74 /**
77 75 * @var null
78 76 */
79 - protected $settings = null;
80 -
77 + protected $client_id = null;
81 78 /**
82 - * @var array
79 + * @var null
83 80 */
84 - //protected $line_items = array();
81 + protected $client_secret = null;
82 + /**
83 + * @var string
84 + */
85 + protected $subscription_webhook_id = '';
85 86
87 + const PAYPAL_TOKEN = 'paypal_token';
88 +
86 89 /**
87 90 * LP_Gateway_Paypal constructor.
88 91 */
89 92 public function __construct() {
90 - $this->id = 'paypal';
91 -
92 93 $this->method_title = esc_html__( 'PayPal', 'learnpress' );
93 - $this->method_description = esc_html__( 'Make payment via Paypal.', 'learnpress' );
94 - $this->icon = '';
94 + $this->method_description = esc_html__( 'Make a payment via Paypal.', 'learnpress' );
95 + $this->icon = LP_PLUGIN_URL . 'assets/images/paypal-logo-preview.png';
95 96
96 97 $this->title = esc_html__( 'PayPal', 'learnpress' );
97 98 $this->description = esc_html__( 'Pay with PayPal', 'learnpress' );
98 99
99 - // get settings
100 - $this->settings = LP_Settings::instance()->get_group( 'paypal', '' );
100 + parent::__construct();
101 101
102 - $this->enabled = $this->settings->get( 'enable', 'no' );
103 -
104 102 $this->init();
105 -
106 - parent::__construct();
107 103 }
108 104
109 105 /**
110 106 * Init.
107 + * @throws Exception
111 108 */
112 109 public function init() {
113 110 if ( $this->is_enabled() ) {
114 111 if ( $this->settings->get( 'paypal_sandbox', 'no' ) === 'no' ) {
@@ -113,45 +110,55 @@
113 110 if ( $this->is_enabled() ) {
114 111 if ( $this->settings->get( 'paypal_sandbox', 'no' ) === 'no' ) {
115 112 $this->paypal_url = $this->paypal_live_url;
116 113 $this->paypal_payment_url = $this->paypal_payment_live_url;
117 - $this->paypal_nvp_api_url = $this->paypal_nvp_api_live_url;
118 114 $this->paypal_email = $this->settings->get( 'paypal_email' );
115 + $this->api_url = $this->api_live_url; // use for PayPal rest api
119 116 } else {
120 117 $this->paypal_url = $this->paypal_sandbox_url;
121 118 $this->paypal_payment_url = $this->paypal_payment_sandbox_url;
122 - $this->paypal_nvp_api_url = $this->paypal_nvp_api_sandbox_url;
123 119 $this->paypal_email = $this->settings->get( 'paypal_sandbox_email' );
120 + $this->api_url = $this->api_sandbox_url; // use for PayPal rest api
124 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;
125 129 }
126 130
127 - add_filter( 'learn-press/payment-gateway/' . $this->id . '/available', array( $this, 'paypal_available' ), 10, 2 );
131 + $this->check_webhook_callback();
128 132 }
129 133
130 134 /**
131 - * Check payment gateway available.
135 + * Check whether PayPal subscription mode is enabled in gateway settings.
132 136 *
133 - * @param bool $default
134 - * @param $payment
135 - *
136 137 * @return bool
137 138 */
138 - public function paypal_available( bool $default, $payment ): bool {
139 - if ( ! $this->is_enabled() ) {
140 - return false;
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;
141 149 }
142 150
143 - // Empty live email and Sandbox mode also disabled
144 - if ( $this->settings->get( 'paypal_sandbox' ) != 'yes' && ! $this->settings->get( 'paypal_email' ) ) {
145 - return false;
151 + $paypal_order_id = LP_Request::get_param( 'token' );
152 + if ( empty( $paypal_order_id ) ) {
153 + return;
146 154 }
147 155
148 - // Enable Sandbox mode but it's email is empty
149 - if ( ! $this->settings->get( 'paypal_sandbox_email' ) && $this->settings->get( 'paypal_sandbox' ) == 'yes' ) {
150 - return false;
156 + try {
157 + $this->capture_payment_for_order( $paypal_order_id );
158 + } catch ( Throwable $e ) {
159 + LP_Debug::error_log( $e );
151 160 }
152 -
153 - return $default;
154 161 }
155 162
156 163 /**
157 164 * https://developer.paypal.com/api/nvp-soap/ipn/IPNImplementation/#link-ipnlistenerrequestresponseflow
@@ -157,10 +164,11 @@
157 164 * https://developer.paypal.com/api/nvp-soap/ipn/IPNImplementation/#link-ipnlistenerrequestresponseflow
158 165 * Check validate IPN.
159 166 *
160 167 * @return bool
168 + * @deprecated 4.3.8
161 169 */
162 - public function validate_ipn():bool {
170 + /*public function validate_ipn(): bool {
163 171 $validate_ipn = array( 'cmd' => '_notify-validate' );
164 172 $validate_ipn += wp_unslash( $_POST );
165 173
166 174 $params = array(
@@ -181,73 +189,35 @@
181 189 error_log( 'Error code paypal validate ipn: ' . $response->get_error_message() );
182 190 }
183 191
184 192 return false;
185 - }
186 -
187 - /**
188 - * Handle a completed payment
189 - *
190 - * @param LP_Order $order
191 - * @param array $request
192 - */
193 - /*protected function payment_status_completed( $order, $request ) {
194 - if ( $order->has_status( 'completed' ) ) {
195 - exit;
196 - }
197 -
198 - if ( 'completed' === $request['payment_status'] ) {
199 - $this->payment_complete( $order, ( ! empty( $request['txn_id'] ) ? $request['txn_id'] : '' ), __( 'IPN payment completed', 'learnpress' ) );
200 - // save paypal fee
201 - if ( ! empty( $request['mc_fee'] ) ) {
202 - update_post_meta( $order->get_id(), '_transaction_fee', $request['mc_fee'] );
203 - }
204 - } else {
205 - }
206 193 }*/
207 194
208 195 /**
209 - * Handle a pending payment
210 - *
211 - * @param LP_Order
212 - * @param Paypal IPN params
213 - */
214 - /*protected function payment_status_pending( $order, $request ) {
215 - $this->payment_status_completed( $order, $request );
216 - }*/
217 -
218 - /**
219 - * @param LP_Order
220 - * @param string $txn_id
221 - * @param string $note - not use
222 - */
223 - /*public function payment_complete( $order, $txn_id = '', $note = '' ) {
224 - $order->payment_complete( $txn_id );
225 - }*/
226 -
227 - /**
228 196 * Handle payment.
229 197 *
230 198 * @param int $order_id
231 199 *
232 200 * @return array
201 + * @throws Exception
233 202 */
234 203 public function process_payment( $order_id = 0 ): array {
235 - $paypal_payment_url = '';
204 + $order = new LP_Order( $order_id );
236 205
237 - try {
238 - $order = new LP_Order( $order_id );
239 - $paypal_args = $this->get_paypal_args( $order );
206 + //$subscription_data = $this->resolve_subscription_payment_data( $order );
240 207
241 - $paypal_payment_url = $this->paypal_url . '?' . http_build_query( $paypal_args );
242 - } catch ( Throwable $e ) {
243 - error_log( $e->getMessage() );
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 );
244 214 }
245 215
246 - return array(
247 - 'result' => ! empty( $paypal_payment_url ) ? 'success' : 'fail',
216 + return [
217 + 'result' => 'success',
248 218 'redirect' => $paypal_payment_url,
249 - );
219 + ];
250 220 }
251 221
252 222 /**
253 223 * Prepare args to send to PayPal
@@ -254,32 +224,27 @@
254 224 *
255 225 * @param LP_Order $order
256 226 *
257 227 * @return array
228 + * @since 3.0.0
229 + * @version 1.0.1
230 + * @deprecated 4.3.8
258 231 */
259 - public function get_paypal_args( LP_Order $order ): array {
260 - $checkout = LP()->checkout();
261 - $custom = array(
232 + /*public function get_paypal_args( LP_Order $order ): array {
233 + $checkout = LearnPress::instance()->checkout();
234 + $custom = array(
262 235 'order_id' => $order->get_id(),
263 236 'order_key' => $order->get_order_key(),
264 237 'checkout_email' => $checkout->get_checkout_email(),
265 238 );
266 -
267 - // Item
268 - $items = LP()->get_cart()->get_items();
269 - $item_arg = [
239 + $lp_cart = LearnPress::instance()->get_cart();
240 + $cart_total = $lp_cart->calculate_totals();
241 + $item_arg = array(
270 242 'item_name_1' => $order->get_order_number(),
271 - 'quantity_1' => 0,
272 - 'amount_1' => 0,
273 - ];
274 - if ( $items ) {
275 - foreach ( $items as $item ) {
276 - $item_arg['quantity_1'] += $item['quantity'];
277 - $item_arg['amount_1'] += $item['total'];
278 - }
279 - }
280 -
281 - $args = array_merge(
243 + 'quantity_1' => 1,
244 + 'amount_1' => $cart_total->total,
245 + );
246 + $args = array_merge(
282 247 array(
283 248 'cmd' => '_cart',
284 249 'business' => $this->paypal_email,
285 250 'no_note' => 1,
@@ -287,9 +252,9 @@
287 252 'charset' => 'utf-8',
288 253 'rm' => is_ssl() ? 2 : 1,
289 254 'upload' => 1,
290 255 'return' => esc_url_raw( $this->get_return_url( $order ) ),
291 - 'cancel_return' => esc_url_raw( learn_press_is_enable_cart() ? learn_press_get_page_link( 'cart' ) : get_home_url() /* SITE_URL */ ),
256 + 'cancel_return' => esc_url_raw( learn_press_is_enable_cart() ? learn_press_get_page_link( 'cart' ) : get_home_url() ),
292 257 'bn' => 'LearnPress_Cart',
293 258 'custom' => json_encode( $custom ),
294 259 'notify_url' => get_home_url() . '/?paypal_notify=1',
295 260 ),
@@ -296,63 +261,1811 @@
296 261 $item_arg
297 262 );
298 263
299 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;
300 325 }
301 326
302 327 /**
303 - * Settings form fields for this gateway
328 + * create args to create PayPal order
304 329 *
330 + * @param LP_Order $order
331 + *
305 332 * @return array
333 + * @since 4.2.4
334 + * @version 1.0.2
306 335 */
307 - public function get_settings() {
308 - return apply_filters(
309 - 'learn-press/gateway-payment/paypal/settings',
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',
310 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',
311 661 array(
312 - 'type' => 'title',
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,
313 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 ),
314 1007 array(
315 - 'title' => esc_html__( 'Enable/Disable', 'learnpress' ),
316 - 'id' => '[enable]',
317 - 'default' => 'no',
318 - 'type' => 'checkbox',
319 - 'desc' => esc_html__( 'Enable PayPal Standard', 'learnpress' ),
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',
320 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 ),
321 1296 array(
322 - 'title' => esc_html__( 'PayPal email', 'learnpress' ),
323 - 'id' => '[paypal_email]',
324 - 'type' => 'text',
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',
325 1343 ),
326 - array(
327 - 'title' => esc_html__( 'Sandbox mode', 'learnpress' ),
328 - 'id' => '[paypal_sandbox]',
329 - 'default' => 'no',
330 - 'type' => 'checkbox',
331 - 'desc' => esc_html__( 'Enable PayPal sandbox', 'learnpress' ),
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',
332 1456 ),
333 - array(
334 - 'title' => esc_html__( 'Sandbox email address', 'learnpress' ),
335 - 'id' => '[paypal_sandbox_email]',
336 - 'type' => 'text',
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',
337 1709 ),
338 - array(
339 - 'type' => 'sectionend',
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()
340 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 + )
341 1914 )
342 1915 );
1916 +
1917 + return true;
343 1918 }
344 1919
345 1920 /**
346 - * Icon for the gateway
1921 + * Capture PayPal billing subscription creation webhook.
347 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 + *
348 1970 * @return string
349 1971 */
350 - public function get_icon() {
351 - if ( empty( $this->icon ) ) {
352 - $this->icon = LP()->plugin_url( 'assets/images/paypal-logo-preview.png' );
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 );
353 1976 }
354 1977
355 - return parent::get_icon();
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' );
356 2069 }
357 2070 }
358 2071 }