PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / 3.9.3
Tutor LMS – eLearning and online course solution v3.9.3
3.9.14 3.9.13 3.9.12 3.9.11 trunk 1.0.0 1.0.0-alpha 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.2.0 1.2.1 1.2.11 1.2.12 1.2.13 1.2.20 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.7.5 1.7.6 1.7.7 1.7.8 1.7.9 1.8.0 1.8.1 1.8.10 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.15 1.9.16 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.1.1 2.1.10 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.1.7 2.1.8 2.1.9 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.3.0 2.4.0 2.5.0 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 3.0.0 3.0.1 3.0.2 3.1.0 3.2.0 3.2.1 3.2.2 3.2.3 3.3.0 3.3.1 3.4.0 3.4.1 3.4.2 3.5.0 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.7.0 3.7.1 3.7.2 3.7.3 3.7.4 3.8.0 3.8.1 3.8.2 3.8.3 3.9.0 3.9.1 3.9.10 3.9.2 3.9.3 3.9.4 3.9.5 3.9.6 3.9.7 3.9.8 3.9.9
tutor / ecommerce / OrderController.php
tutor / ecommerce Last commit date
Cart 10 months ago PaymentGateways 8 months ago AdminMenu.php 9 months ago BillingController.php 1 year ago CartController.php 1 year ago CheckoutController.php 7 months ago CouponController.php 11 months ago Ecommerce.php 1 year ago EmailController.php 11 months ago HooksHandler.php 7 months ago OptionKeys.php 1 year ago OrderActivitiesController.php 1 year ago OrderController.php 7 months ago PaymentHandler.php 9 months ago Settings.php 9 months ago Tax.php 9 months ago currency.php 1 year ago
OrderController.php
1284 lines
1 <?php
2 /**
3 * Manage Order
4 *
5 * @package Tutor\Ecommerce
6 * @author Themeum <support@themeum.com>
7 * @link https://themeum.com
8 * @since 3.0.0
9 */
10
11 namespace Tutor\Ecommerce;
12
13 use TUTOR\Input;
14 use TUTOR\Earnings;
15 use Tutor\Models\OrderModel;
16 use TUTOR\Backend_Page_Trait;
17 use Tutor\Helpers\HttpHelper;
18 use Tutor\Models\CouponModel;
19 use Tutor\Models\CourseModel;
20 use Tutor\Helpers\QueryHelper;
21 use Tutor\Traits\JsonResponse;
22 use Tutor\Helpers\ValidationHelper;
23 use Tutor\Models\OrderActivitiesModel;
24
25 if ( ! defined( 'ABSPATH' ) ) {
26 exit;
27 }
28 /**
29 * OrderController class
30 *
31 * @since 3.0.0
32 */
33 class OrderController {
34
35 /**
36 * Order page slug
37 *
38 * @since 3.0.0
39 *
40 * @var string
41 */
42 const PAGE_SLUG = 'tutor_orders';
43
44 /**
45 * Order model
46 *
47 * @since 3.0.0
48 *
49 * @var OrderModel
50 */
51 private $model;
52
53 /**
54 * Trait for utilities
55 *
56 * @var $page_title
57 */
58 use Backend_Page_Trait;
59
60 /**
61 * Trait for sending JSON response
62 */
63 use JsonResponse;
64
65 /**
66 * Constructor.
67 *
68 * Initializes the Orders class, sets the page title, and optionally registers
69 * hooks for handling AJAX requests related to order data, bulk actions, order status updates,
70 * and order deletions.
71 *
72 * @param bool $register_hooks Whether to register hooks for handling requests. Default is true.
73 *
74 * @since 3.0.0
75 *
76 * @return void
77 */
78 public function __construct( $register_hooks = true ) {
79 $this->model = new OrderModel();
80
81 if ( $register_hooks ) {
82 /**
83 * Handle AJAX request for getting order related data by order ID.
84 *
85 * @since 3.0.0
86 */
87 add_action( 'wp_ajax_tutor_order_details', array( $this, 'get_order_by_id' ) );
88
89 /**
90 * Handle AJAX request for marking an order as paid by order ID.
91 *
92 * @since 3.0.0
93 */
94 add_action( 'wp_ajax_tutor_order_paid', array( $this, 'order_mark_as_paid' ) );
95
96 /**
97 * Handle AJAX request for canceling an order status.
98 *
99 * @since 3.0.0
100 */
101 add_action( 'wp_ajax_tutor_order_cancel', array( $this, 'order_cancel' ) );
102
103 /**
104 * Handle AJAX request for marking an order's refund action.
105 *
106 * @since 3.0.0
107 */
108 add_action( 'wp_ajax_tutor_order_refund', array( $this, 'make_refund' ) );
109
110 /**
111 * Handle AJAX request for adding an order comment.
112 *
113 * @since 3.0.0
114 */
115 add_action( 'wp_ajax_tutor_order_comment', array( $this, 'add_comment' ) );
116
117 /**
118 * Handle AJAX request for add/update an order's discount action.
119 *
120 * @since 3.0.0
121 */
122 add_action( 'wp_ajax_tutor_order_discount', array( $this, 'add_discount' ) );
123
124 /**
125 * Handle bulk action
126 *
127 * @since 3.0.0
128 */
129 add_action( 'wp_ajax_tutor_order_bulk_action', array( $this, 'bulk_action_handler' ) );
130
131 add_filter( 'tutor_calculate_order_tax_amount', array( $this, 'filter_calculate_single_order_tax_amount' ), 10, 5 );
132 }
133 }
134
135 /**
136 * Page title fallback
137 *
138 * @since 3.5.0
139 *
140 * @param string $name Property name.
141 *
142 * @return string
143 */
144 public function __get( $name ) {
145 if ( 'page_title' === $name ) {
146 return esc_html__( 'Orders', 'tutor' );
147 }
148 }
149
150 /**
151 * Get order page url
152 *
153 * @since 3.0.0
154 *
155 * @param boolean $is_admin Whether to get admin or frontend url.
156 *
157 * @return string
158 */
159 public static function get_order_page_url( bool $is_admin = true ) {
160 if ( $is_admin ) {
161 return admin_url( 'admin.php?page=' . self::PAGE_SLUG );
162 } else {
163 return tutor_utils()->get_tutor_dashboard_url() . '/orders';
164 }
165 }
166
167 /**
168 * For Single Order
169 * Filter order tax calculation during create an order.
170 *
171 * @since 3.7.0
172 *
173 * @param int|float $tax_amount tax amount.
174 * @param int|float $total_price total price.
175 * @param int|float $tax_rate tax rate.
176 * @param string $order_type order type.
177 * @param array $items order items.
178 *
179 * @return int|float
180 */
181 public function filter_calculate_single_order_tax_amount( $tax_amount, $total_price, $tax_rate, $order_type, $items ) {
182 if ( OrderModel::TYPE_SINGLE_ORDER === $order_type ) {
183 $tax_exempt_price = 0;
184 $tax_amount = Tax::calculate_tax( $total_price, $tax_rate );
185
186 foreach ( $items as $item ) {
187 $is_tax_enabled = CourseModel::is_tax_enabled_for_single_purchase( $item['item_id'] ?? 0 );
188 if ( ! $is_tax_enabled ) {
189 $display_price = $this->model->get_order_item_display_price( (object) $item );
190 $tax_exempt_price += $display_price;
191 }
192 }
193
194 $tax_exempt_amount = Tax::calculate_tax( $tax_exempt_price, $tax_rate );
195 $tax_amount = $tax_amount - $tax_exempt_amount;
196 }
197
198 return $tax_amount;
199 }
200
201 /**
202 * Create order based on the arguments
203 *
204 * Note: This method assumes nonce & user cap has been validated.
205 *
206 * Note: This method will validate data so it could be
207 * used without validation.
208 *
209 * @since 3.0.0
210 *
211 * @since 3.9.2 `prepare_order_data` method added.
212 *
213 * @param int $user_id Typically student.
214 * @param array $items Key value pairs based on order_items table.
215 * @param string $payment_status Order payment status.
216 * @param string $order_type Type single_order/subscription.
217 * @param mixed $coupon_code Optional, if not provided automatic coupon.
218 * @param array $args Optional, Args to set data such as fees, tax, etc. Even to modify $order_data.
219 * @param bool $return_id return id.
220 *
221 * @throws \Exception Throw exception if data not valid or
222 * any other exception occur.
223 *
224 * @return mixed order id or order data.
225 */
226 public function create_order( int $user_id, array $items, string $payment_status, string $order_type, $coupon_code = null, array $args = array(), $return_id = true ) {
227
228 $order_data = $this->prepare_order_data( $user_id, $items, $payment_status, $order_type, $coupon_code, $args );
229
230 // Update data with arguments.
231 $order_data = apply_filters( 'tutor_before_order_create', array_merge( $order_data, $args ) );
232
233 try {
234 do_action( 'tutor_before_order_create', $order_data );
235 $order_id = $this->model->create_order( $order_data );
236 if ( $order_id ) {
237 $order_data['id'] = $order_id;
238 do_action( 'tutor_order_placed', $order_data );
239 return $return_id ? $order_id : $order_data;
240 }
241 } catch ( \Throwable $th ) {
242 throw new \Exception( $th->getMessage() );
243 }
244 }
245
246 /**
247 * Retrieve order data by order ID and respond with JSON.
248 *
249 * This function retrieves the order ID from the POST request, validates it,
250 * fetches the corresponding order data using the OrderModel class, and returns
251 * a JSON response with the order data or an error message.
252 *
253 * If the order ID is not provided, it responds with a "Bad Request" status.
254 * If the order is not found, it responds with a "Not Found" status.
255 * Otherwise, it responds with the order data and a success message.
256 *
257 * @since 3.0.0
258 *
259 * @return void
260 */
261 public function get_order_by_id() {
262 if ( ! tutor_utils()->is_nonce_verified() ) {
263 $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
264 }
265
266 $order_id = Input::post( 'order_id' );
267
268 if ( empty( $order_id ) ) {
269 $this->json_response(
270 __( 'Order ID is required', 'tutor' ),
271 null,
272 HttpHelper::STATUS_BAD_REQUEST
273 );
274 }
275
276 $order_data = $this->model->get_order_by_id( $order_id );
277
278 if ( ! $order_data ) {
279 $this->json_response(
280 __( 'Order not found', 'tutor' ),
281 null,
282 HttpHelper::STATUS_NOT_FOUND
283 );
284 }
285
286 $this->json_response(
287 __( 'Order retrieved successfully', 'tutor' ),
288 $order_data
289 );
290 }
291
292 /**
293 * Mark an order as paid.
294 *
295 * This function verifies a nonce for security, constructs a payload object with
296 * the order ID, note, and payment status, and updates the payment status of the order
297 * to 'paid'. It sends a JSON response indicating the success or failure of the operation.
298 *
299 * @since 3.0.0
300 *
301 * @return void
302 */
303 public function order_mark_as_paid() {
304 tutor_utils()->check_nonce();
305
306 if ( ! current_user_can( 'manage_options' ) ) {
307 $this->json_response( tutor_utils()->error_message( HttpHelper::STATUS_UNAUTHORIZED ), null, HttpHelper::STATUS_UNAUTHORIZED );
308 }
309
310 $params = array(
311 'order_id' => Input::post( 'order_id' ),
312 'note' => Input::post( 'note' ),
313 );
314
315 // Validate request.
316 $validation = $this->validate( $params );
317 if ( ! $validation->success ) {
318 $this->json_response(
319 tutor_utils()->error_message( HttpHelper::STATUS_BAD_REQUEST ),
320 $validation->errors,
321 HttpHelper::STATUS_BAD_REQUEST
322 );
323 }
324
325 $order_id = (int) $params['order_id'];
326
327 $updated = $this->model->mark_as_paid( $order_id, $params['note'] );
328
329 if ( ! $updated ) {
330 $this->json_response(
331 __( 'Failed to update order payment status', 'tutor' ),
332 null,
333 HttpHelper::STATUS_INTERNAL_SERVER_ERROR
334 );
335 }
336
337 $this->json_response( __( 'Order payment status successfully updated', 'tutor' ) );
338 }
339
340 /**
341 * Cancels an order.
342 *
343 * This function cancels an order by updating its status to 'cancelled'. It performs nonce verification
344 * and checks the user's permissions before proceeding. It also validates the input parameters and
345 * triggers actions before and after the order cancellation.
346 *
347 * The function responds with an appropriate JSON message depending on the outcome of the cancellation process.
348 *
349 * @since 3.0.0
350 *
351 * @return void Responds with a JSON message indicating success or failure.
352 */
353 public function order_cancel() {
354 if ( ! tutor_utils()->is_nonce_verified() ) {
355 $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
356 }
357
358 if ( ! current_user_can( 'manage_options' ) ) {
359 $this->json_response( tutor_utils()->error_message( HttpHelper::STATUS_UNAUTHORIZED ), null, HttpHelper::STATUS_UNAUTHORIZED );
360 }
361
362 $params = array(
363 'id' => Input::post( 'order_id' ),
364 'order_status' => $this->model::ORDER_CANCELLED,
365 );
366
367 // Validate request.
368 $validation = $this->validate( $params );
369 if ( ! $validation->success ) {
370 $this->json_response(
371 tutor_utils()->error_message( HttpHelper::STATUS_BAD_REQUEST ),
372 $validation->errors,
373 HttpHelper::STATUS_BAD_REQUEST
374 );
375 }
376
377 do_action( 'tutor_before_order_cancel', $params );
378
379 $response = $this->model->update_order( $params['id'], $params );
380 if ( ! $response ) {
381 $this->json_response(
382 __( 'Failed to cancel order status', 'tutor' ),
383 null,
384 HttpHelper::STATUS_INTERNAL_SERVER_ERROR
385 );
386 }
387
388 do_action( 'tutor_order_payment_status_changed', $params['id'], '', $this->model::ORDER_CANCELLED );
389
390 $this->json_response( __( 'Order successfully canceled', 'tutor' ) );
391 }
392
393 /**
394 * Handle order refund process.
395 *
396 * This method processes the refund for an order. It verifies the nonce and user capabilities,
397 * triggers necessary actions before and after the refund process, validates input data, and
398 * interacts with the OrderActivitiesModel to record the refund metadata. If any validation
399 * fails or the refund process encounters an error, it returns an appropriate JSON response.
400 *
401 * @since 3.0.0
402 *
403 * @return void
404 */
405 public function make_refund() {
406 if ( ! tutor_utils()->is_nonce_verified() ) {
407 $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
408 }
409
410 if ( ! current_user_can( 'manage_options' ) ) {
411 $this->json_response( tutor_utils()->error_message( HttpHelper::STATUS_UNAUTHORIZED ), null, HttpHelper::STATUS_UNAUTHORIZED );
412 }
413
414 $order_id = Input::post( 'order_id', 0, Input::TYPE_INT );
415 $amount = (float) Input::post( 'amount' );
416 $reason = Input::post( 'reason' );
417 $cancel_enrollment = Input::post( 'is_remove_enrolment', false, Input::TYPE_BOOL );
418
419 if ( $amount <= 0 ) {
420 $this->json_response( __( 'Invalid refund amount provided', 'tutor' ), null, HttpHelper::STATUS_BAD_REQUEST );
421 }
422
423 $meta_value = array(
424 'amount' => $amount,
425 'reason' => $reason,
426 'message' => __( 'Order refunded by ', 'tutor' ) . get_userdata( get_current_user_id() )->display_name,
427 );
428
429 $order_data = $this->model->get_order_by_id( $order_id );
430 $cancel_enrollment = apply_filters( 'tutor_cancel_enrollment_on_refund', $cancel_enrollment, $order_data );
431
432 if ( $amount > (float) $order_data->net_payment ) {
433 $this->json_response(
434 __( 'Refund amount exceeded.', 'tutor' ),
435 null,
436 HttpHelper::STATUS_BAD_REQUEST
437 );
438 }
439
440 $order_status = $order_data->order_status;
441 $payment_status = $order_data->payment_status;
442
443 try {
444 do_action( 'tutor_before_initiate_refund', $order_data );
445 } catch ( \Throwable $th ) {
446 $this->json_response(
447 $th->getMessage(),
448 '',
449 HttpHelper::STATUS_BAD_REQUEST
450 );
451 }
452
453 $meta_key = OrderActivitiesModel::META_KEY_REFUND;
454 if ( $amount < (float) $order_data->net_payment ) {
455 $meta_key = OrderActivitiesModel::META_KEY_PARTIALLY_REFUND;
456 }
457
458 if ( OrderActivitiesModel::META_KEY_PARTIALLY_REFUND === $meta_key ) {
459 $meta_value['message'] = __( 'Partially refunded by ', 'tutor' ) . get_userdata( get_current_user_id() )->display_name;
460 }
461
462 $params = array(
463 'order_id' => $order_id,
464 'meta_key' => $meta_key,
465 'meta_value' => wp_json_encode( $meta_value ),
466 );
467
468 // Validate request.
469 $validation = $this->validate( $params );
470 if ( ! $validation->success ) {
471 $this->json_response(
472 tutor_utils()->error_message( HttpHelper::STATUS_BAD_REQUEST ),
473 $validation->errors,
474 HttpHelper::STATUS_BAD_REQUEST
475 );
476 }
477
478 $payload = new \stdClass();
479 $payload->order_id = $params['order_id'];
480 $payload->meta_key = $params['meta_key'];
481 $payload->meta_value = $params['meta_value'];
482
483 $activity_model = new OrderActivitiesModel();
484 $response = $activity_model->add_order_meta( $payload );
485
486 if ( $response ) {
487 // Update net payment.
488 $refund_amount = $this->model->get_refund_amount( $order_id );
489
490 $net_payment = floatval( $order_data->total_price ) - floatval( $refund_amount );
491 if ( 'refund' === $meta_key ) {
492 $payment_status = $this->model::PAYMENT_REFUNDED;
493 } else {
494 $payment_status = $this->model::PAYMENT_PARTIALLY_REFUNDED;
495 }
496
497 if ( $cancel_enrollment ) {
498 $order_status = $this->model::ORDER_CANCELLED;
499 }
500
501 $update_data = array(
502 'net_payment' => $net_payment,
503 'refund_amount' => $refund_amount,
504 'payment_status' => $payment_status,
505 'order_status' => $order_status,
506 );
507
508 $this->model->update_order( $order_id, $update_data );
509
510 do_action( 'tutor_order_payment_status_changed', $order_data->id, $order_data->payment_status, $payment_status );
511
512 $order_data->payment_status = $update_data['payment_status'];
513 $order_data->order_status = $update_data['order_status'];
514 do_action( 'tutor_after_order_refund', $order_id, $amount, $reason );
515
516 $res_msg = __( 'Order refund successful', 'tutor' );
517
518 try {
519 $this->refund_from_payment_gateway( $order_id, $amount, $reason );
520 $this->json_response( $res_msg );
521 } catch ( \Throwable $th ) {
522 $res_msg = __( 'Order refunded successfully, but pending payment gateway issuance.', 'tutor' );
523 $this->json_response( $res_msg );
524 }
525 } else {
526 $this->json_response(
527 __( 'Failed to make refund', 'tutor' ),
528 null,
529 HttpHelper::STATUS_INTERNAL_SERVER_ERROR
530 );
531 }
532 }
533
534 /**
535 * Handle adding a comment to an order.
536 *
537 * This method processes the addition of a comment to an order. It verifies the nonce and user capabilities,
538 * triggers necessary actions before and after the comment addition, validates input data, and
539 * interacts with the OrderActivitiesModel to record the comment metadata. If any validation
540 * fails or the comment addition process encounters an error, it returns an appropriate JSON response.
541 *
542 * @since 3.0.0
543 *
544 * @return void
545 */
546 public function add_comment() {
547 if ( ! tutor_utils()->is_nonce_verified() ) {
548 $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
549 }
550
551 if ( ! current_user_can( 'manage_options' ) ) {
552 $this->json_response( tutor_utils()->error_message( HttpHelper::STATUS_UNAUTHORIZED ), null, HttpHelper::STATUS_UNAUTHORIZED );
553 }
554
555 $params = array(
556 'order_id' => Input::post( 'order_id' ),
557 'meta_key' => OrderActivitiesModel::META_KEY_COMMENT,
558 'meta_value' => Input::post( 'comment' ),
559 );
560
561 do_action( 'tutor_before_order_comment', $params );
562
563 // Validate request.
564 $validation = $this->validate( $params );
565 if ( ! $validation->success ) {
566 $this->json_response(
567 tutor_utils()->error_message( HttpHelper::STATUS_BAD_REQUEST ),
568 $validation->errors,
569 HttpHelper::STATUS_BAD_REQUEST
570 );
571 }
572
573 $payload = new \stdClass();
574 $payload->order_id = $params['order_id'];
575 $payload->meta_key = $params['meta_key'];
576 $payload->meta_value = $params['meta_value'];
577
578 $activity_model = new OrderActivitiesModel();
579 $response = $activity_model->add_order_meta( $payload );
580
581 do_action( 'tutor_after_order_comment', $params );
582
583 if ( ! $response ) {
584 $this->json_response(
585 __( 'Failed to make a comment', 'tutor' ),
586 null,
587 HttpHelper::STATUS_INTERNAL_SERVER_ERROR
588 );
589 }
590
591 $this->json_response( __( 'Order comment successful added', 'tutor' ) );
592 }
593
594 /**
595 * Add a discount to an order.
596 *
597 * This function handles the request to add a discount to an order. It verifies the nonce,
598 * checks user permissions, validates the input, and then updates the order with the discount details.
599 *
600 * @since 3.0.0
601 *
602 * @return void This function outputs a JSON response and does not return a value.
603 */
604 public function add_discount() {
605 if ( ! tutor_utils()->is_nonce_verified() ) {
606 $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
607 }
608
609 if ( ! current_user_can( 'manage_options' ) ) {
610 $this->json_response( tutor_utils()->error_message( HttpHelper::STATUS_UNAUTHORIZED ), null, HttpHelper::STATUS_UNAUTHORIZED );
611 }
612
613 $request = Input::sanitize_array( $_POST ); //phpcs:ignore --already sanitized.
614
615 // Validate request.
616 $validation = $this->validate( $request );
617 if ( ! $validation->success ) {
618 $this->json_response(
619 tutor_utils()->error_message( HttpHelper::STATUS_BAD_REQUEST ),
620 $validation->errors,
621 HttpHelper::STATUS_BAD_REQUEST
622 );
623 }
624
625 $request = (object) $request;
626
627 try {
628 $order = $this->model->get_order_by_id( $request->order_id );
629 $subtotal = $order->subtotal_price;
630
631 $discount_amount = $this->model->calculate_discount_amount( $request->discount_type, $request->discount_amount, $subtotal );
632
633 $deducted_amount = $discount_amount;
634 if ( ! empty( $order->coupon_code ) && $order->coupon_amount > 0 ) {
635 $deducted_amount += $order->coupon_amount;
636 }
637
638 $total_price = $subtotal - $deducted_amount;
639
640 $tax_rate = Tax::get_user_tax_rate( $order->user_id );
641 $tax_amount = Tax::calculate_tax( $total_price, $tax_rate );
642 if ( ! Tax::is_tax_included_in_price() ) {
643 $total_price += $tax_amount;
644 }
645
646 $order_data = array(
647 'discount_amount' => $request->discount_amount,
648 'discount_reason' => $request->discount_reason,
649 'discount_type' => $request->discount_type,
650 'subtotal_price' => $subtotal,
651 'tax_rate' => $tax_rate,
652 'tax_amount' => $tax_amount,
653 'net_payment' => $total_price,
654 'total_price' => $total_price,
655 );
656
657 $update = $this->model->update_order( $request->order_id, $order_data );
658 if ( ! $update ) {
659 $this->json_response(
660 __( 'Failed to add a discount', 'tutor' ),
661 null,
662 HttpHelper::STATUS_INTERNAL_SERVER_ERROR
663 );
664 }
665
666 do_action( 'tutor_after_add_order_discount', $order, $discount_amount );
667
668 $this->json_response( __( 'Order discount successful added', 'tutor' ) );
669 } catch ( \Throwable $th ) {
670 $this->json_response(
671 __( 'Failed to add a discount', 'tutor' ),
672 $th->getMessage(),
673 HttpHelper::STATUS_INTERNAL_SERVER_ERROR
674 );
675 }
676 }
677
678 /**
679 * Prepare bulk actions that will show on dropdown options
680 *
681 * @return array
682 * @since 3.0.0
683 */
684 public function prepare_bulk_actions(): array {
685 $actions = array(
686 $this->bulk_action_default(),
687 );
688
689 $active_tab = Input::get( 'data', '' );
690
691 if ( $this->model::ORDER_TRASH !== $active_tab ) {
692 $actions[] = $this->bulk_action_mark_order_trash();
693 }
694
695 if ( ! empty( $active_tab ) ) {
696 switch ( $active_tab ) {
697 case $this->model::ORDER_INCOMPLETE:
698 $actions[] = $this->bulk_action_mark_order_paid();
699 break;
700 case $this->model::ORDER_COMPLETED:
701 $actions[] = $this->bulk_action_mark_order_unpaid();
702 break;
703 case $this->model::ORDER_TRASH:
704 $actions[] = $this->bulk_action_delete();
705 break;
706 default:
707 // code...
708 break;
709 }
710 }
711
712 return apply_filters( 'tutor_order_bulk_actions', $actions );
713 }
714
715 /**
716 * Available tabs that will visible on the right side of page navbar
717 *
718 * @return array
719 *
720 * @since 3.0.0
721 */
722 public function tabs_key_value(): array {
723 $url = apply_filters( 'tutor_data_tab_base_url', get_pagenum_link() );
724
725 $date = Input::get( 'date', '' );
726 $payment_status = Input::get( 'payment-status', '' );
727 $search = Input::get( 'search', '' );
728
729 $where = array(
730 'order_type' => OrderModel::TYPE_SINGLE_ORDER,
731 );
732
733 if ( ! empty( $date ) ) {
734 $where['date(o.created_at_gmt)'] = tutor_get_formated_date( '', $date );
735 }
736
737 if ( ! empty( $payment_status ) ) {
738 $where['o.payment_status'] = $payment_status;
739 }
740
741 $order_status = $this->model->get_order_status();
742
743 $tabs = array();
744
745 $tabs [] = array(
746 'key' => '',
747 'title' => __( 'All', 'tutor' ),
748 'value' => $this->model->get_order_count( $where, $search ),
749 'url' => $url . '&data=all',
750 );
751
752 foreach ( $order_status as $key => $value ) {
753 $where['order_status'] = $key;
754
755 $tabs[] = array(
756 'key' => $key,
757 'title' => $value,
758 'value' => $this->model->get_order_count( $where, $search ),
759 'url' => $url . '&data=' . $key,
760 );
761 }
762
763 return apply_filters( 'tutor_order_tabs', $tabs );
764 }
765
766 /**
767 * Count orders by status & filters
768 * Count all | min | published | pending | draft
769 *
770 * @param string $status | required.
771 * @param string $order_id selected order id | optional.
772 * @param string $date selected date | optional.
773 * @param string $search_term search by user name or email | optional.
774 *
775 * @return int
776 *
777 * @since 3.0.0
778 */
779 protected static function count_order( string $status, $order_id = '', $date = '', $search_term = '' ): int {
780 $user_id = get_current_user_id();
781 $status = sanitize_text_field( $status );
782 $order_id = sanitize_text_field( $order_id );
783 $date = sanitize_text_field( $date );
784 $search_term = sanitize_text_field( $search_term );
785
786 $args = array(
787 'post_type' => tutor()->order_post_type,
788 );
789
790 if ( 'all' === $status || 'mine' === $status ) {
791 $args['post_status'] = array( 'publish', 'pending', 'draft', 'private', 'future' );
792 } else {
793 $args['post_status'] = array( $status );
794 }
795
796 // Author query.
797 if ( 'mine' === $status || ! current_user_can( 'administrator' ) ) {
798 $args['author'] = $user_id;
799 }
800
801 $date_filter = sanitize_text_field( $date );
802
803 $year = gmdate( 'Y', strtotime( $date_filter ) );
804 $month = gmdate( 'm', strtotime( $date_filter ) );
805 $day = gmdate( 'd', strtotime( $date_filter ) );
806
807 // Add date query.
808 if ( '' !== $date_filter ) {
809 $args['date_query'] = array(
810 array(
811 'year' => $year,
812 'month' => $month,
813 'day' => $day,
814 ),
815 );
816 }
817
818 if ( '' !== $order_id ) {
819 $args['p'] = $order_id;
820 }
821
822 // Search filter.
823 if ( '' !== $search_term ) {
824 $args['s'] = $search_term;
825 }
826
827 $the_query = new \WP_Query( $args );
828
829 return ! is_null( $the_query ) && isset( $the_query->found_posts ) ? $the_query->found_posts : $the_query;
830 }
831
832 /**
833 * Handle order bulk action
834 *
835 * @since 3.0.0
836 *
837 * @return void send wp_json response
838 */
839 public function bulk_action_handler() {
840
841 tutor_utils()->checking_nonce();
842
843 // Check if user is privileged.
844 if ( ! current_user_can( 'administrator' ) ) {
845 wp_send_json_error( tutor_utils()->error_message() );
846 }
847
848 $request = Input::sanitize_array( $_POST ); //phpcs:ignore -- already sanitized.
849 $bulk_action = $request['bulk-action'];
850
851 $bulk_ids = isset( $request['bulk-ids'] ) ? array_map( 'intval', explode( ',', $request['bulk-ids'] ) ) : array();
852
853 $allowed_bulk_actions = array(
854 $this->model::PAYMENT_PAID,
855 $this->model::PAYMENT_UNPAID,
856 $this->model::ORDER_TRASH,
857 'delete',
858 );
859
860 if ( ! in_array( $bulk_action, $allowed_bulk_actions, true ) ) {
861 wp_send_json_error( __( 'Please select appropriate action', 'tutor' ) );
862 }
863
864 if ( empty( $bulk_ids ) ) {
865 wp_send_json_error( __( 'No items selected for the bulk action.', 'tutor' ) );
866 }
867
868 do_action( 'tutor_before_order_bulk_action', $bulk_action, $bulk_ids );
869
870 $response = false;
871 if ( 'delete' === $bulk_action ) {
872 $response = $this->model->delete_order( $bulk_ids );
873 } else {
874 $data = null;
875
876 switch ( $bulk_action ) {
877 case $this->model::PAYMENT_PAID:
878 $data = array(
879 'payment_status' => $this->model::PAYMENT_PAID,
880 'order_status' => $this->model::ORDER_COMPLETED,
881 );
882 break;
883 case $this->model::PAYMENT_UNPAID:
884 $data = array(
885 'payment_status' => $this->model::PAYMENT_UNPAID,
886 'order_status' => $this->model::ORDER_INCOMPLETE,
887 );
888 break;
889 case $this->model::ORDER_TRASH:
890 $data = array(
891 'order_status' => $this->model::ORDER_TRASH,
892 );
893 break;
894 default:
895 // code...
896 break;
897 }
898
899 if ( ! empty( $data ) ) {
900 $response = $this->model->update_order( $bulk_ids, $data );
901 }
902 }
903
904 if ( $response ) {
905 if ( 'delete' !== $bulk_action ) {
906 foreach ( $bulk_ids as $id ) {
907 do_action( 'tutor_order_payment_status_changed', $id, '', $bulk_action );
908 }
909 }
910 wp_send_json_success( __( 'Order updated successfully.', 'tutor' ) );
911 } else {
912 wp_send_json_error( __( 'Failed to update order.', 'tutor' ) );
913 }
914 }
915
916 /**
917 * Execute bulk delete action
918 *
919 * @param string $bulk_ids ids that need to update.
920 * @return bool
921 * @since 3.0.0
922 */
923 public function bulk_delete_order( $bulk_ids ): bool {
924 $bulk_ids = explode( ',', sanitize_text_field( $bulk_ids ) );
925
926 $response = false;
927 try {
928 $response = QueryHelper::bulk_delete_by_ids( $this->model->get_table_name(), $bulk_ids );
929 } catch ( \Throwable $th ) {
930 error_log( $th->getMessage() . ' Line: ' . $th->getLine() . ' File: ' . $th->getFile() );
931 }
932
933 return $response;
934 }
935
936 /**
937 * Update order status
938 *
939 * @param string $status for updating order status.
940 * @param string $bulk_ids comma separated ids.
941 *
942 * @return bool
943 *
944 * @since 3.0.0
945 */
946 public static function update_order_status( string $status, $bulk_ids ): bool {
947 global $wpdb;
948 $post_table = $wpdb->posts;
949 $status = sanitize_text_field( $status );
950 $bulk_ids = sanitize_text_field( $bulk_ids );
951
952 $ids = array_map( 'intval', explode( ',', $bulk_ids ) );
953 $in_clause = QueryHelper::prepare_in_clause( $ids );
954
955 $update = $wpdb->query(
956 $wpdb->prepare(
957 "UPDATE {$post_table} SET post_status = %s WHERE ID IN ($in_clause)", //phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
958 $status
959 )
960 );
961
962 return true;
963 }
964
965 /**
966 * Get orders
967 *
968 * @since 3.0.0
969 *
970 * @param integer $limit List limit.
971 * @param integer $offset List offset.
972 *
973 * @return array
974 */
975 public function get_orders( $limit = 10, $offset = 0 ) {
976
977 $active_tab = Input::get( 'data', 'all' );
978
979 $date = Input::get( 'date', '' );
980 $search_term = Input::get( 'search', '' );
981 $payment_status = Input::get( 'payment-status', '' );
982
983 $where_clause = array(
984 'order_type' => OrderModel::TYPE_SINGLE_ORDER,
985 );
986
987 if ( $date ) {
988 $where_clause['date(o.created_at_gmt)'] = tutor_get_formated_date( '', $date );
989 }
990
991 if ( $payment_status ) {
992 $where_clause['o.payment_status'] = $payment_status;
993 }
994
995 if ( 'all' !== $active_tab ) {
996 $where_clause['o.order_status'] = $active_tab;
997 }
998
999 $list_order = Input::get( 'order', 'DESC' );
1000 $list_order_by = 'id';
1001
1002 return $this->model->get_orders( $where_clause, $search_term, $limit, $offset, $list_order_by, $list_order );
1003 }
1004
1005 /**
1006 * Filter discount data if monetization is Tutor
1007 *
1008 * @since 3.0.0
1009 *
1010 * @param int $user_id Current user id.
1011 * @param string $period Period for filter refund data.
1012 * @param string $start_date Filter start date.
1013 * @param string $end_date Filter end date.
1014 * @param int $course_id Course id.
1015 *
1016 * @return array
1017 */
1018 public function get_discount_data( $user_id = 0, $period = '', $start_date = '', $end_date = '', $course_id = 0 ) {
1019 // Sanitize params.
1020 $user_id = is_admin() ? 0 : $user_id;
1021 $period = Input::sanitize( $period );
1022 $start_date = Input::sanitize( $start_date );
1023 $end_date = Input::sanitize( $end_date );
1024 $course_id = (int) $course_id;
1025
1026 return $this->model->get_discounts_by_user( $user_id, $period, $start_date, $end_date, $course_id );
1027 }
1028
1029 /**
1030 * Filter refund data if monetization is Tutor
1031 *
1032 * @since 3.0.0
1033 *
1034 * @param int $user_id Current user id.
1035 * @param string $period Period for filter refund data.
1036 * @param string $start_date Filter start date.
1037 * @param string $end_date Filter end date.
1038 * @param int $course_id Course id.
1039 *
1040 * @return array
1041 */
1042 public function get_refund_data( $user_id = 0, $period = '', $start_date = '', $end_date = '', $course_id = 0 ) {
1043 // Sanitize params.
1044 $user_id = is_admin() ? 0 : $user_id;
1045 $period = Input::sanitize( $period );
1046 $start_date = Input::sanitize( $start_date );
1047 $end_date = Input::sanitize( $end_date );
1048 $course_id = (int) $course_id;
1049
1050 return $this->model->get_refunds_by_user( $user_id, $period, $start_date, $end_date, $course_id );
1051 }
1052
1053 /**
1054 * Validate input data based on predefined rules.
1055 *
1056 * This protected method validates the provided data array against a set of
1057 * predefined validation rules. The rules specify that 'order_id' is required
1058 * and must be numeric. The method will skip validation rules for fields that
1059 * are not present in the data array.
1060 *
1061 * @since 3.0.0
1062 *
1063 * @param array $data The data array to validate.
1064 *
1065 * @return object The validation result. It returns validation object.
1066 */
1067 protected function validate( array $data ) {
1068
1069 $validation_rules = array(
1070 'order_id' => 'required|numeric',
1071 'meta_key' => 'required',
1072 'meta_value' => 'required',
1073 'discount_type' => 'required',
1074 'discount_amount' => 'required',
1075 );
1076
1077 // Skip validation rules for not available fields in data.
1078 foreach ( $validation_rules as $key => $value ) {
1079 if ( ! array_key_exists( $key, $data ) ) {
1080 unset( $validation_rules[ $key ] );
1081 }
1082 }
1083
1084 return ValidationHelper::validate( $validation_rules, $data );
1085 }
1086
1087 /**
1088 * Process refund from payment gateway
1089 *
1090 * @since 3.1.0
1091 *
1092 * @param int $order_id Order id.
1093 * @param string $amount Refund amount.
1094 * @param string $reason Refund reason.
1095 *
1096 * @throws \Throwable If an error occurs during the refund process.
1097 *
1098 * @return void
1099 */
1100 public function refund_from_payment_gateway( $order_id, $amount, $reason ) {
1101 $order = $this->model->get_order_by_id( $order_id );
1102 if ( $order && ! $this->model->is_manual_payment( $order->payment_method ) ) {
1103 $refund_data = $this->prepare_refund_data( $order, $amount, $reason );
1104 try {
1105 $payment_gateway_ref = Ecommerce::payment_gateways_with_ref( $order->payment_method );
1106 if ( $payment_gateway_ref ) {
1107 $gateway_obj = Ecommerce::get_payment_gateway_object( $payment_gateway_ref['gateway_class'] );
1108 $gateway_obj->make_refund( $refund_data );
1109 }
1110 } catch ( \Throwable $th ) {
1111 throw $th;
1112 }
1113 }
1114 }
1115
1116 /**
1117 * Prepare refund data
1118 *
1119 * @since 3.1.0
1120 *
1121 * @param object $order Order object.
1122 * @param string $amount Raw amount.
1123 * @param string $reason Refund reason.
1124 *
1125 * @return object
1126 */
1127 public function prepare_refund_data( $order, $amount, $reason ) {
1128 $currency = tutor_get_currencies_info_by_code( tutor_utils()->get_option( OptionKeys::CURRENCY_CODE ) );
1129
1130 $refund_data = array(
1131 'type' => 'refund',
1132 'amount' => $amount,
1133 'payment_payload' => $order->payment_payloads, // JSON string representing the payment payload.
1134 'order_id' => $order->id,
1135 'reason' => $reason,
1136 'refund_type' => $order->net_payment == $amount ? 'full' : 'partial',
1137 'currency' => (object) array(
1138 'code' => $currency['code'],
1139 'symbol' => $currency['symbol'],
1140 'name' => $currency['name'],
1141 'locale' => $currency['locale'],
1142 'numeric_code' => $currency['numeric_code'],
1143 ),
1144 );
1145
1146 return (object) $refund_data;
1147 }
1148
1149 /**
1150 * Prepare sanitized and calculated order data for database insertion or update.
1151 *
1152 * @since 3.9.2
1153 *
1154 * @param int $user_id The ID of the user placing or updating the order.
1155 * @param array $items The list of items included in the order.
1156 * @param string $payment_status The current payment status (e.g., 'paid', 'unpaid').
1157 * @param string $order_type The type of order (e.g., single, subscription, renewal).
1158 * @param string|null $coupon_code Optional coupon code applied to the order.
1159 * @param array $args Optional additional arguments, such as discounts or custom data.
1160 *
1161 * @return array The fully prepared order data array, ready to be stored or updated.
1162 *
1163 * @throws \Exception If the payment status is invalid or required data is missing.
1164 */
1165 private function prepare_order_data( int $user_id, array $items, string $payment_status, string $order_type, $coupon_code = null, array $args = array() ): array {
1166 $items = Input::sanitize_array( $items );
1167 $payment_status = Input::sanitize( $payment_status );
1168 $coupon_code = Input::sanitize( $coupon_code );
1169
1170 $allowed_item_fields = $this->model->get_order_items_fillable_fields();
1171 unset( $allowed_item_fields['order_id'] );
1172
1173 // Validate order items.
1174 if ( ! isset( $items[0] ) ) {
1175 $items = array( $items );
1176 }
1177
1178 // Validate payment status.
1179 if ( ! in_array( $payment_status, array_keys( $this->model->get_payment_status() ) ) ) {
1180 throw new \Exception( __( 'Invalid payment status', 'tutor' ) );
1181 }
1182
1183 $subtotal_price = 0;
1184 $total_price = 0;
1185
1186 if ( $this->model::TYPE_SINGLE_ORDER === $order_type ) {
1187 $item_price = $this->model::calculate_order_price( $items );
1188 $subtotal_price = $item_price->subtotal;
1189 $total_price = $item_price->total;
1190 } else {
1191 // For subscription and renewal order.
1192 $prices = apply_filters( 'tutor_create_order_prices_for_subscription', null, $items, $order_type, $user_id );
1193 if ( $prices ) {
1194 $subtotal_price = $prices->subtotal_price;
1195 $total_price = $prices->total_price;
1196 }
1197 }
1198
1199 $order_data = array(
1200 'items' => $items,
1201 'payment_status' => $payment_status,
1202 'order_type' => $order_type,
1203 'coupon_code' => $coupon_code,
1204 'coupon_amount' => isset( $args['coupon_amount'] ) ? $args['coupon_amount'] : null,
1205 'subtotal_price' => $subtotal_price,
1206 'pre_tax_price' => $total_price,
1207 'total_price' => $total_price,
1208 'net_payment' => $total_price,
1209 'user_id' => $user_id,
1210 'order_status' => $this->model::PAYMENT_PAID === $payment_status ? $this->model::ORDER_COMPLETED : $this->model::ORDER_INCOMPLETE,
1211 'created_at_gmt' => current_time( 'mysql', true ),
1212 'created_by' => get_current_user_id(),
1213 'updated_at_gmt' => current_time( 'mysql', true ),
1214 'updated_by' => get_current_user_id(),
1215 );
1216
1217 if ( isset( $args['discount_amount'] ) && $args['discount_amount'] > 0 ) {
1218 $order_data['discount_type'] = 'flat';
1219 $order_data['discount_amount'] = floatval( $args['discount_amount'] );
1220 $order_data['discount_reason'] = __( 'Sale discount', 'tutor' );
1221 }
1222
1223 $calculate_tax = apply_filters( 'tutor_calculate_order_tax', Tax::should_calculate_tax(), $args );
1224
1225 if ( $calculate_tax ) {
1226 /**
1227 * Tax calculation for order.
1228 */
1229 $tax_rate = Tax::get_user_tax_rate( $user_id );
1230 if ( $tax_rate ) {
1231 $tax_amount = apply_filters( 'tutor_calculate_order_tax_amount', 0, $total_price, $tax_rate, $order_type, $items );
1232
1233 $order_data['tax_type'] = Tax::get_tax_type();
1234 $order_data['tax_rate'] = $tax_rate;
1235 $order_data['tax_amount'] = $tax_amount;
1236
1237 if ( ! Tax::is_tax_included_in_price() ) {
1238 $total_price += $tax_amount;
1239 $order_data['total_price'] = $total_price;
1240 $order_data['net_payment'] = $total_price;
1241 } else {
1242 $order_data['pre_tax_price'] = $total_price - $tax_amount;
1243 }
1244 }
1245 }
1246
1247 return $order_data;
1248 }
1249
1250 /**
1251 * Update an existing order with validated and recalculated data.
1252 *
1253 * @since 3.9.2
1254 *
1255 * @param int $order_id The unique ID of the order to update.
1256 * @param int $user_id The ID of the user associated with the order.
1257 * @param array $items The list of items to update for the order.
1258 * @param string $payment_status The payment status (e.g., 'paid', 'unpaid').
1259 * @param string $order_type The order type (e.g., single, subscription, renewal).
1260 * @param string|null $coupon_code Optional coupon code applied to the order.
1261 * @param array $args Optional additional arguments (discounts, tax data, etc.).
1262 * @param bool $return_data Whether to return the updated order data on success.
1263 *
1264 * @return array|bool The updated order data if $return_data is true and the update succeeds,
1265 * or false otherwise.
1266 */
1267 public function update_order( int $order_id, int $user_id, array $items, string $payment_status, string $order_type, $coupon_code = null, array $args = array(), $return_data = false ) {
1268
1269 $update_data = $this->prepare_order_data( $user_id, $items, $payment_status, $order_type, $coupon_code, $args );
1270 $order_items = $update_data['items'];
1271
1272 unset( $update_data['items'], $update_data['created_at_gmt'], $update_data['created_by'] );
1273
1274 if ( $this->model->update_order( $order_id, $update_data, $order_items ) ) {
1275 $update_data['id'] = $order_id;
1276 $update_data['items'] = $order_items;
1277 do_action( 'tutor_order_updated', $update_data );
1278 return $return_data ? $update_data : true;
1279 }
1280
1281 return false;
1282 }
1283 }
1284