PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / trunk
Tutor LMS – eLearning and online course solution vtrunk
3.9.14 3.9.13 3.9.12 3.9.11 trunk 1.0.0 1.0.0-alpha 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.2.0 1.2.1 1.2.11 1.2.12 1.2.13 1.2.20 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.7.5 1.7.6 1.7.7 1.7.8 1.7.9 1.8.0 1.8.1 1.8.10 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.15 1.9.16 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.1.1 2.1.10 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.1.7 2.1.8 2.1.9 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.3.0 2.4.0 2.5.0 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 3.0.0 3.0.1 3.0.2 3.1.0 3.2.0 3.2.1 3.2.2 3.2.3 3.3.0 3.3.1 3.4.0 3.4.1 3.4.2 3.5.0 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.7.0 3.7.1 3.7.2 3.7.3 3.7.4 3.8.0 3.8.1 3.8.2 3.8.3 3.9.0 3.9.1 3.9.10 3.9.2 3.9.3 3.9.4 3.9.5 3.9.6 3.9.7 3.9.8 3.9.9
tutor / ecommerce / CheckoutController.php
tutor / ecommerce Last commit date
Cart 10 months ago PaymentGateways 3 weeks ago AdminMenu.php 9 months ago BillingController.php 1 year ago CartController.php 1 year ago CheckoutController.php 2 months ago CouponController.php 5 months ago Ecommerce.php 1 year ago EmailController.php 11 months ago HooksHandler.php 2 months ago OptionKeys.php 1 year ago OrderActivitiesController.php 1 year ago OrderController.php 6 months ago PaymentHandler.php 9 months ago Settings.php 9 months ago Tax.php 9 months ago currency.php 5 months ago
CheckoutController.php
1237 lines
1 <?php
2 /**
3 * Manage Checkout
4 *
5 * @package Tutor\Ecommerce
6 * @author Themeum
7 * @link https://themeum.com
8 * @since 3.0.0
9 */
10
11 namespace Tutor\Ecommerce;
12
13 use TUTOR\Input;
14 use Tutor\Models\CartModel;
15 use Tutor\Models\OrderModel;
16 use Tutor\Models\CouponModel;
17 use Tutor\Models\CourseModel;
18 use Tutor\Helpers\QueryHelper;
19 use Tutor\Models\BillingModel;
20 use Tutor\Traits\JsonResponse;
21 use Tutor\Helpers\ValidationHelper;
22 use TutorPro\Ecommerce\GuestCheckout\GuestCheckout;
23
24 if ( ! defined( 'ABSPATH' ) ) {
25 exit;
26 }
27
28 /**
29 * Checkout Controller class
30 *
31 * @since 3.0.0
32 */
33 class CheckoutController {
34
35 use JsonResponse;
36
37 /**
38 * Page slug for checkout page
39 *
40 * @since 3.0.0
41 *
42 * @var string
43 */
44 const PAGE_SLUG = 'checkout';
45
46 /**
47 * Page slug for checkout page
48 *
49 * @since 3.0.0
50 *
51 * @var string
52 */
53 const PAGE_ID_OPTION_NAME = 'tutor_checkout_page_id';
54
55 /**
56 * Pay now error transient key
57 *
58 * @since 3.0.0
59 *
60 * @var string
61 */
62 const PAY_NOW_ERROR_TRANSIENT_KEY = 'tutor_pay_now_errors_';
63
64 /**
65 * Pay now alert transient key
66 *
67 * @since 3.0.0
68 *
69 * @var string
70 */
71 const PAY_NOW_ALERT_MSG_TRANSIENT_KEY = 'tutor_pay_now_alert_msg_';
72
73 /**
74 * Coupon model instance.
75 *
76 * @since 3.0.0
77 *
78 * @var CouponModel
79 */
80 public $coupon_model;
81
82 /**
83 * Instance of order controller.
84 *
85 * @since 3.5.0
86 *
87 * @var OrderController
88 */
89 public $order_ctrl;
90
91 /**
92 * Constructor.
93 *
94 * Initializes the Checkout class, sets the page title, and optionally registers
95 * hooks for handling AJAX requests related to cart data, bulk actions, cart updates,
96 * and cart deletions.
97 *
98 * @param bool $register_hooks Whether to register hooks for handling requests. Default is true.
99 *
100 * @since 3.0.0
101 *
102 * @return void
103 */
104 public function __construct( $register_hooks = true ) {
105 $this->coupon_model = new CouponModel();
106 $this->order_ctrl = new OrderController( false );
107
108 if ( $register_hooks ) {
109 add_action( 'tutor_action_tutor_pay_now', array( $this, 'pay_now' ) );
110 add_action( 'tutor_action_tutor_pay_incomplete_order', array( $this, 'pay_incomplete_order' ) );
111 add_action( 'template_redirect', array( $this, 'restrict_checkout_page' ) );
112 add_action( 'wp_ajax_tutor_get_checkout_html', array( $this, 'ajax_get_checkout_html' ) );
113 add_action( 'tutor_before_checkout_order_details', array( $this, 'add_warning_alert' ) );
114 }
115 }
116
117 /**
118 * Get cart page url
119 *
120 * @since 3.0.0
121 *
122 * @return string
123 */
124 public static function get_page_url() {
125 return get_post_permalink( self::get_page_id() );
126 }
127
128 /**
129 * Get cart page ID
130 *
131 * @since 3.0.0
132 *
133 * @return string
134 */
135 public static function get_page_id() {
136 return (int) tutor_utils()->get_option( self::PAGE_ID_OPTION_NAME );
137 }
138
139 /**
140 * Create checkout page
141 *
142 * @since 3.0.0
143 *
144 * @return void
145 */
146 public static function create_checkout_page() {
147 $page_id = self::get_page_id();
148 if ( ! $page_id ) {
149 $args = array(
150 'post_title' => ucfirst( self::PAGE_SLUG ),
151 'post_content' => '',
152 'post_type' => 'page',
153 'post_status' => 'publish',
154 );
155
156 $page_id = wp_insert_post( $args );
157 tutor_utils()->update_option( self::PAGE_ID_OPTION_NAME, $page_id );
158 }
159 }
160
161 /**
162 * Get checkout HTML
163 *
164 * @since 3.0.0
165 *
166 * @return void
167 */
168 public function ajax_get_checkout_html() {
169 tutor_utils()->check_nonce();
170
171 ob_start();
172 tutor_load_template( 'ecommerce/checkout-details' );
173 $content = ob_get_clean();
174
175 $this->json_response(
176 __( 'Success', 'tutor' ),
177 $content
178 );
179 }
180
181 /**
182 * Add warning alert to checkout details.
183 *
184 * @since 3.5.0
185 *
186 * @param array $course_list course list.
187 *
188 * @return void
189 */
190 public function add_warning_alert( $course_list ) {
191 /**
192 * Scenario: Guest checkout and buy now option enabled.
193 * Display a warning alert if the user attempts to purchase a course they are already enrolled in.
194 */
195 $course_id = (int) Input::sanitize_request_data( 'course_id', 0 );
196 if ( Settings::is_buy_now_enabled() && $course_id && tutor_utils()->is_enrolled( $course_id, get_current_user_id() ) ) {
197 add_filter( 'tutor_checkout_enable_pay_now_btn', '__return_false' );
198 ?>
199 <div class="tutor-alert tutor-warning tutor-d-flex tutor-gap-1">
200 <span><?php esc_html_e( 'You\'re already enrolled in this course.', 'tutor' ); ?></span>
201 <a href="<?php echo esc_url( get_the_permalink( $course_id ) ); ?>"><?php esc_html_e( 'Start learning!', 'tutor' ); ?></a>
202 </div>
203 <?php
204 }
205
206 /**
207 * Scenario: user login from the checkout page with courses in the cart.
208 * Display a warning alert if the user tries to purchase a course they are already enrolled in.
209 */
210 if ( ! Settings::is_buy_now_enabled() && is_array( $course_list ) && count( $course_list ) ) {
211 $enrolled_courses = array();
212 foreach ( $course_list as $course ) {
213 if ( tutor_utils()->is_enrolled( $course->ID, get_current_user_id() ) ) {
214 $enrolled_courses[] = $course;
215 }
216 }
217
218 if ( count( $enrolled_courses ) ) {
219 add_filter( 'tutor_checkout_enable_pay_now_btn', '__return_false' );
220 ?>
221 <div class="tutor-alert tutor-warning">
222 <div>
223 <p class="tutor-mb-8">
224 <?php
225 if ( count( $enrolled_courses ) > 1 ) {
226 esc_html_e( 'You are already enrolled in the following courses. Please remove those from your cart and continue.', 'tutor' );
227 } else {
228 esc_html_e( 'You are already enrolled in the following course. Please remove that from your cart and continue.', 'tutor' );
229 }
230 ?>
231 <a class="tutor-text-decoration-none tutor-color-primary" href="<?php echo esc_url( CartController::get_page_url() ); ?>"><?php esc_html_e( 'View Cart', 'tutor' ); ?></a>
232 </p>
233 <ul>
234 <?php foreach ( $enrolled_courses as $course ) : ?>
235 <li><a class="tutor-text-decoration-none tutor-color-primary" href="<?php echo esc_url( get_the_permalink( $course->ID ) ); ?>"><?php echo esc_html( $course->post_title ); ?></a></li>
236 <?php endforeach; ?>
237 </ul>
238 </div>
239 </div>
240 <?php
241 }
242 }
243 }
244
245 /**
246 * Check coupon is applied on checkout item.
247 *
248 * @param array $item checkout item.
249 *
250 * @return boolean applied or not.
251 */
252 private function is_coupon_applied_on_item( array $item ) {
253 return isset( $item['is_coupon_applied'] ) && (bool) $item['is_coupon_applied'];
254 }
255
256 /**
257 * Prepare items
258 *
259 * @since 3.0.0
260 *
261 * @param array $item_ids items.
262 * @param string $order_type order type.
263 * @param object|null $coupon coupon.
264 *
265 * @return array
266 */
267 private function prepare_items( $item_ids, $order_type = OrderModel::TYPE_SINGLE_ORDER, $coupon = null ) {
268 $items = array();
269 $plan_info = null;
270
271 foreach ( $item_ids as $item_id ) {
272
273 if ( OrderModel::TYPE_SINGLE_ORDER === $order_type ) {
274 $item_name = get_the_title( $item_id );
275 $course_price = tutor_utils()->get_raw_course_price( $item_id );
276
277 $regular_price = $course_price->regular_price;
278 $sale_price = $course_price->sale_price;
279
280 $item = array(
281 'item_id' => (int) $item_id,
282 'item_name' => $item_name,
283 'regular_price' => $regular_price,
284 'sale_price' => $sale_price ? $sale_price : null,
285 'is_coupon_applied' => false,
286 'coupon_code' => null,
287 'tax_collection' => CourseModel::is_tax_enabled_for_single_purchase( $item_id ),
288 );
289 }
290
291 if ( OrderModel::TYPE_SUBSCRIPTION === $order_type ) {
292 $item = apply_filters( 'tutor_checkout_subscription_item', array(), $item_id, $coupon );
293 }
294
295 $is_coupon_applicable = false;
296 if ( Settings::is_coupon_usage_enabled() && is_object( $coupon ) ) {
297 $is_coupon_applicable = $this->coupon_model->is_coupon_applicable( $coupon, $item_id, $order_type );
298 if ( $is_coupon_applicable ) {
299 $item['is_coupon_applied'] = $is_coupon_applicable;
300 $item['coupon_code'] = $coupon->coupon_code;
301 $item['sale_price'] = null;
302 }
303 }
304
305 $items[] = $item;
306 }
307
308 return array( $items, $plan_info );
309 }
310
311 /**
312 * Calculate discount.
313 *
314 * @since 3.0.0
315 * @since 3.6.0 refactor and inaccurate flat discount distribution.
316 *
317 * @param array $items item array.
318 * @param string $discount_type discount type. like percentage or fixed.
319 * @param float $discount_value value of discount.
320 *
321 * @return array
322 */
323 public function calculate_discount( $items, $discount_type, $discount_value ) {
324 $final = array();
325 $coupon_applied_items = array();
326 $total_regular_price_coupon_applied = 0;
327
328 foreach ( $items as $item ) {
329 if ( $this->is_coupon_applied_on_item( $item ) ) {
330 $coupon_applied_items[] = $item;
331 $total_regular_price_coupon_applied += $item['regular_price'];
332 } else {
333 $item['discount_amount'] = 0;
334 $final[] = $item;
335 }
336 }
337
338 // For flat discount calculation.
339 $cumulative_discount = 0;
340 $coupon_applied_count = count( $coupon_applied_items );
341
342 foreach ( $coupon_applied_items as $index => $item ) {
343 $regular_price = $item['regular_price'];
344
345 if ( 'percentage' === $discount_type ) {
346 // Limit percentage value between 0 and 100.
347 $percentage = max( 0, min( 100, (float) $discount_value ) );
348 $raw_discount = $regular_price * ( $percentage / 100 );
349 $discount = round( $raw_discount, 2 );
350
351 // Prevent discount from exceeding the item price.
352 $discount = min( $discount, $regular_price );
353
354 $discount_price = round( $regular_price - $discount, 2 );
355
356 $item['discount_amount'] = $discount;
357 $item['discount_price'] = $discount_price;
358
359 } elseif ( 'flat' === $discount_type && $total_regular_price_coupon_applied > 0 ) {
360 /**
361 * Apply a proportional fixed discount
362 * based on the total applied coupon item regular price.
363 */
364 $proportion = $regular_price / $total_regular_price_coupon_applied;
365 $discount = $discount_value * $proportion;
366
367 /**
368 * On last item, fix rounding error.
369 *
370 * Example: $100 discount spread over 3 items
371 * could result in $33.33 + $33.33 + $33.33 = $99.99, losing 1 cent.
372 */
373 if ( $index === $coupon_applied_count - 1 ) {
374 $discount = $discount_value - $cumulative_discount;
375 }
376
377 // Prevent discount from exceeding the item price.
378 $discount = min( $discount, $regular_price );
379 $discount_price = $regular_price - $discount;
380
381 $item['discount_amount'] = round( $discount, 2 );
382 $item['discount_price'] = round( $discount_price, 2 );
383 $cumulative_discount += round( $discount, 2 );
384 }
385
386 $final[] = $item;
387 }
388
389 return $final;
390 }
391
392 /**
393 * Prepare checkout item with applying coupon if required.
394 *
395 * @since 3.0.0
396 *
397 * @since 3.3.0 is_coupon_applicable check added
398 *
399 * @param int|array $item_ids Required, course ids or plan id.
400 * @param string $order_type order type.
401 * @param string $coupon_code coupon code.
402 *
403 * @return object
404 */
405 public function prepare_checkout_items( $item_ids, $order_type = OrderModel::TYPE_SINGLE_ORDER, $coupon_code = null ) {
406 $item_ids = is_array( $item_ids ) ? $item_ids : array( $item_ids );
407 $response = array();
408 $user_id = get_current_user_id();
409
410 $coupon_type = empty( $coupon_code ) ? 'automatic' : 'manual';
411 $is_coupon_applied = false;
412 $coupon_title = '';
413
414 $total_price = 0;
415 $subtotal_price = 0;
416 $coupon_discount = 0;
417 $sale_discount = 0;
418
419 $tax_exempt_price = 0;
420 $tax_exempt_amount = 0;
421
422 $coupon = null;
423 $is_coupon_applied = false;
424 $is_meet_min_requirement = false;
425 $selected_coupon = null;
426
427 if ( Settings::is_coupon_usage_enabled() && '-1' !== $coupon_code ) {
428 $selected_coupon = $this->coupon_model->get_coupon_details_for_checkout( $coupon_code );
429 if ( ! $selected_coupon ) {
430 $this->coupon_model->set_apply_coupon_error( $this->coupon_model->get_coupon_failed_error_msg( 'not_found' ) );
431 }
432 }
433
434 $is_valid = is_object( $selected_coupon ) && $this->coupon_model->is_coupon_valid( $selected_coupon );
435 if ( $is_valid ) {
436 $is_meet_min_requirement = $this->coupon_model->is_coupon_requirement_meet( $item_ids, $selected_coupon, $order_type );
437 if ( $is_meet_min_requirement ) {
438 $coupon = $selected_coupon;
439 }
440 }
441
442 list( $items, $plan_info ) = $this->prepare_items( $item_ids, $order_type, $coupon );
443
444 // Iterate with each item and check if coupon is applicable @since 3.3.0.
445 $is_coupon_applicable = false;
446 if ( $coupon ) {
447 foreach ( $items as $item ) {
448 if ( ! $is_coupon_applicable ) {
449 $is_coupon_applicable = $this->coupon_model->is_coupon_applicable( $coupon, $item['item_id'], $order_type );
450 }
451 }
452 if ( $is_coupon_applicable ) {
453 $is_coupon_applied = true;
454 }
455 }
456
457 if ( $is_coupon_applied ) {
458 $items = $this->calculate_discount( $items, $coupon->discount_type, $coupon->discount_amount );
459 $coupon_title = $coupon->coupon_title;
460 }
461
462 $should_calculate_tax = Tax::should_calculate_tax();
463 $tax_included = Tax::is_tax_included_in_price();
464 $tax_rate = Tax::get_user_tax_rate();
465
466 // Keep calculated price for each item.
467 foreach ( $items as $item ) {
468 $discount_amount = isset( $item['discount_amount'] ) ? $item['discount_amount'] : 0;
469 $has_discount_amount = $discount_amount > 0;
470 $item['discount_price'] = $has_discount_amount ? max( 0, $item['discount_price'] ) : null;
471
472 $display_price = isset( $item['sale_price'] ) ? $item['sale_price'] : $item['regular_price'];
473 $display_price = $has_discount_amount ? $item['discount_price'] : $display_price;
474 $item['display_price'] = $display_price;
475
476 $item['tax_amount'] = 0;
477 $item['tax_amount_readable'] = '';
478
479 if ( $should_calculate_tax ) {
480 $tax_amount = Tax::calculate_tax( $display_price, $tax_rate );
481 // translators: %1$s: tax amount %2$s: included text or empty string.
482 $tax_amount_readable = sprintf( __( 'Tax: %1$s%2$s', 'tutor' ), tutor_get_formatted_price( $tax_amount ), $tax_included ? __( ' included', 'tutor' ) : '' );
483
484 $item['tax_amount'] = $tax_amount;
485 $item['tax_amount_readable'] = $tax_amount_readable;
486 }
487
488 $sale_discount_amount = isset( $item['sale_price'] ) ? $item['regular_price'] - $item['sale_price'] : 0;
489 $item['sale_discount_amount'] = $sale_discount_amount;
490
491 $response['items'][] = (object) $item;
492
493 $subtotal_price += $item['regular_price'];
494 $coupon_discount += $discount_amount;
495 $sale_discount += $sale_discount_amount;
496
497 $additional_items = $item['additional_items'] ?? array();
498 foreach ( $additional_items as $additional_item ) {
499 $subtotal_price += $additional_item['regular_price'] ?? 0;
500 }
501
502 if ( isset( $item['tax_collection'] ) && false === $item['tax_collection'] ) {
503 $tax_exempt_price += $display_price;
504 $tax_exempt_price += array_sum( array_column( $additional_items, 'regular_price' ) );
505 }
506 }
507
508 $total_price = $subtotal_price - ( $coupon_discount + $sale_discount );
509 $tax_amount = 0;
510
511 if ( $should_calculate_tax ) {
512 $tax_amount = Tax::calculate_tax( $total_price, $tax_rate );
513 $tax_exempt_amount = Tax::calculate_tax( $tax_exempt_price, $tax_rate );
514 $tax_amount = $tax_amount - $tax_exempt_amount;
515 }
516
517 $total_price_without_tax = $total_price;
518 if ( ! Tax::is_tax_included_in_price() ) {
519 $total_price += $tax_amount;
520 }
521
522 // Total price should not negative.
523 $total_price = max( 0, $total_price );
524
525 $response['plan_info'] = $plan_info;
526
527 $response['total_items'] = tutor_utils()->count( $items );
528 $response['coupon_type'] = $coupon_type;
529 $response['coupon_code'] = $is_coupon_applied ? $coupon->coupon_code : null;
530 $response['coupon_title'] = $coupon_title;
531 $response['is_coupon_applied'] = $is_coupon_applied;
532
533 $response['subtotal_price'] = $subtotal_price;
534 $response['coupon_discount'] = $coupon_discount;
535 $response['sale_discount'] = $sale_discount;
536 $response['tax_rate'] = $tax_rate;
537 $response['total_price_without_tax'] = $total_price_without_tax;
538 $response['tax_exempt_amount'] = $tax_exempt_amount;
539 $response['tax_amount'] = $tax_amount;
540 $response['total_price'] = $total_price;
541 $response['order_type'] = $order_type;
542
543 $response['formatted_total_price_without_tax'] = tutor_get_formatted_price( $total_price_without_tax );
544 $response['formatted_total_price'] = tutor_get_formatted_price( $total_price );
545
546 return (object) $response;
547 }
548
549 /**
550 * Pay now ajax handler
551 * Create pending order, prepare payment data & proceed to payment gateway
552 *
553 * @since 3.0.0
554 *
555 * @return void
556 */
557 public function pay_now() {
558 $errors = array();
559 if ( ! tutor_utils()->is_nonce_verified() ) {
560 array_push( $errors, tutor_utils()->error_message( 'nonce' ) );
561 set_transient( self::PAY_NOW_ALERT_MSG_TRANSIENT_KEY . 'pay_now_nonce_alert', $errors );
562 return;
563 }
564
565 global $wpdb;
566 $order_data = null;
567 $billing_model = new BillingModel();
568 $current_user_id = get_current_user_id();
569
570 $is_guest_checkout_endabled = class_exists( 'TutorPro\Ecommerce\GuestCheckout\GuestCheckout' ) && GuestCheckout::is_enable();
571
572 // Pevent invalid request.
573 if ( ! $current_user_id ) {
574 if ( $is_guest_checkout_endabled ) {
575 // Guest user.
576 $current_user_id = wp_rand(); // A random id to iniquely indentify.
577 } else {
578 wp_die( esc_html( tutor_utils()->error_message( 'invalid_req' ) ) );
579 }
580 }
581
582 $request = Input::sanitize_array( $_POST ); //phpcs:ignore --sanitized.
583 $order_id = Input::get( 'order_id', 0, Input::TYPE_INT );
584
585 if ( $order_id ) {
586 $order_data = OrderModel::get_valid_incomplete_order( $order_id, get_current_user_id(), true );
587 if ( ! $order_data || OrderModel::TYPE_SINGLE_ORDER !== $order_data->order_type ) {
588 array_push( $errors, __( 'Invalid order', 'tutor' ) );
589 }
590 }
591
592 $billing_fillable_fields = array_intersect_key( $request, array_flip( $billing_model->get_fillable_fields() ) );
593
594 $order_payment_fields = array(
595 'object_ids',
596 'coupon_code',
597 'payment_method',
598 'payment_type',
599 'order_type',
600 );
601
602 $request = array_intersect_key( $request, array_flip( $order_payment_fields ) );
603 // Set required.
604 foreach ( $order_payment_fields as $field ) {
605 if ( ! isset( $request[ $field ] ) ) {
606 $request[ $field ] = '';
607 }
608 }
609
610 // Validate data.
611 $validate = $this->validate_pay_now_req( $request );
612
613 if ( ! $validate->success ) {
614 foreach ( $validate->errors as $error ) {
615 if ( is_array( $error ) ) {
616 foreach ( $error as $err ) {
617 array_push( $errors, $err );
618 }
619 } else {
620 array_push( $errors, $error );
621 }
622 }
623 }
624
625 // Return if validation failed.
626 if ( ! empty( $errors ) ) {
627 set_transient( self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id, $errors );
628 return;
629 }
630
631 $object_ids = array_filter( explode( ',', $request['object_ids'] ), 'is_numeric' );
632 $coupon_code = isset( $request['coupon_code'] ) ? $request['coupon_code'] : '';
633 $payment_method = $request['payment_method'];
634 $payment_type = 'free' === strtolower( $payment_method ) ? 'manual' : $request['payment_type'];
635 $order_type = $request['order_type'];
636
637 if ( empty( $object_ids ) ) {
638 array_push( $errors, __( 'Invalid cart items', 'tutor' ) );
639 } elseif ( OrderModel::TYPE_SINGLE_ORDER === $order_type ) {
640 foreach ( $object_ids as $object_id ) {
641 if ( ! in_array( get_post_type( $object_id ), array( tutor()->course_post_type, tutor()->bundle_post_type ), true ) ) {
642 // translators: %s is the course title.
643 array_push( $errors, sprintf( __( 'Invalid item: %s', 'tutor' ), get_the_title( $object_id ) ) );
644 }
645 }
646 } elseif ( OrderModel::TYPE_SUBSCRIPTION === $order_type ) {
647 $item_id = $object_ids[0] ?? 0;
648 if ( $item_id ) {
649 $plan = apply_filters( 'tutor_get_plan_info', null, $item_id );
650 if ( ! $plan ) {
651 array_push( $errors, __( 'Invalid plan', 'tutor' ) );
652 }
653 } else {
654 array_push( $errors, __( 'Invalid plan', 'tutor' ) );
655 }
656 } else {
657 array_push( $errors, __( 'Invalid order type', 'tutor' ) );
658 }
659
660 if ( ! empty( $errors ) ) {
661 set_transient( self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id, $errors );
662 return;
663 }
664
665 $billing_info = $billing_model->get_info( $current_user_id );
666 if ( $billing_info ) {
667 $update_billing = $billing_model->update( $billing_fillable_fields, array( 'user_id' => $current_user_id ) );
668 if ( ! $update_billing ) {
669 array_push( $errors, __( 'Billing information update failed!', 'tutor' ) );
670 }
671 } else {
672 // Save billing info.
673 $billing_fillable_fields['user_id'] = $current_user_id;
674
675 $save = $billing_model->insert( $billing_fillable_fields );
676 if ( ! $save ) {
677 array_push( $errors, __( 'Billing info save failed!', 'tutor' ) );
678 }
679 }
680
681 $checkout_data = $this->prepare_checkout_items( $object_ids, $order_type, $coupon_code );
682
683 if ( $checkout_data->total_price > 0 && 'free' === $payment_method ) {
684 array_push( $errors, __( 'Select a payment method', 'tutor' ) );
685 }
686
687 $items = array();
688 foreach ( $checkout_data->items as $item ) {
689 $items[] = array(
690 'item_id' => $item->item_id,
691 'regular_price' => $item->regular_price,
692 'sale_price' => $item->sale_price,
693 'discount_price' => $item->discount_price,
694 'coupon_code' => $item->is_coupon_applied ? $item->coupon_code : null,
695 );
696 }
697
698 $args = apply_filters(
699 'tutor_order_create_args',
700 array(
701 'payment_method' => $payment_method,
702 'coupon_amount' => $checkout_data->coupon_discount,
703 'discount_amount' => $checkout_data->sale_discount,
704 )
705 );
706
707 if ( empty( $errors ) ) {
708 if ( ! is_user_logged_in() ) {
709 $guest_user = apply_filters( 'tutor_guest_user_id', $current_user_id, $order_data, $billing_fillable_fields );
710 if ( is_wp_error( $guest_user ) ) {
711 // Delete the billing info if user registration failed.
712 QueryHelper::delete( "{$wpdb->prefix}tutor_customers", array( 'user_id' => $current_user_id ) );
713
714 add_filter( 'tutor_checkout_user_id', fn () => $current_user_id );
715
716 // translators: wp error message.
717 $error_msg = sprintf( esc_html_x( 'Order placement failed. %s', 'guest checkout', 'tutor' ), $guest_user->get_error_message() );
718 set_transient(
719 self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id,
720 array(
721 'message' => $error_msg,
722 )
723 );
724 return;
725 } else {
726 $current_user_id = $guest_user;
727 }
728 }
729
730 if ( ! empty( $order_data ) ) {
731 $order_data = $this->order_ctrl->update_order(
732 $order_id,
733 $current_user_id,
734 $items,
735 OrderModel::PAYMENT_UNPAID,
736 $order_type,
737 $checkout_data->coupon_code,
738 $args,
739 true
740 );
741 } else {
742 $order_data = $this->order_ctrl->create_order(
743 $current_user_id,
744 $items,
745 OrderModel::PAYMENT_UNPAID,
746 $order_type,
747 $checkout_data->coupon_code,
748 $args,
749 false
750 );
751 }
752
753 if ( ! empty( $order_data ) ) {
754 if ( 'automate' === $payment_type ) {
755 try {
756 $payment_data = self::prepare_payment_data( $order_data );
757 $this->proceed_to_payment( $payment_data, $payment_method, $order_type );
758 } catch ( \Throwable $th ) {
759 tutor_log( $th );
760 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data['id'], $th->getMessage() );
761 }
762 } else {
763 // Set alert message session.
764 $this->set_pay_now_alert_msg( $order_data );
765 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_SUCCESS, $order_data['id'] );
766 }
767 } else {
768 array_push( $errors, __( 'Failed to place order!', 'tutor' ) );
769 set_transient( self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id, $errors );
770 $this->set_pay_now_alert_msg( $order_data );
771 }
772 } else {
773 set_transient( self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id, $errors );
774 $this->set_pay_now_alert_msg( $order_data );
775 }
776 }
777
778 /**
779 * Prepare payment data
780 *
781 * @since 3.0.0
782 *
783 * @param array $order Order object.
784 *
785 * @return mixed
786 */
787 public static function prepare_payment_data( array $order ) {
788 $site_name = get_bloginfo( 'name' );
789 $order_id = $order['id'];
790 $order_user_id = $order['user_id'];
791 $user_data = get_userdata( $order_user_id );
792
793 $items = array();
794 $subtotal_price = $order['subtotal_price'];
795 $total_price = $order['total_price'];
796 $grand_total = $total_price;
797 $order_type = $order['order_type'];
798
799 $currency_code = tutor_utils()->get_option( OptionKeys::CURRENCY_CODE, 'USD' );
800 $currency_symbol = tutor_get_currency_symbol_by_code( $currency_code );
801 $currency_info = tutor_get_currencies_info_by_code( $currency_code );
802
803 $billing_info = ( new BillingModel() )->get_info( $order_user_id );
804
805 $country_info = tutor_get_country_info_by_name( $billing_info->billing_country );
806
807 $country = (object) array(
808 'name' => $country_info['name'],
809 'numeric_code' => $country_info['numeric_code'],
810 'alpha_2' => $country_info['alpha_2'],
811 'alpha_3' => $country_info['alpha_3'],
812 'phone_code' => $country_info['phone_code'],
813 );
814
815 $billing_name = $billing_info ? trim( $billing_info->billing_first_name . ' ' . $billing_info->billing_last_name ) : $user_data->display_name;
816
817 $shipping_and_billing = array(
818 'name' => $billing_name,
819 'address1' => $billing_info->billing_address ?? '',
820 'address2' => $billing_info->billing_address ?? '',
821 'city' => $billing_info->billing_city ?? '',
822 'state' => $billing_info->billing_state ?? '',
823 'region' => '',
824 'postal_code' => $billing_info->billing_zip_code ?? '',
825 'country' => $country,
826 'phone_number' => $billing_info->billing_phone ?? '',
827 'email' => $billing_info->billing_email ?? '',
828 );
829
830 $customer_info = $shipping_and_billing;
831
832 foreach ( $order['items'] as $item ) {
833 $item = (object) $item;
834 $item_id = $item->item_id ?? $item->id;
835
836 if ( OrderModel::TYPE_SINGLE_ORDER === $order_type ) {
837 $items[] = array(
838 'item_id' => $item_id,
839 'item_name' => get_the_title( $item_id ),
840 'regular_price' => $item->sale_price > 0 ? $item->sale_price : $item->regular_price,
841 'quantity' => 1,
842 'discounted_price' => is_null( $item->discount_price ) || '' === $item->discount_price ? null : $item->discount_price,
843 );
844 }
845
846 if ( OrderModel::TYPE_SUBSCRIPTION === $order_type ) {
847 $subscription_items = apply_filters( 'tutor_checkout_subscription_payment_items', array(), $item, $order_id );
848 foreach ( $subscription_items as $subscription_item ) {
849 $items[] = $subscription_item;
850 }
851 }
852 }
853
854 if ( isset( $order['tax_amount'] ) && ! Tax::is_tax_included_in_price() ) {
855 $grand_total += $order['tax_amount'];
856
857 /* translators: %s: tax rate */
858 $tax_item = sprintf( __( 'Tax (%s)', 'tutor' ), $order['tax_rate'] . '%' );
859 $items[] = array(
860 'item_id' => 'tax',
861 'item_name' => $tax_item,
862 'regular_price' => $order['tax_amount'],
863 'quantity' => 1,
864 'discounted_price' => null,
865 );
866 }
867
868 return (object) array(
869 'items' => (object) $items,
870 'subtotal' => floatval( $subtotal_price ),
871 'total_price' => floatval( $total_price ),
872 'order_id' => $order_id,
873 'store_name' => $site_name,
874 'order_description' => 'Tutor Order',
875 'tax' => 0,
876 'currency' => (object) array(
877 'code' => $currency_code,
878 'symbol' => $currency_symbol,
879 'name' => $currency_info['name'] ?? '',
880 'locale' => $currency_info['locale'] ?? '',
881 'numeric_code' => $currency_info['numeric_code'] ?? '',
882 ),
883 'country' => $country,
884 'shipping_charge' => 0,
885 'coupon_discount' => 0,
886 'shipping_address' => (object) $shipping_and_billing,
887 'billing_address' => (object) $shipping_and_billing,
888 'decimal_separator' => tutor_utils()->get_option( OptionKeys::DECIMAL_SEPARATOR, '.' ),
889 'thousand_separator' => tutor_utils()->get_option( OptionKeys::THOUSAND_SEPARATOR, '.' ),
890 'customer' => (object) $customer_info,
891 );
892 }
893
894 /**
895 * Prepare payment data
896 *
897 * @since 3.0.0
898 *
899 * @param int $order_id Order id.
900 *
901 * @throws \Exception Throw exception if order not found.
902 *
903 * @return mixed
904 */
905 public static function prepare_recurring_payment_data( int $order_id ) {
906 $order_data = ( new OrderModel() )->get_order_by_id( $order_id );
907 if ( ! $order_data ) {
908 throw new \Exception( __( 'Order not found!', 'tutor' ) );
909 }
910
911 $amount = $order_data->total_price;
912
913 $order_user_id = $order_data->student->id;
914 $user_data = get_userdata( $order_user_id );
915
916 $currency_code = tutor_utils()->get_option( OptionKeys::CURRENCY_CODE, 'USD' );
917 $currency_symbol = tutor_get_currency_symbol_by_code( $currency_code );
918 $currency_info = tutor_get_currencies_info_by_code( $currency_code );
919
920 $billing_info = ( new BillingModel() )->get_info( $order_user_id );
921
922 $country_info = tutor_get_country_info_by_name( $billing_info->billing_country );
923
924 $country = (object) array(
925 'name' => $country_info['name'],
926 'numeric_code' => $country_info['numeric_code'],
927 'alpha_2' => $country_info['alpha_2'],
928 'alpha_3' => $country_info['alpha_3'],
929 'phone_code' => $country_info['phone_code'],
930 );
931
932 $billing_name = $billing_info ? trim( $billing_info->billing_first_name . ' ' . $billing_info->billing_last_name ) : $user_data->display_name;
933
934 $shipping_and_billing = array(
935 'name' => $billing_name,
936 'address1' => $billing_info->billing_address ?? '',
937 'address2' => $billing_info->billing_address ?? '',
938 'city' => $billing_info->billing_city ?? '',
939 'state' => $billing_info->billing_state ?? '',
940 'region' => '',
941 'postal_code' => $billing_info->billing_zip_code ?? '',
942 'country' => $country,
943 'phone_number' => $billing_info->billing_phone ?? '',
944 'email' => $billing_info->billing_email ?? '',
945 );
946
947 $customer_info = $shipping_and_billing;
948
949 return (object) array(
950 'type' => 'recurring',
951 'previous_payload' => $order_data->payment_payloads,
952 'total_amount' => floatval( $amount ),
953 'sub_total_amount' => floatval( $amount ),
954 'currency' => (object) array(
955 'code' => $currency_code,
956 'symbol' => $currency_symbol,
957 'name' => $currency_info['name'] ?? '',
958 'locale' => $currency_info['locale'] ?? '',
959 'numeric_code' => $currency_info['numeric_code'] ?? '',
960 ),
961 'order_id' => $order_id,
962 'customer' => (object) $customer_info,
963 'shipping_address' => (object) $shipping_and_billing,
964 );
965 }
966
967 /**
968 * Proceed to payment
969 *
970 * @since 3.0.0
971 *
972 * @param mixed $payment_data Payment data for making order.
973 * @param string $payment_method Payment method name.
974 * @param string $order_type Order type.
975 *
976 * @throws \Throwable Throw throwable if error occur.
977 * @throws \Exception Throw exception if payment gateway is invalid.
978 *
979 * @return void
980 */
981 public function proceed_to_payment( $payment_data, $payment_method, $order_type ) {
982 $payment_gateways = apply_filters( 'tutor_gateways_with_class', Ecommerce::payment_gateways_with_ref(), $payment_method );
983
984 $payment_gateway_class = isset( $payment_gateways[ $payment_method ] )
985 ? $payment_gateways[ $payment_method ]['gateway_class']
986 : null;
987
988 if ( $payment_gateway_class ) {
989 try {
990
991 add_filter(
992 'tutor_ecommerce_webhook_url',
993 function ( $url ) use ( $payment_method ) {
994 $url = add_query_arg( array( 'payment_method' => $payment_method ), $url );
995 return $url;
996 }
997 );
998
999 add_filter(
1000 'tutor_ecommerce_payment_success_url_args',
1001 function ( $args ) use ( $payment_data ) {
1002 $args['order_id'] = $payment_data->order_id;
1003 return $args;
1004 }
1005 );
1006 add_filter(
1007 'tutor_ecommerce_payment_cancelled_url_args',
1008 function ( $args ) use ( $payment_data ) {
1009 $args['order_id'] = $payment_data->order_id;
1010 return $args;
1011 }
1012 );
1013
1014 $gateway_instance = Ecommerce::get_payment_gateway_object( $payment_gateway_class );
1015 $gateway_instance->setup_payment_and_redirect( $payment_data );
1016 } catch ( \Throwable $th ) {
1017 throw $th;
1018 }
1019 } else {
1020 throw new \Exception( 'Invalid payment gateway class' );
1021 }
1022 }
1023
1024 /**
1025 * Restrict checkout page
1026 *
1027 * @return void
1028 */
1029 public function restrict_checkout_page() {
1030 $page_id = self::get_page_id();
1031 if ( ! $page_id || ! is_page( $page_id ) ) {
1032 return;
1033 }
1034
1035 $cart_page_url = CartController::get_page_url();
1036
1037 if ( ! is_user_logged_in() && ! apply_filters( 'tutor_is_guest_checkout_enabled', false ) ) {
1038 wp_safe_redirect( $cart_page_url );
1039 exit;
1040 }
1041
1042 $user_id = tutils()->get_user_id();
1043 $cart_model = new CartModel();
1044 $has_cart_item = $cart_model->has_item_in_cart( $user_id );
1045 $buy_now = Settings::is_buy_now_enabled();
1046 $plan_id = Input::get( 'plan', 0, Input::TYPE_INT );
1047 $order_id = Input::get( 'order_id', 0, Input::TYPE_INT );
1048
1049 if ( ! $has_cart_item && ! $buy_now && ! $plan_id && ! $order_id ) {
1050 wp_safe_redirect( $cart_page_url );
1051 exit;
1052 }
1053 }
1054
1055 /**
1056 * Set alert message on the session based on
1057 * order data
1058 *
1059 * @since 3.0.0
1060 *
1061 * @param mixed $order_data Order data or null. If order
1062 * data is falsy then failed message will be set.
1063 *
1064 * @return void
1065 */
1066 private function set_pay_now_alert_msg( $order_data ) {
1067 $user_id = $order_data ? $order_data['user_id'] : get_current_user_id();
1068 if ( empty( $order_data ) ) {
1069 set_transient(
1070 self::PAY_NOW_ALERT_MSG_TRANSIENT_KEY . $user_id,
1071 array(
1072 'alert' => 'danger',
1073 'message' => __( 'Failed to place order!', 'tutor' ),
1074 ),
1075 );
1076 } else {
1077 set_transient(
1078 self::PAY_NOW_ALERT_MSG_TRANSIENT_KEY . $user_id,
1079 array(
1080 'alert' => 'success',
1081 'message' => __( 'Your order has been placed successfully!', 'tutor' ),
1082 ),
1083 );
1084 }
1085 }
1086
1087 /**
1088 * Pay for the incomplete order
1089 *
1090 * Redirect to the payment gateway to complete the order
1091 * After completing the process it will redirect user to
1092 * order placement page
1093 *
1094 * @since 3.0.0
1095 *
1096 * @return void
1097 */
1098 public function pay_incomplete_order() {
1099
1100 // Authentication check.
1101 if ( ! is_user_logged_in() ) {
1102 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, 0, __( 'Please log in first', 'tutor' ) );
1103 }
1104
1105 $order_id = Input::post( 'order_id', 0, Input::TYPE_INT );
1106 $payment_method = Input::post( 'payment_method', '' );
1107 $request = Input::sanitize_array( $_POST ); //phpcs:ignore -- $POST sanitized
1108
1109 $billing_model = new BillingModel();
1110 $billing_fillable_fields = array_intersect_key( $request, array_flip( $billing_model->get_fillable_fields() ) );
1111
1112 if ( ! tutor_utils()->is_nonce_verified() ) {
1113 tutor_utils()->redirect_to( tutor_utils()->tutor_dashboard_url( 'purchase_history' ), tutor_utils()->error_message( 'nonce' ), 'error' );
1114 exit;
1115 }
1116
1117 if ( $order_id ) {
1118 $order_model = new OrderModel();
1119 $order_data = $order_model->get_order_by_id( $order_id );
1120 if ( $order_data && get_current_user_id() === (int) $order_data->user_id ) {
1121 try {
1122
1123 if ( ! empty( $payment_method ) && OrderModel::PAYMENT_METHOD_MANUAL === $order_data->payment_method ) {
1124 $billing_info = $billing_model->get_info( $order_data->user_id );
1125 if ( $billing_info ) {
1126 $update_billing = $billing_model->update( $billing_fillable_fields, array( 'user_id' => $order_data->user_id ) );
1127
1128 if ( ! $update_billing ) {
1129 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data->id, __( 'Billing information update failed!', 'tutor' ) );
1130 }
1131 } else {
1132 // Save billing info.
1133 $billing_fillable_fields['user_id'] = $order_data->user_id;
1134
1135 $save = $billing_model->insert( $billing_fillable_fields );
1136
1137 if ( ! $save ) {
1138 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data->id, __( 'Billing info save failed!', 'tutor' ) );
1139 }
1140 }
1141
1142 $update_order_data = $order_model->get_recalculated_order_tax_data( $order_id );
1143 $update_order_data['payment_method'] = $payment_method;
1144
1145 $updated = $order_model->update_order( $order_data->id, $update_order_data );
1146
1147 if ( $updated ) {
1148 $order_data = $order_model->get_order_by_id( $order_id );
1149 }
1150 }
1151
1152 $payment_data = $this->prepare_payment_data( (array) $order_data, $payment_method ? $payment_method : $order_data->payment_method, $order_data->order_type );
1153 $this->proceed_to_payment( $payment_data, $payment_method ? $payment_method : $order_data->payment_method, $order_data->order_type );
1154 } catch ( \Throwable $th ) {
1155 tutor_log( $th );
1156 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data->id, $th->getMessage() );
1157 }
1158 } else {
1159 $error_msg = __( 'Order not found!', 'tutor' );
1160 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_id, $error_msg );
1161 }
1162 } else {
1163 $error_msg = __( 'Invalid order ID!', 'tutor' );
1164 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_id, $error_msg );
1165 }
1166 }
1167
1168 /**
1169 * Validate pay now request
1170 *
1171 * @since 3.0.0
1172 *
1173 * @param array $data The data array to validate.
1174 *
1175 * @return object The validation result. It returns validation object.
1176 */
1177 protected function validate_pay_now_req( array $data ) {
1178
1179 $order_types = array(
1180 OrderModel::TYPE_SINGLE_ORDER,
1181 OrderModel::TYPE_SUBSCRIPTION,
1182 OrderModel::TYPE_RENEWAL,
1183 );
1184
1185 $order_types = implode( ',', $order_types );
1186
1187 $validation_rules = array(
1188 'object_ids' => 'required',
1189 'order_type' => "required|match_string:{$order_types}",
1190 'payment_method' => 'required',
1191 );
1192
1193 // Skip validation rules for not available fields in data.
1194 foreach ( $validation_rules as $key => $value ) {
1195 if ( ! array_key_exists( $key, $data ) ) {
1196 unset( $validation_rules[ $key ] );
1197 }
1198 }
1199
1200 return ValidationHelper::validate( $validation_rules, $data );
1201 }
1202
1203 /**
1204 * Retrieve course data for a given set of order items.
1205 *
1206 * @since 3.9.0
1207 *
1208 * @param array $order_items Array of order item objects.
1209 * @return array{
1210 * courses: array{
1211 * total_count: int,
1212 * results: \WP_Post[]
1213 * }
1214 * }
1215 */
1216 public function get_courses_data_by_order_items( $order_items ): array {
1217
1218 $results = array();
1219
1220 foreach ( $order_items as $item ) {
1221
1222 $course = get_post( $item->id );
1223
1224 if ( $course instanceof \WP_Post ) {
1225 $results[] = $course;
1226 }
1227 }
1228
1229 return array(
1230 'courses' => array(
1231 'total_count' => count( $results ),
1232 'results' => $results,
1233 ),
1234 );
1235 }
1236 }
1237