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