PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / 3.6.4
Tutor LMS – eLearning and online course solution v3.6.4
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 1 year ago PaymentGateways 1 year ago AdminMenu.php 1 year ago BillingController.php 1 year ago CartController.php 1 year ago CheckoutController.php 1 year ago CouponController.php 1 year ago Ecommerce.php 1 year ago EmailController.php 1 year ago HooksHandler.php 1 year ago OptionKeys.php 1 year ago OrderActivitiesController.php 1 year ago OrderController.php 1 year ago PaymentHandler.php 1 year ago Settings.php 1 year ago Tax.php 1 year ago currency.php 1 year ago
CheckoutController.php
1100 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\Helpers\QueryHelper;
14 use Tutor\Helpers\ValidationHelper;
15 use TUTOR\Input;
16 use Tutor\Models\BillingModel;
17 use Tutor\Traits\JsonResponse;
18 use Tutor\Models\CartModel;
19 use Tutor\Models\CouponModel;
20 use Tutor\Models\OrderMetaModel;
21 use Tutor\Models\OrderModel;
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 );
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 $coupon = null;
419 $is_coupon_applied = false;
420 $is_meet_min_requirement = false;
421 $selected_coupon = null;
422
423 if ( Settings::is_coupon_usage_enabled() && '-1' !== $coupon_code ) {
424 $selected_coupon = $this->coupon_model->get_coupon_details_for_checkout( $coupon_code );
425 if ( ! $selected_coupon ) {
426 $this->coupon_model->set_apply_coupon_error( $this->coupon_model->get_coupon_failed_error_msg( 'not_found' ) );
427 }
428 }
429
430 $is_valid = is_object( $selected_coupon ) && $this->coupon_model->is_coupon_valid( $selected_coupon );
431 if ( $is_valid ) {
432 $is_meet_min_requirement = $this->coupon_model->is_coupon_requirement_meet( $item_ids, $selected_coupon, $order_type );
433 if ( $is_meet_min_requirement ) {
434 $coupon = $selected_coupon;
435 }
436 }
437
438 list( $items, $plan_info ) = $this->prepare_items( $item_ids, $order_type, $coupon );
439
440 // Iterate with each item and check if coupon is applicable @since 3.3.0.
441 $is_coupon_applicable = false;
442 if ( $coupon ) {
443 foreach ( $items as $item ) {
444 if ( ! $is_coupon_applicable ) {
445 $is_coupon_applicable = $this->coupon_model->is_coupon_applicable( $coupon, $item['item_id'], $order_type );
446 }
447 }
448 if ( $is_coupon_applicable ) {
449 $is_coupon_applied = true;
450 }
451 }
452
453 if ( $is_coupon_applied ) {
454 $items = $this->calculate_discount( $items, $coupon->discount_type, $coupon->discount_amount );
455 $coupon_title = $coupon->coupon_title;
456 }
457
458 // Keep calculated price for each item.
459 foreach ( $items as $item ) {
460 $discount_amount = isset( $item['discount_amount'] ) ? $item['discount_amount'] : 0;
461 $has_discount_amount = $discount_amount > 0;
462 $item['discount_price'] = $has_discount_amount ? max( 0, $item['discount_price'] ) : null;
463
464 $display_price = isset( $item['sale_price'] ) ? $item['sale_price'] : $item['regular_price'];
465 $display_price = $has_discount_amount ? $item['discount_price'] : $display_price;
466 $item['display_price'] = $display_price;
467
468 $sale_discount_amount = isset( $item['sale_price'] ) ? $item['regular_price'] - $item['sale_price'] : 0;
469 $item['sale_discount_amount'] = $sale_discount_amount;
470
471 $response['items'][] = (object) $item;
472
473 $subtotal_price += $item['regular_price'];
474 $coupon_discount += $discount_amount;
475 $sale_discount += $sale_discount_amount;
476
477 $additional_items = $item['additional_items'] ?? array();
478 foreach ( $additional_items as $additional_item ) {
479 $subtotal_price += $additional_item['regular_price'] ?? 0;
480 }
481 }
482
483 $total_price = $subtotal_price - ( $coupon_discount + $sale_discount );
484 $tax_rate = Tax::get_user_tax_rate();
485 $tax_amount = Tax::calculate_tax( $total_price, $tax_rate );
486
487 $total_price_without_tax = $total_price;
488 if ( ! Tax::is_tax_included_in_price() ) {
489 $total_price += $tax_amount;
490 }
491
492 // Total price should not negative.
493 $total_price = max( 0, $total_price );
494
495 $response['plan_info'] = $plan_info;
496
497 $response['coupon_type'] = $coupon_type;
498 $response['coupon_code'] = $is_coupon_applied ? $coupon->coupon_code : null;
499 $response['coupon_title'] = $coupon_title;
500 $response['is_coupon_applied'] = $is_coupon_applied;
501
502 $response['subtotal_price'] = $subtotal_price;
503 $response['coupon_discount'] = $coupon_discount;
504 $response['sale_discount'] = $sale_discount;
505 $response['tax_rate'] = $tax_rate;
506 $response['total_price_without_tax'] = $total_price_without_tax;
507 $response['tax_amount'] = $tax_amount;
508 $response['total_price'] = $total_price;
509 $response['order_type'] = $order_type;
510
511 $response['formatted_total_price_without_tax'] = tutor_get_formatted_price( $total_price_without_tax );
512 $response['formatted_total_price'] = tutor_get_formatted_price( $total_price );
513
514 return (object) $response;
515 }
516
517 /**
518 * Pay now ajax handler
519 * Create pending order, prepare payment data & proceed to payment gateway
520 *
521 * @since 3.0.0
522 *
523 * @return void
524 */
525 public function pay_now() {
526 tutor_utils()->check_nonce();
527 global $wpdb;
528
529 $errors = array();
530 $order_data = null;
531
532 $billing_model = new BillingModel();
533 $current_user_id = is_user_logged_in() ? get_current_user_id() : wp_rand();
534 $request = Input::sanitize_array( $_POST ); //phpcs:ignore --sanitized.
535
536 $billing_fillable_fields = array_intersect_key( $request, array_flip( $billing_model->get_fillable_fields() ) );
537
538 $order_payment_fields = array(
539 'object_ids',
540 'coupon_code',
541 'payment_method',
542 'payment_type',
543 'order_type',
544 );
545
546 $request = array_intersect_key( $request, array_flip( $order_payment_fields ) );
547 // Set required.
548 foreach ( $order_payment_fields as $field ) {
549 if ( ! isset( $request[ $field ] ) ) {
550 $request[ $field ] = '';
551 }
552 }
553
554 // Validate data.
555 $validate = $this->validate_pay_now_req( $request );
556
557 if ( ! $validate->success ) {
558 foreach ( $validate->errors as $error ) {
559 if ( is_array( $error ) ) {
560 foreach ( $error as $err ) {
561 array_push( $errors, $err );
562 }
563 } else {
564 array_push( $errors, $error );
565 }
566 }
567 }
568
569 // Return if validation failed.
570 if ( ! empty( $errors ) ) {
571 set_transient( self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id, $errors );
572 return;
573 }
574
575 $object_ids = array_filter( explode( ',', $request['object_ids'] ), 'is_numeric' );
576 $coupon_code = isset( $request['coupon_code'] ) ? $request['coupon_code'] : '';
577 $payment_method = $request['payment_method'];
578 $payment_type = 'free' === strtolower( $payment_method ) ? 'manual' : $request['payment_type'];
579 $order_type = $request['order_type'];
580
581 if ( empty( $object_ids ) ) {
582 array_push( $errors, __( 'Invalid cart items', 'tutor' ) );
583 }
584
585 $billing_info = $billing_model->get_info( $current_user_id );
586 if ( $billing_info ) {
587 $update_billing = $billing_model->update( $billing_fillable_fields, array( 'user_id' => $current_user_id ) );
588 if ( ! $update_billing ) {
589 array_push( $errors, __( 'Billing information update failed!', 'tutor' ) );
590 }
591 } else {
592 // Save billing info.
593 $billing_fillable_fields['user_id'] = $current_user_id;
594
595 $save = $billing_model->insert( $billing_fillable_fields );
596 if ( ! $save ) {
597 array_push( $errors, __( 'Billing info save failed!', 'tutor' ) );
598 }
599 }
600
601 $checkout_data = $this->prepare_checkout_items( $object_ids, $order_type, $coupon_code );
602
603 if ( $checkout_data->total_price > 0 && 'free' === $payment_method ) {
604 array_push( $errors, __( 'Select a payment method', 'tutor' ) );
605 }
606
607 $items = array();
608 foreach ( $checkout_data->items as $item ) {
609 $items[] = array(
610 'item_id' => $item->item_id,
611 'regular_price' => $item->regular_price,
612 'sale_price' => $item->sale_price,
613 'discount_price' => $item->discount_price,
614 'coupon_code' => $item->is_coupon_applied ? $item->coupon_code : null,
615 );
616 }
617
618 $args = apply_filters(
619 'tutor_order_create_args',
620 array(
621 'payment_method' => $payment_method,
622 'coupon_amount' => $checkout_data->coupon_discount,
623 'discount_amount' => $checkout_data->sale_discount,
624 )
625 );
626
627 if ( empty( $errors ) ) {
628 if ( ! is_user_logged_in() ) {
629 $guest_user = apply_filters( 'tutor_guest_user_id', $current_user_id, $order_data, $billing_fillable_fields );
630 if ( is_wp_error( $guest_user ) ) {
631 // Delete the billing info if user registration failed.
632 QueryHelper::delete( "{$wpdb->prefix}tutor_customers", array( 'user_id' => $current_user_id ) );
633
634 add_filter( 'tutor_checkout_user_id', fn () => $current_user_id );
635
636 // translators: wp error message.
637 $error_msg = sprintf( esc_html_x( 'Order placement failed. %s', 'guest checkout', 'tutor' ), $guest_user->get_error_message() );
638 set_transient(
639 self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id,
640 array(
641 'message' => $error_msg,
642 )
643 );
644 return;
645 } else {
646 $current_user_id = $guest_user;
647 }
648 }
649
650 $order_data = $this->order_ctrl->create_order(
651 $current_user_id,
652 $items,
653 OrderModel::PAYMENT_UNPAID,
654 $order_type,
655 $checkout_data->coupon_code,
656 $args,
657 false
658 );
659
660 if ( ! empty( $order_data ) ) {
661 if ( 'automate' === $payment_type ) {
662 try {
663 $payment_data = self::prepare_payment_data( $order_data );
664 $this->proceed_to_payment( $payment_data, $payment_method, $order_type );
665 } catch ( \Throwable $th ) {
666 tutor_log( $th );
667 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data['id'], $th->getMessage() );
668 }
669 } else {
670 // Set alert message session.
671 $this->set_pay_now_alert_msg( $order_data );
672 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_SUCCESS, $order_data['id'] );
673 }
674 } else {
675 array_push( $errors, __( 'Failed to place order!', 'tutor' ) );
676 set_transient( self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id, $errors );
677 $this->set_pay_now_alert_msg( $order_data );
678 }
679 } else {
680 set_transient( self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id, $errors );
681 $this->set_pay_now_alert_msg( $order_data );
682 }
683 }
684
685 /**
686 * Prepare payment data
687 *
688 * @since 3.0.0
689 *
690 * @param array $order Order object.
691 *
692 * @return mixed
693 */
694 public static function prepare_payment_data( array $order ) {
695 $site_name = get_bloginfo( 'name' );
696 $order_id = $order['id'];
697 $order_user_id = $order['user_id'];
698 $user_data = get_userdata( $order_user_id );
699
700 $items = array();
701 $subtotal_price = $order['subtotal_price'];
702 $total_price = $order['total_price'];
703 $grand_total = $total_price;
704 $order_type = $order['order_type'];
705
706 $currency_code = tutor_utils()->get_option( OptionKeys::CURRENCY_CODE, 'USD' );
707 $currency_symbol = tutor_get_currency_symbol_by_code( $currency_code );
708 $currency_info = tutor_get_currencies_info_by_code( $currency_code );
709
710 $billing_info = ( new BillingModel() )->get_info( $order_user_id );
711
712 $country_info = tutor_get_country_info_by_name( $billing_info->billing_country );
713
714 $country = (object) array(
715 'name' => $country_info['name'],
716 'numeric_code' => $country_info['numeric_code'],
717 'alpha_2' => $country_info['alpha_2'],
718 'alpha_3' => $country_info['alpha_3'],
719 'phone_code' => $country_info['phone_code'],
720 );
721
722 $billing_name = $billing_info ? trim( $billing_info->billing_first_name . ' ' . $billing_info->billing_last_name ) : $user_data->display_name;
723
724 $shipping_and_billing = array(
725 'name' => $billing_name,
726 'address1' => $billing_info->billing_address ?? '',
727 'address2' => $billing_info->billing_address ?? '',
728 'city' => $billing_info->billing_city ?? '',
729 'state' => $billing_info->billing_state ?? '',
730 'region' => '',
731 'postal_code' => $billing_info->billing_zip_code ?? '',
732 'country' => $country,
733 'phone_number' => $billing_info->billing_phone ?? '',
734 'email' => $billing_info->billing_email ?? '',
735 );
736
737 $customer_info = $shipping_and_billing;
738
739 foreach ( $order['items'] as $item ) {
740 $item = (object) $item;
741 $item_id = $item->item_id ?? $item->id;
742
743 if ( OrderModel::TYPE_SINGLE_ORDER === $order_type ) {
744 $items[] = array(
745 'item_id' => $item_id,
746 'item_name' => get_the_title( $item_id ),
747 'regular_price' => $item->sale_price > 0 ? $item->sale_price : $item->regular_price,
748 'quantity' => 1,
749 'discounted_price' => is_null( $item->discount_price ) || '' === $item->discount_price ? null : $item->discount_price,
750 );
751 }
752
753 if ( OrderModel::TYPE_SUBSCRIPTION === $order_type ) {
754 $subscription_items = apply_filters( 'tutor_checkout_subscription_payment_items', array(), $item, $order_id );
755 foreach ( $subscription_items as $subscription_item ) {
756 $items[] = $subscription_item;
757 }
758 }
759 }
760
761 if ( isset( $order['tax_amount'] ) && ! Tax::is_tax_included_in_price() ) {
762 $grand_total += $order['tax_amount'];
763
764 /* translators: %s: tax rate */
765 $tax_item = sprintf( __( 'Tax (%s)', 'tutor' ), $order['tax_rate'] . '%' );
766 $items[] = array(
767 'item_id' => 'tax',
768 'item_name' => $tax_item,
769 'regular_price' => $order['tax_amount'],
770 'quantity' => 1,
771 'discounted_price' => null,
772 );
773 }
774
775 return (object) array(
776 'items' => (object) $items,
777 'subtotal' => floatval( $subtotal_price ),
778 'total_price' => floatval( $total_price ),
779 'order_id' => $order_id,
780 'store_name' => $site_name,
781 'order_description' => 'Tutor Order',
782 'tax' => 0,
783 'currency' => (object) array(
784 'code' => $currency_code,
785 'symbol' => $currency_symbol,
786 'name' => $currency_info['name'] ?? '',
787 'locale' => $currency_info['locale'] ?? '',
788 'numeric_code' => $currency_info['numeric_code'] ?? '',
789 ),
790 'country' => $country,
791 'shipping_charge' => 0,
792 'coupon_discount' => 0,
793 'shipping_address' => (object) $shipping_and_billing,
794 'billing_address' => (object) $shipping_and_billing,
795 'decimal_separator' => tutor_utils()->get_option( OptionKeys::DECIMAL_SEPARATOR, '.' ),
796 'thousand_separator' => tutor_utils()->get_option( OptionKeys::THOUSAND_SEPARATOR, '.' ),
797 'customer' => (object) $customer_info,
798 );
799 }
800
801 /**
802 * Prepare payment data
803 *
804 * @since 3.0.0
805 *
806 * @param int $order_id Order id.
807 *
808 * @throws \Exception Throw exception if order not found.
809 *
810 * @return mixed
811 */
812 public static function prepare_recurring_payment_data( int $order_id ) {
813 $order_data = ( new OrderModel() )->get_order_by_id( $order_id );
814 if ( ! $order_data ) {
815 throw new \Exception( __( 'Order not found!', 'tutor' ) );
816 }
817
818 $amount = $order_data->total_price;
819
820 $order_user_id = $order_data->student->id;
821 $user_data = get_userdata( $order_user_id );
822
823 $currency_code = tutor_utils()->get_option( OptionKeys::CURRENCY_CODE, 'USD' );
824 $currency_symbol = tutor_get_currency_symbol_by_code( $currency_code );
825 $currency_info = tutor_get_currencies_info_by_code( $currency_code );
826
827 $billing_info = ( new BillingModel() )->get_info( $order_user_id );
828
829 $country_info = tutor_get_country_info_by_name( $billing_info->billing_country );
830
831 $country = (object) array(
832 'name' => $country_info['name'],
833 'numeric_code' => $country_info['numeric_code'],
834 'alpha_2' => $country_info['alpha_2'],
835 'alpha_3' => $country_info['alpha_3'],
836 'phone_code' => $country_info['phone_code'],
837 );
838
839 $billing_name = $billing_info ? trim( $billing_info->billing_first_name . ' ' . $billing_info->billing_last_name ) : $user_data->display_name;
840
841 $shipping_and_billing = array(
842 'name' => $billing_name,
843 'address1' => $billing_info->billing_address ?? '',
844 'address2' => $billing_info->billing_address ?? '',
845 'city' => $billing_info->billing_city ?? '',
846 'state' => $billing_info->billing_state ?? '',
847 'region' => '',
848 'postal_code' => $billing_info->billing_zip_code ?? '',
849 'country' => $country,
850 'phone_number' => $billing_info->billing_phone ?? '',
851 'email' => $billing_info->billing_email ?? '',
852 );
853
854 $customer_info = $shipping_and_billing;
855
856 return (object) array(
857 'type' => 'recurring',
858 'previous_payload' => $order_data->payment_payloads,
859 'total_amount' => floatval( $amount ),
860 'sub_total_amount' => floatval( $amount ),
861 'currency' => (object) array(
862 'code' => $currency_code,
863 'symbol' => $currency_symbol,
864 'name' => $currency_info['name'] ?? '',
865 'locale' => $currency_info['locale'] ?? '',
866 'numeric_code' => $currency_info['numeric_code'] ?? '',
867 ),
868 'order_id' => $order_id,
869 'customer' => (object) $customer_info,
870 'shipping_address' => (object) $shipping_and_billing,
871 );
872 }
873
874 /**
875 * Proceed to payment
876 *
877 * @since 3.0.0
878 *
879 * @param mixed $payment_data Payment data for making order.
880 * @param string $payment_method Payment method name.
881 * @param string $order_type Order type.
882 *
883 * @throws \Throwable Throw throwable if error occur.
884 * @throws \Exception Throw exception if payment gateway is invalid.
885 *
886 * @return void
887 */
888 public function proceed_to_payment( $payment_data, $payment_method, $order_type ) {
889 $payment_gateways = apply_filters( 'tutor_gateways_with_class', Ecommerce::payment_gateways_with_ref(), $payment_method );
890
891 $payment_gateway_class = isset( $payment_gateways[ $payment_method ] )
892 ? $payment_gateways[ $payment_method ]['gateway_class']
893 : null;
894
895 if ( $payment_gateway_class ) {
896 try {
897
898 add_filter(
899 'tutor_ecommerce_webhook_url',
900 function ( $url ) use ( $payment_method ) {
901 $url = add_query_arg( array( 'payment_method' => $payment_method ), $url );
902 return $url;
903 }
904 );
905
906 add_filter(
907 'tutor_ecommerce_payment_success_url_args',
908 function ( $args ) use ( $payment_data ) {
909 $args['order_id'] = $payment_data->order_id;
910 return $args;
911 }
912 );
913 add_filter(
914 'tutor_ecommerce_payment_cancelled_url_args',
915 function ( $args ) use ( $payment_data ) {
916 $args['order_id'] = $payment_data->order_id;
917 return $args;
918 }
919 );
920
921 $gateway_instance = Ecommerce::get_payment_gateway_object( $payment_gateway_class );
922 $gateway_instance->setup_payment_and_redirect( $payment_data );
923 } catch ( \Throwable $th ) {
924 throw $th;
925 }
926 } else {
927 throw new \Exception( 'Invalid payment gateway class' );
928 }
929 }
930
931 /**
932 * Restrict checkout page
933 *
934 * @return void
935 */
936 public function restrict_checkout_page() {
937 if ( ! is_page( self::get_page_id() ) ) {
938 return;
939 }
940
941 $cart_page_url = CartController::get_page_url();
942
943 if ( ! is_user_logged_in() && ! GuestCheckout::is_enable() ) {
944 wp_safe_redirect( $cart_page_url );
945 exit;
946 }
947
948 $user_id = tutils()->get_user_id();
949 $cart_model = new CartModel();
950 $has_cart_item = $cart_model->has_item_in_cart( $user_id );
951 $buy_now = Settings::is_buy_now_enabled();
952 $plan_id = Input::get( 'plan', 0, Input::TYPE_INT );
953
954 if ( ! $has_cart_item && ! $buy_now && ! $plan_id ) {
955 wp_safe_redirect( $cart_page_url );
956 exit;
957 }
958 }
959
960 /**
961 * Set alert message on the session based on
962 * order data
963 *
964 * @since 3.0.0
965 *
966 * @param mixed $order_data Order data or null. If order
967 * data is falsy then failed message will be set.
968 *
969 * @return void
970 */
971 private function set_pay_now_alert_msg( $order_data ) {
972 $user_id = $order_data ? $order_data['user_id'] : get_current_user_id();
973 if ( empty( $order_data ) ) {
974 set_transient(
975 self::PAY_NOW_ALERT_MSG_TRANSIENT_KEY . $user_id,
976 array(
977 'alert' => 'danger',
978 'message' => __( 'Failed to place order!', 'tutor' ),
979 ),
980 );
981 } else {
982 set_transient(
983 self::PAY_NOW_ALERT_MSG_TRANSIENT_KEY . $user_id,
984 array(
985 'alert' => 'success',
986 'message' => __( 'Your order has been placed successfully!', 'tutor' ),
987 ),
988 );
989 }
990 }
991
992 /**
993 * Pay for the incomplete order
994 *
995 * Redirect to the payment gateway to complete the order
996 * After completing the process it will redirect user to
997 * order placement page
998 *
999 * @since 3.0.0
1000 *
1001 * @return void
1002 */
1003 public function pay_incomplete_order() {
1004 $order_id = Input::post( 'order_id', 0, Input::TYPE_INT );
1005 $payment_method = Input::post( 'payment_method', '' );
1006 $request = Input::sanitize_array( $_POST ); //phpcs:ignore -- $POST sanitized
1007
1008 $billing_model = new BillingModel();
1009 $billing_fillable_fields = array_intersect_key( $request, array_flip( $billing_model->get_fillable_fields() ) );
1010
1011 if ( ! tutor_utils()->is_nonce_verified() ) {
1012 tutor_utils()->redirect_to( tutor_utils()->tutor_dashboard_url( 'purchase_history' ), tutor_utils()->error_message( 'nonce' ), 'error' );
1013 exit;
1014 }
1015 if ( $order_id ) {
1016 $order_model = new OrderModel();
1017 $order_data = $order_model->get_order_by_id( $order_id );
1018 if ( $order_data ) {
1019 try {
1020 if ( ! empty( $payment_method ) && OrderModel::PAYMENT_METHOD_MANUAL === $order_data->payment_method ) {
1021 $billing_info = $billing_model->get_info( $order_data->user_id );
1022 if ( $billing_info ) {
1023 $update_billing = $billing_model->update( $billing_fillable_fields, array( 'user_id' => $order_data->user_id ) );
1024
1025 if ( ! $update_billing ) {
1026 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data->id, __( 'Billing information update failed!', 'tutor' ) );
1027 }
1028 } else {
1029 // Save billing info.
1030 $billing_fillable_fields['user_id'] = $order_data->user_id;
1031
1032 $save = $billing_model->insert( $billing_fillable_fields );
1033
1034 if ( ! $save ) {
1035 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data->id, __( 'Billing info save failed!', 'tutor' ) );
1036 }
1037 }
1038
1039 $update_order_data = $order_model->get_recalculated_order_tax_data( $order_id );
1040 $update_order_data['payment_method'] = $payment_method;
1041
1042 $updated = $order_model->update_order( $order_data->id, $update_order_data );
1043
1044 if ( $updated ) {
1045 $order_data = $order_model->get_order_by_id( $order_id );
1046 }
1047 }
1048
1049 $payment_data = $this->prepare_payment_data( (array) $order_data, $payment_method ? $payment_method : $order_data->payment_method, $order_data->order_type );
1050 $this->proceed_to_payment( $payment_data, $payment_method ? $payment_method : $order_data->payment_method, $order_data->order_type );
1051 } catch ( \Throwable $th ) {
1052 tutor_log( $th );
1053 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data->id, $th->getMessage() );
1054 }
1055 } else {
1056 $error_msg = __( 'Order not found!', 'tutor' );
1057 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_id, $error_msg );
1058 }
1059 } else {
1060 $error_msg = __( 'Invalid order ID!', 'tutor' );
1061 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_id, $error_msg );
1062 }
1063 }
1064
1065 /**
1066 * Validate pay now request
1067 *
1068 * @since 3.0.0
1069 *
1070 * @param array $data The data array to validate.
1071 *
1072 * @return object The validation result. It returns validation object.
1073 */
1074 protected function validate_pay_now_req( array $data ) {
1075
1076 $order_types = array(
1077 OrderModel::TYPE_SINGLE_ORDER,
1078 OrderModel::TYPE_SUBSCRIPTION,
1079 OrderModel::TYPE_RENEWAL,
1080 );
1081
1082 $order_types = implode( ',', $order_types );
1083
1084 $validation_rules = array(
1085 'object_ids' => 'required',
1086 'order_type' => "required|match_string:{$order_types}",
1087 'payment_method' => 'required',
1088 );
1089
1090 // Skip validation rules for not available fields in data.
1091 foreach ( $validation_rules as $key => $value ) {
1092 if ( ! array_key_exists( $key, $data ) ) {
1093 unset( $validation_rules[ $key ] );
1094 }
1095 }
1096
1097 return ValidationHelper::validate( $validation_rules, $data );
1098 }
1099 }
1100