PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / 3.3.1
Tutor LMS – eLearning and online course solution v3.3.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
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
941 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\OrderModel;
21
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit;
24 }
25
26 /**
27 * Checkout Controller class
28 *
29 * @since 3.0.0
30 */
31 class CheckoutController {
32
33 use JsonResponse;
34
35 /**
36 * Page slug for checkout page
37 *
38 * @since 3.0.0
39 *
40 * @var string
41 */
42 const PAGE_SLUG = 'checkout';
43
44 /**
45 * Page slug for checkout page
46 *
47 * @since 3.0.0
48 *
49 * @var string
50 */
51 const PAGE_ID_OPTION_NAME = 'tutor_checkout_page_id';
52
53 /**
54 * Pay now error transient key
55 *
56 * @since 3.0.0
57 *
58 * @var string
59 */
60 const PAY_NOW_ERROR_TRANSIENT_KEY = 'tutor_pay_now_errors_';
61
62 /**
63 * Pay now alert transient key
64 *
65 * @since 3.0.0
66 *
67 * @var string
68 */
69 const PAY_NOW_ALERT_MSG_TRANSIENT_KEY = 'tutor_pay_now_alert_msg_';
70
71 /**
72 * Coupon model instance.
73 *
74 * @since 3.0.0
75 *
76 * @var CouponModel
77 */
78 public $coupon_model;
79
80 /**
81 * Constructor.
82 *
83 * Initializes the Checkout class, sets the page title, and optionally registers
84 * hooks for handling AJAX requests related to cart data, bulk actions, cart updates,
85 * and cart deletions.
86 *
87 * @param bool $register_hooks Whether to register hooks for handling requests. Default is true.
88 *
89 * @since 3.0.0
90 *
91 * @return void
92 */
93 public function __construct( $register_hooks = true ) {
94 $this->coupon_model = new CouponModel();
95
96 if ( $register_hooks ) {
97 add_action( 'tutor_action_tutor_pay_now', array( $this, 'pay_now' ) );
98 add_action( 'tutor_action_tutor_pay_incomplete_order', array( $this, 'pay_incomplete_order' ) );
99 add_action( 'template_redirect', array( $this, 'restrict_checkout_page' ) );
100 add_action( 'wp_ajax_tutor_get_checkout_html', array( $this, 'ajax_get_checkout_html' ) );
101 }
102 }
103
104 /**
105 * Get cart page url
106 *
107 * @since 3.0.0
108 *
109 * @return string
110 */
111 public static function get_page_url() {
112 return get_post_permalink( self::get_page_id() );
113 }
114
115 /**
116 * Get cart page ID
117 *
118 * @since 3.0.0
119 *
120 * @return string
121 */
122 public static function get_page_id() {
123 return (int) tutor_utils()->get_option( self::PAGE_ID_OPTION_NAME );
124 }
125
126 /**
127 * Create checkout page
128 *
129 * @since 3.0.0
130 *
131 * @return void
132 */
133 public static function create_checkout_page() {
134 $page_id = self::get_page_id();
135 if ( ! $page_id ) {
136 $args = array(
137 'post_title' => ucfirst( self::PAGE_SLUG ),
138 'post_content' => '',
139 'post_type' => 'page',
140 'post_status' => 'publish',
141 );
142
143 $page_id = wp_insert_post( $args );
144 tutor_utils()->update_option( self::PAGE_ID_OPTION_NAME, $page_id );
145 }
146 }
147
148 /**
149 * Get checkout HTML
150 *
151 * @since 3.0.0
152 *
153 * @return void
154 */
155 public function ajax_get_checkout_html() {
156 tutor_utils()->check_nonce();
157
158 ob_start();
159 tutor_load_template( 'ecommerce/checkout-details' );
160 $content = ob_get_clean();
161
162 $this->json_response(
163 __( 'Success', 'tutor' ),
164 $content
165 );
166 }
167
168 /**
169 * Prepare items
170 *
171 * @since 3.0.0
172 *
173 * @param array $item_ids items.
174 * @param string $order_type order type.
175 * @param object|null $coupon coupon.
176 *
177 * @return array
178 */
179 private function prepare_items( $item_ids, $order_type = OrderModel::TYPE_SINGLE_ORDER, $coupon = null ) {
180 $items = array();
181 $plan_info = null;
182
183 foreach ( $item_ids as $item_id ) {
184 $item_name = get_the_title( $item_id );
185 $course_price = tutor_utils()->get_raw_course_price( $item_id );
186 if ( OrderModel::TYPE_SINGLE_ORDER !== $order_type ) {
187 $plan_info = apply_filters( 'tutor_get_plan_info', null, $item_id );
188 if ( $plan_info ) {
189 $item_name = $plan_info->plan_name;
190 $course_price->regular_price = $plan_info->regular_price;
191 $course_price->sale_price = $plan_info->in_sale_price ? $plan_info->sale_price : 0;
192 }
193 }
194
195 $regular_price = $course_price->regular_price;
196 $sale_price = $course_price->sale_price;
197
198 $item = array(
199 'item_id' => (int) $item_id,
200 'item_name' => $item_name,
201 'regular_price' => $regular_price,
202 'sale_price' => $sale_price ? $sale_price : null,
203 'is_coupon_applied' => false,
204 'coupon_code' => null,
205 );
206
207 $is_coupon_applicable = false;
208 if ( Settings::is_coupon_usage_enabled() && is_object( $coupon ) ) {
209 $is_coupon_applicable = ! $sale_price && $this->coupon_model->is_coupon_applicable( $coupon, $item_id );
210 if ( $is_coupon_applicable ) {
211 $item['is_coupon_applied'] = $is_coupon_applicable;
212 $item['coupon_code'] = $coupon->coupon_code;
213 }
214 }
215
216 $items[] = $item;
217 }
218
219 return array( $items, $plan_info );
220 }
221
222 /**
223 * Calculate discount.
224 *
225 * @since 3.0.0
226 *
227 * @param array $items item array.
228 * @param string $discount_type discount type. like percentage or fixed.
229 * @param float $discount_value value of discount.
230 *
231 * @return array
232 */
233 public function calculate_discount( $items, $discount_type, $discount_value ) {
234 // Filter products without a sale price.
235 $items_without_sale = array_filter(
236 $items,
237 function( $item ) {
238 return empty( $item['sale_price'] );
239 }
240 );
241
242 // Calculate the total regular price of products without a sale price.
243 $total_regular_price = array_sum( array_column( $items_without_sale, 'regular_price' ) );
244
245 $final = array();
246 foreach ( $items as $item ) {
247 // If product already has a sale price, no discount is applied.
248 if ( ! empty( $item['sale_price'] ) || ( isset( $item['is_coupon_applied'] ) && ! $item['is_coupon_applied'] ) ) {
249 $item['discount_amount'] = 0;
250 $final[] = $item;
251 } else {
252 if ( 'percentage' === $discount_type ) {
253 // Apply percentage discount.
254 $discount = $item['regular_price'] * ( $discount_value / 100 );
255 $discount_price = $item['regular_price'] - $discount;
256
257 $item['discount_amount'] = round( $discount, 2 );
258 $item['discount_price'] = $discount_price;
259
260 } elseif ( 'flat' === $discount_type && $total_regular_price > 0 ) {
261 // Apply a proportional fixed discount based on the regular price.
262 $proportion = $item['regular_price'] / $total_regular_price;
263 $discount = $discount_value * $proportion;
264 $discount_price = $item['regular_price'] - $discount;
265
266 $item['discount_amount'] = round( $discount, 2 );
267 $item['discount_price'] = round( $discount_price, 2 );
268 }
269 $final[] = $item;
270 }
271 }
272
273 return $final;
274 }
275
276 /**
277 * Prepare checkout item with applying coupon if required.
278 *
279 * @since 3.0.0
280 *
281 * @since 3.3.0 is_coupon_applicable check added
282 *
283 * @param int|array $item_ids Required, course ids or plan id.
284 * @param string $order_type order type.
285 * @param string $coupon_code coupon code.
286 *
287 * @return object
288 */
289 public function prepare_checkout_items( $item_ids, $order_type = OrderModel::TYPE_SINGLE_ORDER, $coupon_code = null ) {
290 $item_ids = is_array( $item_ids ) ? $item_ids : array( $item_ids );
291 $response = array();
292
293 $coupon_type = empty( $coupon_code ) ? 'automatic' : 'manual';
294 $is_coupon_applied = false;
295 $coupon_title = '';
296
297 $total_price = 0;
298 $subtotal_price = 0;
299 $coupon_discount = 0;
300 $sale_discount = 0;
301
302 $coupon = null;
303 $is_coupon_applied = false;
304 $is_meet_min_requirement = false;
305 $selected_coupon = null;
306
307 if ( Settings::is_coupon_usage_enabled() ) {
308 $selected_coupon = $this->coupon_model->get_coupon_details_for_checkout( $coupon_code );
309 }
310
311 $is_valid = is_object( $selected_coupon ) && $this->coupon_model->is_coupon_valid( $selected_coupon );
312 if ( $is_valid ) {
313 $is_meet_min_requirement = $this->coupon_model->is_coupon_requirement_meet( $item_ids, $selected_coupon, $order_type );
314 if ( $is_meet_min_requirement ) {
315 $coupon = $selected_coupon;
316 }
317 }
318
319 list($items, $plan_info) = $this->prepare_items( $item_ids, $order_type, $coupon );
320
321 // Iterate with each item and check if coupon is applicable @since 3.3.0.
322 $is_coupon_applicable = false;
323 if ( $coupon ) {
324 foreach ( $items as $item ) {
325 if ( ! $is_coupon_applicable ) {
326 $is_coupon_applicable = $this->coupon_model->is_coupon_applicable( $coupon, $item['item_id'] );
327 }
328 }
329 if ( $is_coupon_applicable ) {
330 $is_coupon_applied = true;
331 }
332 }
333
334 if ( $is_coupon_applied ) {
335 $items = $this->calculate_discount( $items, $coupon->discount_type, $coupon->discount_amount );
336 $coupon_title = $coupon->coupon_title;
337 }
338
339 // Keep calculated price for each item.
340 foreach ( $items as $item ) {
341 $discount_amount = isset( $item['discount_amount'] ) ? $item['discount_amount'] : 0;
342 $has_discount_amount = $discount_amount > 0;
343 $item['discount_price'] = $has_discount_amount ? max( 0, $item['discount_price'] ) : null;
344
345 $display_price = isset( $item['sale_price'] ) ? $item['sale_price'] : $item['regular_price'];
346 $display_price = $has_discount_amount ? $item['discount_price'] : $display_price;
347 $item['display_price'] = $display_price;
348
349 $sale_discount_amount = isset( $item['sale_price'] ) ? $item['regular_price'] - $item['sale_price'] : 0;
350 $item['sale_discount_amount'] = $sale_discount_amount;
351
352 $response['items'][] = (object) $item;
353
354 $subtotal_price += $item['regular_price'];
355 $coupon_discount += $discount_amount;
356 $sale_discount += $sale_discount_amount;
357 }
358
359 if ( $plan_info && $plan_info->enrollment_fee > 0 ) {
360 $subtotal_price += $plan_info->enrollment_fee;
361 }
362
363 $total_price = $subtotal_price - ( $coupon_discount + $sale_discount );
364 $tax_rate = Tax::get_user_tax_rate();
365 $tax_amount = Tax::calculate_tax( $total_price, $tax_rate );
366
367 $total_price_without_tax = $total_price;
368 if ( ! Tax::is_tax_included_in_price() ) {
369 $total_price += $tax_amount;
370 }
371
372 // Total price should not negative.
373 $total_price = max( 0, $total_price );
374
375 $response['coupon_type'] = $coupon_type;
376 $response['coupon_title'] = $coupon_title;
377 $response['is_coupon_applied'] = $is_coupon_applied;
378
379 $response['subtotal_price'] = $subtotal_price;
380 $response['coupon_discount'] = $coupon_discount;
381 $response['sale_discount'] = $sale_discount;
382 $response['tax_rate'] = $tax_rate;
383 $response['total_price_without_tax'] = $total_price_without_tax;
384 $response['tax_amount'] = $tax_amount;
385 $response['total_price'] = $total_price;
386
387 return (object) $response;
388 }
389
390 /**
391 * Pay now ajax handler
392 * Create pending order, prepare payment data & proceed to payment gateway
393 *
394 * @since 3.0.0
395 *
396 * @return void
397 */
398 public function pay_now() {
399 tutor_utils()->check_nonce();
400 global $wpdb;
401
402 $errors = array();
403 $order_data = null;
404
405 $billing_model = new BillingModel();
406 $current_user_id = is_user_logged_in() ? get_current_user_id() : wp_rand();
407 $request = Input::sanitize_array( $_POST ); //phpcs:ignore --sanitized.
408
409 $billing_fillable_fields = array_intersect_key( $request, array_flip( $billing_model->get_fillable_fields() ) );
410
411 $order_payment_fields = array(
412 'object_ids',
413 'coupon_code',
414 'payment_method',
415 'payment_type',
416 'order_type',
417 );
418
419 $request = array_intersect_key( $request, array_flip( $order_payment_fields ) );
420 // Set required.
421 foreach ( $order_payment_fields as $field ) {
422 if ( ! isset( $request[ $field ] ) ) {
423 $request[ $field ] = '';
424 }
425 }
426
427 // Validate data.
428 $validate = $this->validate_pay_now_req( $request );
429
430 if ( ! $validate->success ) {
431 foreach ( $validate->errors as $error ) {
432 if ( is_array( $error ) ) {
433 foreach ( $error as $err ) {
434 array_push( $errors, $err );
435 }
436 } else {
437 array_push( $errors, $error );
438 }
439 }
440 }
441
442 // Return if validation failed.
443 if ( ! empty( $errors ) ) {
444 set_transient( self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id, $errors );
445 return;
446 }
447
448 $object_ids = array_filter( explode( ',', $request['object_ids'] ), 'is_numeric' );
449 $coupon_code = isset( $request['coupon_code'] ) ? $request['coupon_code'] : '';
450 $payment_method = $request['payment_method'];
451 $payment_type = $request['payment_type'];
452 $order_type = $request['order_type'];
453
454 if ( empty( $object_ids ) ) {
455 array_push( $errors, __( 'Invalid cart items', 'tutor' ) );
456 }
457
458 // if ( ! Ecommerce::is_payment_gateway_configured( $payment_method ) ) {
459 // array_push( $errors, Ecommerce::get_incomplete_payment_setup_error_message( $payment_method ) );
460 // }
461
462 $billing_info = $billing_model->get_info( $current_user_id );
463 if ( $billing_info ) {
464 $update_billing = $billing_model->update( $billing_fillable_fields, array( 'user_id' => $current_user_id ) );
465 if ( ! $update_billing ) {
466 array_push( $errors, __( 'Billing information update failed!', 'tutor' ) );
467 }
468 } else {
469 // Save billing info.
470 $billing_fillable_fields['user_id'] = $current_user_id;
471
472 $save = $billing_model->insert( $billing_fillable_fields );
473 if ( ! $save ) {
474 array_push( $errors, __( 'Billing info save failed!', 'tutor' ) );
475 }
476 }
477
478 $checkout_data = $this->prepare_checkout_items( $object_ids, $order_type, $coupon_code );
479 $items = array();
480 foreach ( $checkout_data->items as $item ) {
481 $items[] = array(
482 'item_id' => $item->item_id,
483 'regular_price' => $item->regular_price,
484 'sale_price' => $item->sale_price,
485 'discount_price' => $item->discount_price,
486 'coupon_code' => $item->is_coupon_applied ? $item->coupon_code : null,
487 );
488 }
489
490 $args = apply_filters(
491 'tutor_order_create_args',
492 array(
493 'payment_method' => $payment_method,
494 'coupon_amount' => $checkout_data->coupon_discount,
495 'discount_amount' => $checkout_data->sale_discount,
496 )
497 );
498
499 if ( empty( $errors ) ) {
500 if ( ! is_user_logged_in() ) {
501 $guest_user = apply_filters( 'tutor_guest_user_id', $current_user_id, $order_data, $billing_fillable_fields );
502 if ( is_wp_error( $guest_user ) ) {
503 // Delete the billing info if user registration failed.
504 QueryHelper::delete( "{$wpdb->prefix}tutor_customers", array( 'user_id' => $current_user_id ) );
505
506 add_filter( 'tutor_checkout_user_id', fn () => $current_user_id );
507
508 // translators: wp error message.
509 $error_msg = sprintf( esc_html_x( 'Order placement failed. %s', 'guest checkout', 'tutor' ), $guest_user->get_error_message() );
510 set_transient(
511 self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id,
512 array(
513 'message' => $error_msg,
514 )
515 );
516 return;
517 } else {
518 $current_user_id = $guest_user;
519 }
520 }
521
522 $order_data = ( new OrderController( false ) )->create_order( $current_user_id, $items, OrderModel::PAYMENT_UNPAID, $order_type, $coupon_code, $args, false );
523 if ( ! empty( $order_data ) ) {
524 if ( 'automate' === $payment_type ) {
525 try {
526 $payment_data = self::prepare_payment_data( $order_data );
527 $this->proceed_to_payment( $payment_data, $payment_method, $order_type );
528 } catch ( \Throwable $th ) {
529 tutor_log( $th );
530 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data['id'], $th->getMessage() );
531 }
532 } else {
533 // Set alert message session.
534 $this->set_pay_now_alert_msg( $order_data );
535 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_SUCCESS, $order_data['id'] );
536 }
537 } else {
538 array_push( $errors, __( 'Failed to place order!', 'tutor' ) );
539 set_transient( self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id, $errors );
540 $this->set_pay_now_alert_msg( $order_data );
541 }
542 } else {
543 set_transient( self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id, $errors );
544 $this->set_pay_now_alert_msg( $order_data );
545 }
546 }
547
548 /**
549 * Prepare payment data
550 *
551 * @since 3.0.0
552 *
553 * @param array $order Order object.
554 *
555 * @return mixed
556 */
557 public static function prepare_payment_data( array $order ) {
558 $site_name = get_bloginfo( 'name' );
559 $order_user_id = $order['user_id'];
560 $user_data = get_userdata( $order_user_id );
561
562 $items = array();
563 $subtotal_price = $order['subtotal_price'];
564 $total_price = $order['total_price'];
565 $grand_total = $total_price;
566 $order_type = $order['order_type'];
567
568 $currency_code = tutor_utils()->get_option( OptionKeys::CURRENCY_CODE, 'USD' );
569 $currency_symbol = tutor_get_currency_symbol_by_code( $currency_code );
570 $currency_info = tutor_get_currencies_info_by_code( $currency_code );
571
572 $billing_info = ( new BillingModel() )->get_info( $order_user_id );
573
574 $country_info = tutor_get_country_info_by_name( $billing_info->billing_country );
575
576 $country = (object) array(
577 'name' => $country_info['name'],
578 'numeric_code' => $country_info['numeric_code'],
579 'alpha_2' => $country_info['alpha_2'],
580 'alpha_3' => $country_info['alpha_3'],
581 'phone_code' => $country_info['phone_code'],
582 );
583
584 $billing_name = $billing_info ? trim( $billing_info->billing_first_name . ' ' . $billing_info->billing_last_name ) : $user_data->display_name;
585
586 $shipping_and_billing = array(
587 'name' => $billing_name,
588 'address1' => $billing_info->billing_address ?? '',
589 'address2' => $billing_info->billing_address ?? '',
590 'city' => $billing_info->billing_city ?? '',
591 'state' => $billing_info->billing_state ?? '',
592 'region' => '',
593 'postal_code' => $billing_info->billing_zip_code ?? '',
594 'country' => $country,
595 'phone_number' => $billing_info->billing_phone ?? '',
596 'email' => $billing_info->billing_email ?? '',
597 );
598
599 $customer_info = $shipping_and_billing;
600
601 foreach ( $order['items'] as $item ) {
602 $item = (object) $item;
603 $item_name = '';
604 $enrollment_item = null;
605
606 // Support for both item_id & id added.
607 $item_id = $item->item_id ?? $item->id;
608
609 if ( OrderModel::TYPE_SUBSCRIPTION === $order_type ) {
610 $plan_id = $item_id;
611 $plan_info = apply_filters( 'tutor_get_plan_info', new \stdClass(), $plan_id );
612 $item_name = $plan_info->plan_name ?? '';
613
614 $items[] = array(
615 'item_id' => $item_id,
616 'item_name' => $item_name,
617 'regular_price' => $item->sale_price > 0 ? $item->sale_price : $item->regular_price,
618 'quantity' => 1,
619 'discounted_price' => is_null( $item->discount_price ) || '' === $item->discount_price ? null : $item->discount_price,
620 );
621
622 if ( $plan_info && property_exists( $plan_info, 'enrollment_fee' ) && $plan_info->enrollment_fee > 0 ) {
623 $enrollment_item = array(
624 'item_id' => 0,
625 'item_name' => 'Enrollment Fee',
626 'regular_price' => floatval( $plan_info->enrollment_fee ),
627 'quantity' => 1,
628 'discounted_price' => null,
629 );
630
631 $items[] = $enrollment_item;
632 }
633 } else {
634 // Single order item.
635 $items[] = array(
636 'item_id' => $item_id,
637 'item_name' => get_the_title( $item_id ),
638 'regular_price' => tutor_get_locale_price( $item->sale_price > 0 ? $item->sale_price : $item->regular_price ),
639 'quantity' => 1,
640 'discounted_price' => is_null( $item->discount_price ) || '' === $item->discount_price ? null : tutor_get_locale_price( $item->discount_price ),
641 );
642 }
643 }
644
645 if ( isset( $order['tax_amount'] ) && ! Tax::is_tax_included_in_price() ) {
646 $grand_total += $order['tax_amount'];
647
648 /* translators: %s: tax rate */
649 $tax_item = sprintf( __( 'Tax (%s)', 'tutor' ), $order['tax_rate'] . '%' );
650 $items[] = array(
651 'item_id' => 'tax',
652 'item_name' => $tax_item,
653 'regular_price' => $order['tax_amount'],
654 'quantity' => 1,
655 'discounted_price' => null,
656 );
657 }
658
659 return (object) array(
660 'items' => (object) $items,
661 'subtotal' => floatval( $subtotal_price ),
662 'total_price' => floatval( $total_price ),
663 'order_id' => $order['id'],
664 'store_name' => $site_name,
665 'order_description' => 'Tutor Order',
666 'tax' => 0,
667 'currency' => (object) array(
668 'code' => $currency_code,
669 'symbol' => $currency_symbol,
670 'name' => $currency_info['name'] ?? '',
671 'locale' => $currency_info['locale'] ?? '',
672 'numeric_code' => $currency_info['numeric_code'] ?? '',
673 ),
674 'country' => $country,
675 'shipping_charge' => 0,
676 'coupon_discount' => 0,
677 'shipping_address' => (object) $shipping_and_billing,
678 'billing_address' => (object) $shipping_and_billing,
679 'decimal_separator' => tutor_utils()->get_option( OptionKeys::DECIMAL_SEPARATOR, '.' ),
680 'thousand_separator' => tutor_utils()->get_option( OptionKeys::THOUSAND_SEPARATOR, '.' ),
681 'customer' => (object) $customer_info,
682 );
683 }
684
685 /**
686 * Prepare payment data
687 *
688 * @since 3.0.0
689 *
690 * @param int $order_id Order id.
691 *
692 * @throws \Exception Throw exception if order not found.
693 *
694 * @return mixed
695 */
696 public static function prepare_recurring_payment_data( int $order_id ) {
697 $order_data = ( new OrderModel() )->get_order_by_id( $order_id );
698 if ( ! $order_data ) {
699 throw new \Exception( __( 'Order not found!', 'tutor' ) );
700 }
701
702 $amount = $order_data->total_price;
703
704 $order_user_id = $order_data->student->id;
705 $user_data = get_userdata( $order_user_id );
706
707 $currency_code = tutor_utils()->get_option( OptionKeys::CURRENCY_CODE, 'USD' );
708 $currency_symbol = tutor_get_currency_symbol_by_code( $currency_code );
709 $currency_info = tutor_get_currencies_info_by_code( $currency_code );
710
711 $billing_info = ( new BillingModel() )->get_info( $order_user_id );
712
713 $country_info = tutor_get_country_info_by_name( $billing_info->billing_country );
714
715 $country = (object) array(
716 'name' => $country_info['name'],
717 'numeric_code' => $country_info['numeric_code'],
718 'alpha_2' => $country_info['alpha_2'],
719 'alpha_3' => $country_info['alpha_3'],
720 'phone_code' => $country_info['phone_code'],
721 );
722
723 $billing_name = $billing_info ? trim( $billing_info->billing_first_name . ' ' . $billing_info->billing_last_name ) : $user_data->display_name;
724
725 $shipping_and_billing = array(
726 'name' => $billing_name,
727 'address1' => $billing_info->billing_address ?? '',
728 'address2' => $billing_info->billing_address ?? '',
729 'city' => $billing_info->billing_city ?? '',
730 'state' => $billing_info->billing_state ?? '',
731 'region' => '',
732 'postal_code' => $billing_info->billing_zip_code ?? '',
733 'country' => $country,
734 'phone_number' => $billing_info->billing_phone ?? '',
735 'email' => $billing_info->billing_email ?? '',
736 );
737
738 $customer_info = $shipping_and_billing;
739
740 return (object) array(
741 'type' => 'recurring',
742 'previous_payload' => $order_data->payment_payloads,
743 'total_amount' => floatval( $amount ),
744 'sub_total_amount' => floatval( $amount ),
745 'currency' => (object) array(
746 'code' => $currency_code,
747 'symbol' => $currency_symbol,
748 'name' => $currency_info['name'] ?? '',
749 'locale' => $currency_info['locale'] ?? '',
750 'numeric_code' => $currency_info['numeric_code'] ?? '',
751 ),
752 'order_id' => $order_id,
753 'customer' => (object) $customer_info,
754 'shipping_address' => (object) $shipping_and_billing,
755 );
756 }
757
758 /**
759 * Proceed to payment
760 *
761 * @since 3.0.0
762 *
763 * @param mixed $payment_data Payment data for making order.
764 * @param string $payment_method Payment method name.
765 * @param string $order_type Order type.
766 *
767 * @throws \Throwable Throw throwable if error occur.
768 * @throws \Exception Throw exception if payment gateway is invalid.
769 *
770 * @return void
771 */
772 public function proceed_to_payment( $payment_data, $payment_method, $order_type ) {
773 $payment_gateways = apply_filters( 'tutor_gateways_with_class', Ecommerce::payment_gateways_with_ref(), $payment_method );
774
775 $payment_gateway_class = isset( $payment_gateways[ $payment_method ] )
776 ? $payment_gateways[ $payment_method ]['gateway_class']
777 : null;
778
779 if ( $payment_gateway_class ) {
780 try {
781
782 add_filter(
783 'tutor_ecommerce_webhook_url',
784 function ( $url ) use ( $payment_method ) {
785 $url = add_query_arg( array( 'payment_method' => $payment_method ), $url );
786 return $url;
787 }
788 );
789
790 add_filter(
791 'tutor_ecommerce_payment_success_url_args',
792 function ( $args ) use ( $payment_data ) {
793 $args['order_id'] = $payment_data->order_id;
794 return $args;
795 }
796 );
797 add_filter(
798 'tutor_ecommerce_payment_cancelled_url_args',
799 function ( $args ) use ( $payment_data ) {
800 $args['order_id'] = $payment_data->order_id;
801 return $args;
802 }
803 );
804
805 $gateway_instance = Ecommerce::get_payment_gateway_object( $payment_gateway_class );
806 $gateway_instance->setup_payment_and_redirect( $payment_data );
807 } catch ( \Throwable $th ) {
808 throw $th;
809 }
810 } else {
811 throw new \Exception( 'Invalid payment gateway class' );
812 }
813 }
814
815 /**
816 * Restrict checkout page
817 *
818 * @return void
819 */
820 public function restrict_checkout_page() {
821 $page_id = self::get_page_id();
822 $plan_id = Input::get( 'plan' );
823
824 if ( is_page( $page_id ) && ! $plan_id ) {
825 $cart_controller = new CartController();
826 $cart_model = new CartModel();
827
828 $user_id = tutils()->get_user_id();
829 $has_cart_item = $cart_model->has_item_in_cart( $user_id );
830
831 if ( ! $has_cart_item ) {
832 wp_safe_redirect( $cart_controller::get_page_url() );
833 exit;
834 }
835 }
836 }
837
838 /**
839 * Set alert message on the session based on
840 * order data
841 *
842 * @since 3.0.0
843 *
844 * @param mixed $order_data Order data or null. If order
845 * data is falsy then failed message will be set.
846 *
847 * @return void
848 */
849 private function set_pay_now_alert_msg( $order_data ) {
850 $user_id = $order_data ? $order_data['user_id'] : get_current_user_id();
851 if ( empty( $order_data ) ) {
852 set_transient(
853 self::PAY_NOW_ALERT_MSG_TRANSIENT_KEY . $user_id,
854 array(
855 'alert' => 'danger',
856 'message' => __( 'Failed to place order!', 'tutor' ),
857 ),
858 );
859 } else {
860 set_transient(
861 self::PAY_NOW_ALERT_MSG_TRANSIENT_KEY . $user_id,
862 array(
863 'alert' => 'success',
864 'message' => __( 'Your order has been placed successfully!', 'tutor' ),
865 ),
866 );
867 }
868 }
869
870 /**
871 * Pay for the incomplete order
872 *
873 * Redirect to the payment gateway to complete the order
874 * After completing the process it will redirect user to
875 * order placement page
876 *
877 * @since 3.0.0
878 *
879 * @return void
880 */
881 public function pay_incomplete_order() {
882 $order_id = Input::post( 'order_id', 0, Input::TYPE_INT );
883 if ( ! tutor_utils()->is_nonce_verified() ) {
884 tutor_utils()->redirect_to( tutor_utils()->tutor_dashboard_url( 'purchase_history' ), tutor_utils()->error_message( 'nonce' ), 'error' );
885 exit;
886 }
887 if ( $order_id ) {
888 $order_data = ( new OrderModel() )->get_order_by_id( $order_id );
889 if ( $order_data ) {
890 try {
891 $payment_data = $this->prepare_payment_data( (array) $order_data, $order_data->payment_method, $order_data->order_type );
892 $this->proceed_to_payment( $payment_data, $order_data->payment_method, $order_data->order_type );
893 } catch ( \Throwable $th ) {
894 tutor_log( $th );
895 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data->id, $th->getMessage() );
896 }
897 } else {
898 $error_msg = __( 'Order not found!', 'tutor' );
899 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_id, $error_msg );
900 }
901 } else {
902 $error_msg = __( 'Invalid order ID!', 'tutor' );
903 tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_id, $error_msg );
904 }
905 }
906
907 /**
908 * Validate pay now request
909 *
910 * @since 3.0.0
911 *
912 * @param array $data The data array to validate.
913 *
914 * @return object The validation result. It returns validation object.
915 */
916 protected function validate_pay_now_req( array $data ) {
917
918 $order_types = array(
919 OrderModel::TYPE_SINGLE_ORDER,
920 OrderModel::TYPE_SUBSCRIPTION,
921 OrderModel::TYPE_RENEWAL,
922 );
923 $order_types = implode( ',', $order_types );
924
925 $validation_rules = array(
926 'object_ids' => 'required',
927 'payment_method' => 'required',
928 'order_type' => "required|match_string:{$order_types}",
929 );
930
931 // Skip validation rules for not available fields in data.
932 foreach ( $validation_rules as $key => $value ) {
933 if ( ! array_key_exists( $key, $data ) ) {
934 unset( $validation_rules[ $key ] );
935 }
936 }
937
938 return ValidationHelper::validate( $validation_rules, $data );
939 }
940 }
941