PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / 3.9.0
Tutor LMS – eLearning and online course solution v3.9.0
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 8 months ago CouponController.php 11 months ago Ecommerce.php 1 year ago EmailController.php 11 months ago HooksHandler.php 9 months ago OptionKeys.php 1 year ago OrderActivitiesController.php 1 year ago OrderController.php 9 months ago PaymentHandler.php 9 months ago Settings.php 9 months ago Tax.php 9 months ago currency.php 1 year ago
CheckoutController.php
1170 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 global $wpdb;
565 $order_data = null;
566 $billing_model = new BillingModel();
567 $current_user_id = is_user_logged_in() ? get_current_user_id() : wp_rand();
568 $request = Input::sanitize_array( $_POST ); //phpcs:ignore --sanitized.
569
570 $billing_fillable_fields = array_intersect_key( $request, array_flip( $billing_model->get_fillable_fields() ) );
571
572 $order_payment_fields = array(
573 'object_ids',
574 'coupon_code',
575 'payment_method',
576 'payment_type',
577 'order_type',
578 );
579
580 $request = array_intersect_key( $request, array_flip( $order_payment_fields ) );
581 // Set required.
582 foreach ( $order_payment_fields as $field ) {
583 if ( ! isset( $request[ $field ] ) ) {
584 $request[ $field ] = '';
585 }
586 }
587
588 // Validate data.
589 $validate = $this->validate_pay_now_req( $request );
590
591 if ( ! $validate->success ) {
592 foreach ( $validate->errors as $error ) {
593 if ( is_array( $error ) ) {
594 foreach ( $error as $err ) {
595 array_push( $errors, $err );
596 }
597 } else {
598 array_push( $errors, $error );
599 }
600 }
601 }
602
603 // Return if validation failed.
604 if ( ! empty( $errors ) ) {
605 set_transient( self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id, $errors );
606 return;
607 }
608
609 $object_ids = array_filter( explode( ',', $request['object_ids'] ), 'is_numeric' );
610 $coupon_code = isset( $request['coupon_code'] ) ? $request['coupon_code'] : '';
611 $payment_method = $request['payment_method'];
612 $payment_type = 'free' === strtolower( $payment_method ) ? 'manual' : $request['payment_type'];
613 $order_type = $request['order_type'];
614
615 if ( empty( $object_ids ) ) {
616 array_push( $errors, __( 'Invalid cart items', 'tutor' ) );
617 }
618
619 $billing_info = $billing_model->get_info( $current_user_id );
620 if ( $billing_info ) {
621 $update_billing = $billing_model->update( $billing_fillable_fields, array( 'user_id' => $current_user_id ) );
622 if ( ! $update_billing ) {
623 array_push( $errors, __( 'Billing information update failed!', 'tutor' ) );
624 }
625 } else {
626 // Save billing info.
627 $billing_fillable_fields['user_id'] = $current_user_id;
628
629 $save = $billing_model->insert( $billing_fillable_fields );
630 if ( ! $save ) {
631 array_push( $errors, __( 'Billing info save failed!', 'tutor' ) );
632 }
633 }
634
635 $checkout_data = $this->prepare_checkout_items( $object_ids, $order_type, $coupon_code );
636
637 if ( $checkout_data->total_price > 0 && 'free' === $payment_method ) {
638 array_push( $errors, __( 'Select a payment method', 'tutor' ) );
639 }
640
641 $items = array();
642 foreach ( $checkout_data->items as $item ) {
643 $items[] = array(
644 'item_id' => $item->item_id,
645 'regular_price' => $item->regular_price,
646 'sale_price' => $item->sale_price,
647 'discount_price' => $item->discount_price,
648 'coupon_code' => $item->is_coupon_applied ? $item->coupon_code : null,
649 );
650 }
651
652 $args = apply_filters(
653 'tutor_order_create_args',
654 array(
655 'payment_method' => $payment_method,
656 'coupon_amount' => $checkout_data->coupon_discount,
657 'discount_amount' => $checkout_data->sale_discount,
658 )
659 );
660
661 if ( empty( $errors ) ) {
662 if ( ! is_user_logged_in() ) {
663 $guest_user = apply_filters( 'tutor_guest_user_id', $current_user_id, $order_data, $billing_fillable_fields );
664 if ( is_wp_error( $guest_user ) ) {
665 // Delete the billing info if user registration failed.
666 QueryHelper::delete( "{$wpdb->prefix}tutor_customers", array( 'user_id' => $current_user_id ) );
667
668 add_filter( 'tutor_checkout_user_id', fn () => $current_user_id );
669
670 // translators: wp error message.
671 $error_msg = sprintf( esc_html_x( 'Order placement failed. %s', 'guest checkout', 'tutor' ), $guest_user->get_error_message() );
672 set_transient(
673 self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id,
674 array(
675 'message' => $error_msg,
676 )
677 );
678 return;
679 } else {
680 $current_user_id = $guest_user;
681 }
682 }
683
684 $order_data = $this->order_ctrl->create_order(
685 $current_user_id,
686 $items,
687 OrderModel::PAYMENT_UNPAID,
688 $order_type,
689 $checkout_data->coupon_code,
690 $args,
691 false
692 );
693
694 if ( ! empty( $order_data ) ) {
695 if ( 'automate' === $payment_type ) {
696 try {
697 $payment_data = self::prepare_payment_data( $order_data );
698 $this->proceed_to_payment( $payment_data, $payment_method, $order_type );
699 } catch ( \Throwable $th ) {
700 tutor_log( $th );
701 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data['id'], $th->getMessage() );
702 }
703 } else {
704 // Set alert message session.
705 $this->set_pay_now_alert_msg( $order_data );
706 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_SUCCESS, $order_data['id'] );
707 }
708 } else {
709 array_push( $errors, __( 'Failed to place order!', 'tutor' ) );
710 set_transient( self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id, $errors );
711 $this->set_pay_now_alert_msg( $order_data );
712 }
713 } else {
714 set_transient( self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id, $errors );
715 $this->set_pay_now_alert_msg( $order_data );
716 }
717 }
718
719 /**
720 * Prepare payment data
721 *
722 * @since 3.0.0
723 *
724 * @param array $order Order object.
725 *
726 * @return mixed
727 */
728 public static function prepare_payment_data( array $order ) {
729 $site_name = get_bloginfo( 'name' );
730 $order_id = $order['id'];
731 $order_user_id = $order['user_id'];
732 $user_data = get_userdata( $order_user_id );
733
734 $items = array();
735 $subtotal_price = $order['subtotal_price'];
736 $total_price = $order['total_price'];
737 $grand_total = $total_price;
738 $order_type = $order['order_type'];
739
740 $currency_code = tutor_utils()->get_option( OptionKeys::CURRENCY_CODE, 'USD' );
741 $currency_symbol = tutor_get_currency_symbol_by_code( $currency_code );
742 $currency_info = tutor_get_currencies_info_by_code( $currency_code );
743
744 $billing_info = ( new BillingModel() )->get_info( $order_user_id );
745
746 $country_info = tutor_get_country_info_by_name( $billing_info->billing_country );
747
748 $country = (object) array(
749 'name' => $country_info['name'],
750 'numeric_code' => $country_info['numeric_code'],
751 'alpha_2' => $country_info['alpha_2'],
752 'alpha_3' => $country_info['alpha_3'],
753 'phone_code' => $country_info['phone_code'],
754 );
755
756 $billing_name = $billing_info ? trim( $billing_info->billing_first_name . ' ' . $billing_info->billing_last_name ) : $user_data->display_name;
757
758 $shipping_and_billing = array(
759 'name' => $billing_name,
760 'address1' => $billing_info->billing_address ?? '',
761 'address2' => $billing_info->billing_address ?? '',
762 'city' => $billing_info->billing_city ?? '',
763 'state' => $billing_info->billing_state ?? '',
764 'region' => '',
765 'postal_code' => $billing_info->billing_zip_code ?? '',
766 'country' => $country,
767 'phone_number' => $billing_info->billing_phone ?? '',
768 'email' => $billing_info->billing_email ?? '',
769 );
770
771 $customer_info = $shipping_and_billing;
772
773 foreach ( $order['items'] as $item ) {
774 $item = (object) $item;
775 $item_id = $item->item_id ?? $item->id;
776
777 if ( OrderModel::TYPE_SINGLE_ORDER === $order_type ) {
778 $items[] = array(
779 'item_id' => $item_id,
780 'item_name' => get_the_title( $item_id ),
781 'regular_price' => $item->sale_price > 0 ? $item->sale_price : $item->regular_price,
782 'quantity' => 1,
783 'discounted_price' => is_null( $item->discount_price ) || '' === $item->discount_price ? null : $item->discount_price,
784 );
785 }
786
787 if ( OrderModel::TYPE_SUBSCRIPTION === $order_type ) {
788 $subscription_items = apply_filters( 'tutor_checkout_subscription_payment_items', array(), $item, $order_id );
789 foreach ( $subscription_items as $subscription_item ) {
790 $items[] = $subscription_item;
791 }
792 }
793 }
794
795 if ( isset( $order['tax_amount'] ) && ! Tax::is_tax_included_in_price() ) {
796 $grand_total += $order['tax_amount'];
797
798 /* translators: %s: tax rate */
799 $tax_item = sprintf( __( 'Tax (%s)', 'tutor' ), $order['tax_rate'] . '%' );
800 $items[] = array(
801 'item_id' => 'tax',
802 'item_name' => $tax_item,
803 'regular_price' => $order['tax_amount'],
804 'quantity' => 1,
805 'discounted_price' => null,
806 );
807 }
808
809 return (object) array(
810 'items' => (object) $items,
811 'subtotal' => floatval( $subtotal_price ),
812 'total_price' => floatval( $total_price ),
813 'order_id' => $order_id,
814 'store_name' => $site_name,
815 'order_description' => 'Tutor Order',
816 'tax' => 0,
817 'currency' => (object) array(
818 'code' => $currency_code,
819 'symbol' => $currency_symbol,
820 'name' => $currency_info['name'] ?? '',
821 'locale' => $currency_info['locale'] ?? '',
822 'numeric_code' => $currency_info['numeric_code'] ?? '',
823 ),
824 'country' => $country,
825 'shipping_charge' => 0,
826 'coupon_discount' => 0,
827 'shipping_address' => (object) $shipping_and_billing,
828 'billing_address' => (object) $shipping_and_billing,
829 'decimal_separator' => tutor_utils()->get_option( OptionKeys::DECIMAL_SEPARATOR, '.' ),
830 'thousand_separator' => tutor_utils()->get_option( OptionKeys::THOUSAND_SEPARATOR, '.' ),
831 'customer' => (object) $customer_info,
832 );
833 }
834
835 /**
836 * Prepare payment data
837 *
838 * @since 3.0.0
839 *
840 * @param int $order_id Order id.
841 *
842 * @throws \Exception Throw exception if order not found.
843 *
844 * @return mixed
845 */
846 public static function prepare_recurring_payment_data( int $order_id ) {
847 $order_data = ( new OrderModel() )->get_order_by_id( $order_id );
848 if ( ! $order_data ) {
849 throw new \Exception( __( 'Order not found!', 'tutor' ) );
850 }
851
852 $amount = $order_data->total_price;
853
854 $order_user_id = $order_data->student->id;
855 $user_data = get_userdata( $order_user_id );
856
857 $currency_code = tutor_utils()->get_option( OptionKeys::CURRENCY_CODE, 'USD' );
858 $currency_symbol = tutor_get_currency_symbol_by_code( $currency_code );
859 $currency_info = tutor_get_currencies_info_by_code( $currency_code );
860
861 $billing_info = ( new BillingModel() )->get_info( $order_user_id );
862
863 $country_info = tutor_get_country_info_by_name( $billing_info->billing_country );
864
865 $country = (object) array(
866 'name' => $country_info['name'],
867 'numeric_code' => $country_info['numeric_code'],
868 'alpha_2' => $country_info['alpha_2'],
869 'alpha_3' => $country_info['alpha_3'],
870 'phone_code' => $country_info['phone_code'],
871 );
872
873 $billing_name = $billing_info ? trim( $billing_info->billing_first_name . ' ' . $billing_info->billing_last_name ) : $user_data->display_name;
874
875 $shipping_and_billing = array(
876 'name' => $billing_name,
877 'address1' => $billing_info->billing_address ?? '',
878 'address2' => $billing_info->billing_address ?? '',
879 'city' => $billing_info->billing_city ?? '',
880 'state' => $billing_info->billing_state ?? '',
881 'region' => '',
882 'postal_code' => $billing_info->billing_zip_code ?? '',
883 'country' => $country,
884 'phone_number' => $billing_info->billing_phone ?? '',
885 'email' => $billing_info->billing_email ?? '',
886 );
887
888 $customer_info = $shipping_and_billing;
889
890 return (object) array(
891 'type' => 'recurring',
892 'previous_payload' => $order_data->payment_payloads,
893 'total_amount' => floatval( $amount ),
894 'sub_total_amount' => floatval( $amount ),
895 'currency' => (object) array(
896 'code' => $currency_code,
897 'symbol' => $currency_symbol,
898 'name' => $currency_info['name'] ?? '',
899 'locale' => $currency_info['locale'] ?? '',
900 'numeric_code' => $currency_info['numeric_code'] ?? '',
901 ),
902 'order_id' => $order_id,
903 'customer' => (object) $customer_info,
904 'shipping_address' => (object) $shipping_and_billing,
905 );
906 }
907
908 /**
909 * Proceed to payment
910 *
911 * @since 3.0.0
912 *
913 * @param mixed $payment_data Payment data for making order.
914 * @param string $payment_method Payment method name.
915 * @param string $order_type Order type.
916 *
917 * @throws \Throwable Throw throwable if error occur.
918 * @throws \Exception Throw exception if payment gateway is invalid.
919 *
920 * @return void
921 */
922 public function proceed_to_payment( $payment_data, $payment_method, $order_type ) {
923 $payment_gateways = apply_filters( 'tutor_gateways_with_class', Ecommerce::payment_gateways_with_ref(), $payment_method );
924
925 $payment_gateway_class = isset( $payment_gateways[ $payment_method ] )
926 ? $payment_gateways[ $payment_method ]['gateway_class']
927 : null;
928
929 if ( $payment_gateway_class ) {
930 try {
931
932 add_filter(
933 'tutor_ecommerce_webhook_url',
934 function ( $url ) use ( $payment_method ) {
935 $url = add_query_arg( array( 'payment_method' => $payment_method ), $url );
936 return $url;
937 }
938 );
939
940 add_filter(
941 'tutor_ecommerce_payment_success_url_args',
942 function ( $args ) use ( $payment_data ) {
943 $args['order_id'] = $payment_data->order_id;
944 return $args;
945 }
946 );
947 add_filter(
948 'tutor_ecommerce_payment_cancelled_url_args',
949 function ( $args ) use ( $payment_data ) {
950 $args['order_id'] = $payment_data->order_id;
951 return $args;
952 }
953 );
954
955 $gateway_instance = Ecommerce::get_payment_gateway_object( $payment_gateway_class );
956 $gateway_instance->setup_payment_and_redirect( $payment_data );
957 } catch ( \Throwable $th ) {
958 throw $th;
959 }
960 } else {
961 throw new \Exception( 'Invalid payment gateway class' );
962 }
963 }
964
965 /**
966 * Restrict checkout page
967 *
968 * @return void
969 */
970 public function restrict_checkout_page() {
971 if ( ! is_page( self::get_page_id() ) ) {
972 return;
973 }
974
975 $cart_page_url = CartController::get_page_url();
976
977 if ( ! is_user_logged_in() && ! GuestCheckout::is_enable() ) {
978 wp_safe_redirect( $cart_page_url );
979 exit;
980 }
981
982 $user_id = tutils()->get_user_id();
983 $cart_model = new CartModel();
984 $has_cart_item = $cart_model->has_item_in_cart( $user_id );
985 $buy_now = Settings::is_buy_now_enabled();
986 $plan_id = Input::get( 'plan', 0, Input::TYPE_INT );
987 $order_id = Input::get( 'order_id', 0, Input::TYPE_INT );
988
989 if ( ! $has_cart_item && ! $buy_now && ! $plan_id && ! $order_id ) {
990 wp_safe_redirect( $cart_page_url );
991 exit;
992 }
993 }
994
995 /**
996 * Set alert message on the session based on
997 * order data
998 *
999 * @since 3.0.0
1000 *
1001 * @param mixed $order_data Order data or null. If order
1002 * data is falsy then failed message will be set.
1003 *
1004 * @return void
1005 */
1006 private function set_pay_now_alert_msg( $order_data ) {
1007 $user_id = $order_data ? $order_data['user_id'] : get_current_user_id();
1008 if ( empty( $order_data ) ) {
1009 set_transient(
1010 self::PAY_NOW_ALERT_MSG_TRANSIENT_KEY . $user_id,
1011 array(
1012 'alert' => 'danger',
1013 'message' => __( 'Failed to place order!', 'tutor' ),
1014 ),
1015 );
1016 } else {
1017 set_transient(
1018 self::PAY_NOW_ALERT_MSG_TRANSIENT_KEY . $user_id,
1019 array(
1020 'alert' => 'success',
1021 'message' => __( 'Your order has been placed successfully!', 'tutor' ),
1022 ),
1023 );
1024 }
1025 }
1026
1027 /**
1028 * Pay for the incomplete order
1029 *
1030 * Redirect to the payment gateway to complete the order
1031 * After completing the process it will redirect user to
1032 * order placement page
1033 *
1034 * @since 3.0.0
1035 *
1036 * @return void
1037 */
1038 public function pay_incomplete_order() {
1039 $order_id = Input::post( 'order_id', 0, Input::TYPE_INT );
1040 $payment_method = Input::post( 'payment_method', '' );
1041 $request = Input::sanitize_array( $_POST ); //phpcs:ignore -- $POST sanitized
1042
1043 $billing_model = new BillingModel();
1044 $billing_fillable_fields = array_intersect_key( $request, array_flip( $billing_model->get_fillable_fields() ) );
1045
1046 if ( ! tutor_utils()->is_nonce_verified() ) {
1047 tutor_utils()->redirect_to( tutor_utils()->tutor_dashboard_url( 'purchase_history' ), tutor_utils()->error_message( 'nonce' ), 'error' );
1048 exit;
1049 }
1050 if ( $order_id ) {
1051 $order_model = new OrderModel();
1052 $order_data = $order_model->get_order_by_id( $order_id );
1053 if ( $order_data ) {
1054 try {
1055
1056 if ( ! empty( $payment_method ) && OrderModel::PAYMENT_METHOD_MANUAL === $order_data->payment_method ) {
1057 $billing_info = $billing_model->get_info( $order_data->user_id );
1058 if ( $billing_info ) {
1059 $update_billing = $billing_model->update( $billing_fillable_fields, array( 'user_id' => $order_data->user_id ) );
1060
1061 if ( ! $update_billing ) {
1062 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data->id, __( 'Billing information update failed!', 'tutor' ) );
1063 }
1064 } else {
1065 // Save billing info.
1066 $billing_fillable_fields['user_id'] = $order_data->user_id;
1067
1068 $save = $billing_model->insert( $billing_fillable_fields );
1069
1070 if ( ! $save ) {
1071 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data->id, __( 'Billing info save failed!', 'tutor' ) );
1072 }
1073 }
1074
1075 $update_order_data = $order_model->get_recalculated_order_tax_data( $order_id );
1076 $update_order_data['payment_method'] = $payment_method;
1077
1078 $updated = $order_model->update_order( $order_data->id, $update_order_data );
1079
1080 if ( $updated ) {
1081 $order_data = $order_model->get_order_by_id( $order_id );
1082 }
1083 }
1084
1085 $payment_data = $this->prepare_payment_data( (array) $order_data, $payment_method ? $payment_method : $order_data->payment_method, $order_data->order_type );
1086 $this->proceed_to_payment( $payment_data, $payment_method ? $payment_method : $order_data->payment_method, $order_data->order_type );
1087 } catch ( \Throwable $th ) {
1088 tutor_log( $th );
1089 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data->id, $th->getMessage() );
1090 }
1091 } else {
1092 $error_msg = __( 'Order not found!', 'tutor' );
1093 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_id, $error_msg );
1094 }
1095 } else {
1096 $error_msg = __( 'Invalid order ID!', 'tutor' );
1097 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_id, $error_msg );
1098 }
1099 }
1100
1101 /**
1102 * Validate pay now request
1103 *
1104 * @since 3.0.0
1105 *
1106 * @param array $data The data array to validate.
1107 *
1108 * @return object The validation result. It returns validation object.
1109 */
1110 protected function validate_pay_now_req( array $data ) {
1111
1112 $order_types = array(
1113 OrderModel::TYPE_SINGLE_ORDER,
1114 OrderModel::TYPE_SUBSCRIPTION,
1115 OrderModel::TYPE_RENEWAL,
1116 );
1117
1118 $order_types = implode( ',', $order_types );
1119
1120 $validation_rules = array(
1121 'object_ids' => 'required',
1122 'order_type' => "required|match_string:{$order_types}",
1123 'payment_method' => 'required',
1124 );
1125
1126 // Skip validation rules for not available fields in data.
1127 foreach ( $validation_rules as $key => $value ) {
1128 if ( ! array_key_exists( $key, $data ) ) {
1129 unset( $validation_rules[ $key ] );
1130 }
1131 }
1132
1133 return ValidationHelper::validate( $validation_rules, $data );
1134 }
1135
1136 /**
1137 * Retrieve course data for a given set of order items.
1138 *
1139 * @since 3.9.0
1140 *
1141 * @param array $order_items Array of order item objects.
1142 * @return array{
1143 * courses: array{
1144 * total_count: int,
1145 * results: \WP_Post[]
1146 * }
1147 * }
1148 */
1149 public function get_courses_data_by_order_items( $order_items ): array {
1150
1151 $results = array();
1152
1153 foreach ( $order_items as $item ) {
1154
1155 $course = get_post( $item->id );
1156
1157 if ( $course instanceof \WP_Post ) {
1158 $results[] = $course;
1159 }
1160 }
1161
1162 return array(
1163 'courses' => array(
1164 'total_count' => count( $results ),
1165 'results' => $results,
1166 ),
1167 );
1168 }
1169 }
1170