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