PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.9
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.9
4.4.9 4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 All 140 releases
learnpress / inc / jwt / rest-api / version1 / class-lp-rest-checkout-v1-controller.php

class-lp-rest-checkout-v1-controller.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.4.9, at inc/jwt/rest-api/version1/class-lp-rest-checkout-v1-controller.php

518 lines 17.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 use LearnPress\Models\UserItems\UserCourseModel;
4
5 /**
6 * REST API for checkout / payment methods.
7 *
8 * @package LearnPress/JWT/RESTAPI
9 */
10 class LP_Jwt_Checkout_V1_Controller extends LP_REST_Jwt_Controller {
11 protected $namespace = 'learnpress/v1';
12
13 protected $rest_base = 'checkout';
14
15 public function register_routes() {
16 register_rest_route(
17 $this->namespace,
18 '/payment-methods',
19 array(
20 array(
21 'methods' => WP_REST_Server::READABLE,
22 'callback' => array( $this, 'list_payment_methods' ),
23 'permission_callback' => '__return_true',
24 ),
25 )
26 );
27
28 register_rest_route(
29 $this->namespace,
30 '/' . $this->rest_base,
31 array(
32 array(
33 'methods' => WP_REST_Server::CREATABLE,
34 'callback' => array( $this, 'process_checkout' ),
35 'permission_callback' => array( $this, 'checkout_permissions_check' ),
36 'args' => array(
37 'course_id' => array(
38 'required' => true,
39 'type' => 'integer',
40 ),
41 'payment_method' => array(
42 'required' => false,
43 'type' => 'string',
44 ),
45 'notes' => array(
46 'required' => false,
47 'type' => 'string',
48 ),
49 ),
50 ),
51 )
52 );
53
54 register_rest_route(
55 $this->namespace,
56 '/' . $this->rest_base . '/paypal/create-order',
57 array(
58 array(
59 'methods' => WP_REST_Server::CREATABLE,
60 'callback' => array( $this, 'paypal_create_order' ),
61 'permission_callback' => array( $this, 'checkout_permissions_check' ),
62 'args' => array(
63 'course_id' => array(
64 'required' => true,
65 'type' => 'integer',
66 ),
67 'notes' => array(
68 'required' => false,
69 'type' => 'string',
70 ),
71 ),
72 ),
73 )
74 );
75
76 register_rest_route(
77 $this->namespace,
78 '/' . $this->rest_base . '/paypal/capture',
79 array(
80 array(
81 'methods' => WP_REST_Server::CREATABLE,
82 'callback' => array( $this, 'paypal_capture' ),
83 'permission_callback' => array( $this, 'checkout_permissions_check' ),
84 'args' => array(
85 'paypal_order_id' => array(
86 'required' => true,
87 'type' => 'string',
88 ),
89 ),
90 ),
91 )
92 );
93 }
94
95 public function checkout_permissions_check( $request ) {
96 if ( ! is_user_logged_in() ) {
97 return new WP_Error(
98 'rest_forbidden',
99 esc_html__( 'You must be logged in to checkout.', 'learnpress' ),
100 array( 'status' => 401 )
101 );
102 }
103
104 return true;
105 }
106
107 /**
108 * List all enabled payment gateways.
109 */
110 public function list_payment_methods( $request ) {
111 $response = new LP_REST_Response();
112 $gateways = LP_Gateways::instance()->get_available_payment_gateways();
113
114 $methods = array();
115 foreach ( $gateways as $gateway ) {
116 if ( ! is_object( $gateway ) ) {
117 continue;
118 }
119
120 $method = array(
121 'id' => $gateway->get_id(),
122 'title' => $gateway->title ? wp_strip_all_tags( $gateway->title ) : $gateway->get_method_title(),
123 'description' => $gateway->description ? wp_strip_all_tags( $gateway->description ) : $gateway->get_method_description(),
124 'icon' => esc_url_raw( (string) $gateway->icon ),
125 'is_default' => (bool) ( $gateway->is_selected ?? false ),
126 'config' => new stdClass(),
127 );
128
129 // Expose PayPal env so the frontend can render Smart Buttons via @paypal/react-paypal-js.
130 if ( $gateway->get_id() === 'paypal' ) {
131 $paypal_settings = LP_Settings::instance()->get_group( 'paypal' );
132 $method['config'] = array(
133 'client_id' => (string) $paypal_settings->get( 'app_client_id', '' ),
134 'sandbox' => $paypal_settings->get( 'paypal_sandbox', 'no' ) === 'yes',
135 'currency' => learn_press_get_currency(),
136 );
137 }
138
139 $methods[] = $method;
140 }
141
142 $response->status = 'success';
143 $response->data = $methods;
144
145 return rest_ensure_response( $response );
146 }
147
148 /**
149 * Create an order for the given course + payment method.
150 *
151 * Body: { course_id, payment_method?, notes? }
152 * Returns: { status, message, data: { order_id, redirect } }
153 */
154 public function process_checkout( $request ) {
155 $response = new LP_REST_Response();
156
157 try {
158 $course_id = absint( $request['course_id'] );
159 $payment_method_str = isset( $request['payment_method'] ) ? sanitize_text_field( $request['payment_method'] ) : '';
160 $notes = isset( $request['notes'] ) ? sanitize_textarea_field( $request['notes'] ) : '';
161
162 if ( ! $course_id ) {
163 throw new Exception( esc_html__( 'Missing course_id.', 'learnpress' ) );
164 }
165
166 $course = learn_press_get_course( $course_id );
167 if ( ! $course ) {
168 throw new Exception( esc_html__( 'Invalid course.', 'learnpress' ) );
169 }
170
171 $user_id = get_current_user_id();
172
173 // Check if already enrolled.
174 $userCourse = UserCourseModel::find( $user_id, $course_id, true );
175 if ( $userCourse && $userCourse->get_status() ) {
176 throw new Exception( esc_html__( 'You are already enrolled in this course.', 'learnpress' ) );
177 }
178
179 $cart = LearnPress::instance()->cart;
180 $checkout = LP_Checkout::instance();
181
182 // Single-course checkout — reset cart to avoid stale items.
183 $cart->empty_cart();
184 $cart_id = $cart->add_to_cart( $course_id, 1, array() );
185 if ( ! $cart_id ) {
186 throw new Exception( esc_html__( 'Could not add the course to the cart.', 'learnpress' ) );
187 }
188
189 $checkout->payment_method_str = $payment_method_str;
190 $checkout->order_comment = $notes;
191
192 $needs_payment = $cart->needs_payment();
193
194 if ( $needs_payment ) {
195 if ( ! $payment_method_str ) {
196 throw new Exception( esc_html__( 'No payment method selected.', 'learnpress' ) );
197 }
198
199 $available = LP_Gateways::instance()->get_available_payment_gateways();
200 if ( ! isset( $available[ $payment_method_str ] ) ) {
201 throw new Exception( esc_html__( 'Invalid payment method.', 'learnpress' ) );
202 }
203
204 $checkout->payment_method = $available[ $payment_method_str ];
205 }
206
207 // Match LP core's pattern: reuse the pending order stashed in the LP
208 // session (`order_awaiting_payment`) to avoid creating orphans on retry.
209 $order_id = $this->reuse_or_create_order( $checkout, $course_id );
210
211 $redirect = '';
212 $requires_redirect = false;
213 $payment_label = '';
214
215 if ( $checkout->payment_method instanceof LP_Gateway_Abstract ) {
216 $payment_label = $checkout->payment_method->get_title();
217 $payment_result = $checkout->payment_method->process_payment( $order_id );
218
219 // Order status after process_payment: PENDING means the gateway needs an
220 // external redirect (e.g. PayPal/Stripe) to actually take payment.
221 // PROCESSING / COMPLETED means the gateway settled the order in our DB
222 // (e.g. offline, sandbox) and no external redirect is necessary.
223 $order_after = new LP_Order( $order_id );
224 $requires_redirect = ( $order_after->get_status() === 'pending' );
225
226 if ( $requires_redirect && ! empty( $payment_result['redirect'] ) ) {
227 $redirect = $payment_result['redirect'];
228 }
229 } else {
230 // Free course — complete the order directly. No external redirect.
231 $order_free = new LP_Order( $order_id );
232 $order_free->payment_complete();
233 $payment_label = esc_html__( 'Free', 'learnpress' );
234 }
235
236 $cart->empty_cart();
237 // Order has been settled (free/offline/sandbox). Drop the session pointer
238 // so the next checkout starts a fresh order. External redirects (PayPal
239 // browser flow) keep it so a retry can reuse the still-pending order.
240 if ( ! $requires_redirect ) {
241 $this->clear_awaiting_session();
242 }
243
244 $order = new LP_Order( $order_id );
245
246 $response->status = 'success';
247 $response->message = esc_html__( 'Order created.', 'learnpress' );
248 $response->data = new stdClass();
249 $response->data->order_id = $order_id;
250 $response->data->redirect = $redirect;
251 $response->data->requires_redirect = $requires_redirect;
252 $response->data->order = $this->build_order_summary( $order, $payment_label );
253 } catch ( Throwable $e ) {
254 $response->status = 'error';
255 $response->message = $e->getMessage();
256 }
257
258 return rest_ensure_response( $response );
259 }
260
261 /**
262 * POST /checkout/paypal/create-order
263 * Creates a pending LP order + a PayPal v2 order, returns the PayPal order id for SDK use.
264 */
265 public function paypal_create_order( $request ) {
266 $response = new LP_REST_Response();
267
268 try {
269 $course_id = absint( $request['course_id'] );
270 $notes = isset( $request['notes'] ) ? sanitize_textarea_field( $request['notes'] ) : '';
271
272 if ( ! $course_id ) {
273 throw new Exception( esc_html__( 'Missing course_id.', 'learnpress' ) );
274 }
275
276 $course = learn_press_get_course( $course_id );
277 if ( ! $course ) {
278 throw new Exception( esc_html__( 'Invalid course.', 'learnpress' ) );
279 }
280
281 $user_id = get_current_user_id();
282 $userCourse = UserCourseModel::find( $user_id, $course_id, true );
283 if ( $userCourse && $userCourse->get_status() ) {
284 throw new Exception( esc_html__( 'You are already enrolled in this course.', 'learnpress' ) );
285 }
286
287 $available = LP_Gateways::instance()->get_available_payment_gateways();
288 if ( ! isset( $available['paypal'] ) ) {
289 throw new Exception( esc_html__( 'PayPal is not enabled.', 'learnpress' ) );
290 }
291 $paypal_gateway = $available['paypal'];
292
293 $cart = LearnPress::instance()->cart;
294 $checkout = LP_Checkout::instance();
295
296 $cart->empty_cart();
297 $cart_id = $cart->add_to_cart( $course_id, 1, array() );
298 if ( ! $cart_id ) {
299 throw new Exception( esc_html__( 'Could not add the course to the cart.', 'learnpress' ) );
300 }
301
302 $checkout->payment_method_str = 'paypal';
303 $checkout->order_comment = $notes;
304 $checkout->payment_method = $paypal_gateway;
305
306 // Same session-based reuse pattern as LP_Checkout::process_checkout.
307 $lp_order_id = $this->reuse_or_create_order( $checkout, $course_id );
308 $lp_order = new LP_Order( $lp_order_id );
309
310 // Create PayPal order via v2 API directly so we can read the id (for SDK).
311 $data_token = $paypal_gateway->get_access_token();
312 if ( ! isset( $data_token->access_token ) || ! isset( $data_token->token_type ) ) {
313 throw new Exception( esc_html__( 'Invalid PayPal access token.', 'learnpress' ) );
314 }
315
316 $params = $paypal_gateway->get_order_args( $lp_order );
317
318 $paypal_response = wp_remote_post(
319 $paypal_gateway->api_url . 'v2/checkout/orders',
320 array(
321 'body' => json_encode( $params ),
322 'headers' => array(
323 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token,
324 'Content-Type' => 'application/json',
325 ),
326 'timeout' => 60,
327 )
328 );
329
330 $paypal_result = LP_Helper::json_decode( wp_remote_retrieve_body( $paypal_response ) );
331
332 if ( isset( $paypal_result->error ) ) {
333 throw new Exception( $paypal_result->error_description ?? 'PayPal error' );
334 }
335 if ( isset( $paypal_result->name ) && isset( $paypal_result->details[0] ) ) {
336 throw new Exception( $paypal_result->details[0]->description );
337 }
338 if ( empty( $paypal_result->id ) ) {
339 throw new Exception( esc_html__( 'Invalid PayPal order response.', 'learnpress' ) );
340 }
341
342 $cart->empty_cart();
343
344 $response->status = 'success';
345 $response->data = new stdClass();
346 $response->data->lp_order_id = $lp_order_id;
347 $response->data->paypal_order_id = $paypal_result->id;
348 } catch ( Throwable $e ) {
349 $response->status = 'error';
350 $response->message = $e->getMessage();
351 }
352
353 return rest_ensure_response( $response );
354 }
355
356 /**
357 * POST /checkout/paypal/capture
358 * Captures an authorized PayPal order and marks the matching LP order completed.
359 */
360 public function paypal_capture( $request ) {
361 $response = new LP_REST_Response();
362
363 try {
364 $paypal_order_id = sanitize_text_field( $request['paypal_order_id'] );
365 if ( ! $paypal_order_id ) {
366 throw new Exception( esc_html__( 'Missing paypal_order_id.', 'learnpress' ) );
367 }
368
369 $available = LP_Gateways::instance()->get_available_payment_gateways();
370 if ( ! isset( $available['paypal'] ) ) {
371 throw new Exception( esc_html__( 'PayPal is not enabled.', 'learnpress' ) );
372 }
373 $paypal_gateway = $available['paypal'];
374
375 $data_token_str = LP_Settings::get_option( 'paypal_token' );
376 $data_token = json_decode( $data_token_str );
377 if ( ! isset( $data_token->access_token ) || ! isset( $data_token->token_type ) ) {
378 $data_token = $paypal_gateway->get_access_token();
379 }
380
381 $capture_response = wp_remote_post(
382 $paypal_gateway->api_url . 'v2/checkout/orders/' . $paypal_order_id . '/capture',
383 array(
384 'headers' => array(
385 'Content-Type' => 'application/json',
386 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token,
387 ),
388 'timeout' => 60,
389 )
390 );
391
392 $code = wp_remote_retrieve_response_code( $capture_response );
393 $body = wp_remote_retrieve_body( $capture_response );
394 $transaction = LP_Helper::json_decode( $body );
395
396 if ( $code !== 201 || ! isset( $transaction->status ) || $transaction->status !== 'COMPLETED' ) {
397 $msg = isset( $transaction->details[0]->description )
398 ? $transaction->details[0]->description
399 : esc_html__( 'Could not capture PayPal payment.', 'learnpress' );
400 throw new Exception( $msg );
401 }
402
403 $lp_order_id = 0;
404 if ( isset( $transaction->purchase_units[0]->payments->captures[0]->custom_id ) ) {
405 $lp_order_id = absint( $transaction->purchase_units[0]->payments->captures[0]->custom_id );
406 }
407 if ( ! $lp_order_id ) {
408 throw new Exception( esc_html__( 'Could not resolve order.', 'learnpress' ) );
409 }
410
411 $lp_order = new LP_Order( $lp_order_id );
412
413 // Defense in depth: only allow the LP order's owner to finalize it.
414 if ( (int) $lp_order->get_user_id() !== get_current_user_id() ) {
415 throw new Exception( esc_html__( 'You are not allowed to capture this order.', 'learnpress' ) );
416 }
417
418 $lp_order->update_status( LP_ORDER_COMPLETED );
419 $this->clear_awaiting_session();
420
421 $response->status = 'success';
422 $response->message = esc_html__( 'Payment captured.', 'learnpress' );
423 $response->data = new stdClass();
424 $response->data->order = $this->build_order_summary( $lp_order, $paypal_gateway->get_title() );
425 } catch ( Throwable $e ) {
426 $response->status = 'error';
427 $response->message = $e->getMessage();
428 }
429
430 return rest_ensure_response( $response );
431 }
432
433 /**
434 * Per-user meta key for tracking the in-flight LP order awaiting payment.
435 *
436 * LP core uses LP_Session for this. In a JWT/headless context the session
437 * isn't reliable (it inits at plugins_loaded before JWT auth fires, and the
438 * proxy strips cookies), so we use user_meta instead — same semantics,
439 * scoped per user, persistent across requests.
440 */
441 private const AWAITING_META_KEY = '_lp_jwt_order_awaiting_payment';
442
443 private function reuse_or_create_order( LP_Checkout $checkout, int $course_id ): int {
444 $user_id = get_current_user_id();
445
446 $existing_id = (int) get_user_meta( $user_id, self::AWAITING_META_KEY, true );
447 if ( $existing_id ) {
448 $existing = learn_press_get_order( $existing_id );
449 if ( $existing && $existing->has_status( array( 'pending', 'failed', 'cancelled' ) ) ) {
450 $contains_course = false;
451 foreach ( (array) $existing->get_items() as $oi ) {
452 if ( isset( $oi['course_id'] ) && (int) $oi['course_id'] === $course_id ) {
453 $contains_course = true;
454 break;
455 }
456 }
457 if ( $contains_course ) {
458 // User may have switched payment method between attempts — sync it.
459 if ( $checkout->payment_method instanceof LP_Gateway_Abstract ) {
460 $existing->set_data( 'payment_method', $checkout->payment_method->get_id() );
461 $existing->set_data( 'payment_method_title', $checkout->payment_method->get_title() );
462 $existing->save();
463 }
464 return $existing_id;
465 }
466 }
467 // Stale — clear so a fresh order is created.
468 delete_user_meta( $user_id, self::AWAITING_META_KEY );
469 }
470
471 $new_id = $checkout->create_order();
472 if ( is_wp_error( $new_id ) ) {
473 throw new Exception( $new_id->get_error_message() );
474 }
475 update_user_meta( $user_id, self::AWAITING_META_KEY, $new_id );
476 return $new_id;
477 }
478
479 /**
480 * Clear the awaiting-payment marker once an order has been settled.
481 */
482 private function clear_awaiting_session(): void {
483 $user_id = get_current_user_id();
484 if ( $user_id ) {
485 delete_user_meta( $user_id, self::AWAITING_META_KEY );
486 }
487 }
488
489 /**
490 * Build the order summary payload shared by /checkout and /checkout/paypal/capture.
491 */
492 private function build_order_summary( LP_Order $order, string $payment_label ): array {
493 $status = $order->get_status();
494 $items_raw = $order->get_items();
495 $items_data = array();
496 foreach ( (array) $items_raw as $oi ) {
497 $items_data[] = array(
498 'name' => isset( $oi['name'] ) ? wp_strip_all_tags( (string) $oi['name'] ) : '',
499 'course_id' => isset( $oi['course_id'] ) ? absint( $oi['course_id'] ) : 0,
500 'total' => isset( $oi['total'] ) ? (float) $oi['total'] : 0,
501 );
502 }
503
504 return array(
505 'id' => $order->get_id(),
506 'number' => $order->get_order_number(),
507 'status' => $status,
508 'status_label' => LP_Order::get_status_label( $status ),
509 'total' => (float) $order->get_total(),
510 'total_formatted' => $order->get_formatted_order_total(),
511 'subtotal' => (float) $order->get_subtotal(),
512 'created_at' => $order->get_order_date( 'c' ),
513 'payment_method' => $payment_label,
514 'items' => $items_data,
515 );
516 }
517 }
518