PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / trunk
Tutor LMS – eLearning and online course solution vtrunk
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 3 weeks ago AdminMenu.php 9 months ago BillingController.php 1 year ago CartController.php 1 year ago CheckoutController.php 2 months ago CouponController.php 5 months ago Ecommerce.php 1 year ago EmailController.php 11 months ago HooksHandler.php 2 months ago OptionKeys.php 1 year ago OrderActivitiesController.php 1 year ago OrderController.php 6 months ago PaymentHandler.php 9 months ago Settings.php 9 months ago Tax.php 9 months ago currency.php 5 months ago
OrderController.php
1283 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, 'ajax_get_order_details' ) );
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 ajax_get_order_details() {
262 tutor_utils()->check_nonce();
263 tutor_utils()->check_current_user_capability();
264
265 $order_id = Input::post( 'order_id' );
266
267 if ( empty( $order_id ) ) {
268 $this->json_response(
269 __( 'Order ID is required', 'tutor' ),
270 null,
271 HttpHelper::STATUS_BAD_REQUEST
272 );
273 }
274
275 $order_data = $this->model->get_order_by_id( $order_id );
276
277 if ( ! $order_data ) {
278 $this->json_response(
279 __( 'Order not found', 'tutor' ),
280 null,
281 HttpHelper::STATUS_NOT_FOUND
282 );
283 }
284
285 $this->json_response(
286 __( 'Order retrieved successfully', 'tutor' ),
287 $order_data
288 );
289 }
290
291 /**
292 * Mark an order as paid.
293 *
294 * This function verifies a nonce for security, constructs a payload object with
295 * the order ID, note, and payment status, and updates the payment status of the order
296 * to 'paid'. It sends a JSON response indicating the success or failure of the operation.
297 *
298 * @since 3.0.0
299 *
300 * @return void
301 */
302 public function order_mark_as_paid() {
303 tutor_utils()->check_nonce();
304
305 if ( ! current_user_can( 'manage_options' ) ) {
306 $this->json_response( tutor_utils()->error_message( HttpHelper::STATUS_UNAUTHORIZED ), null, HttpHelper::STATUS_UNAUTHORIZED );
307 }
308
309 $params = array(
310 'order_id' => Input::post( 'order_id' ),
311 'note' => Input::post( 'note' ),
312 );
313
314 // Validate request.
315 $validation = $this->validate( $params );
316 if ( ! $validation->success ) {
317 $this->json_response(
318 tutor_utils()->error_message( HttpHelper::STATUS_BAD_REQUEST ),
319 $validation->errors,
320 HttpHelper::STATUS_BAD_REQUEST
321 );
322 }
323
324 $order_id = (int) $params['order_id'];
325
326 $updated = $this->model->mark_as_paid( $order_id, $params['note'] );
327
328 if ( ! $updated ) {
329 $this->json_response(
330 __( 'Failed to update order payment status', 'tutor' ),
331 null,
332 HttpHelper::STATUS_INTERNAL_SERVER_ERROR
333 );
334 }
335
336 $this->json_response( __( 'Order payment status successfully updated', 'tutor' ) );
337 }
338
339 /**
340 * Cancels an order.
341 *
342 * This function cancels an order by updating its status to 'cancelled'. It performs nonce verification
343 * and checks the user's permissions before proceeding. It also validates the input parameters and
344 * triggers actions before and after the order cancellation.
345 *
346 * The function responds with an appropriate JSON message depending on the outcome of the cancellation process.
347 *
348 * @since 3.0.0
349 *
350 * @return void Responds with a JSON message indicating success or failure.
351 */
352 public function order_cancel() {
353 if ( ! tutor_utils()->is_nonce_verified() ) {
354 $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
355 }
356
357 if ( ! current_user_can( 'manage_options' ) ) {
358 $this->json_response( tutor_utils()->error_message( HttpHelper::STATUS_UNAUTHORIZED ), null, HttpHelper::STATUS_UNAUTHORIZED );
359 }
360
361 $params = array(
362 'id' => Input::post( 'order_id' ),
363 'order_status' => $this->model::ORDER_CANCELLED,
364 );
365
366 // Validate request.
367 $validation = $this->validate( $params );
368 if ( ! $validation->success ) {
369 $this->json_response(
370 tutor_utils()->error_message( HttpHelper::STATUS_BAD_REQUEST ),
371 $validation->errors,
372 HttpHelper::STATUS_BAD_REQUEST
373 );
374 }
375
376 do_action( 'tutor_before_order_cancel', $params );
377
378 $response = $this->model->update_order( $params['id'], $params );
379 if ( ! $response ) {
380 $this->json_response(
381 __( 'Failed to cancel order status', 'tutor' ),
382 null,
383 HttpHelper::STATUS_INTERNAL_SERVER_ERROR
384 );
385 }
386
387 do_action( 'tutor_order_payment_status_changed', $params['id'], '', $this->model::ORDER_CANCELLED );
388
389 $this->json_response( __( 'Order successfully canceled', 'tutor' ) );
390 }
391
392 /**
393 * Handle order refund process.
394 *
395 * This method processes the refund for an order. It verifies the nonce and user capabilities,
396 * triggers necessary actions before and after the refund process, validates input data, and
397 * interacts with the OrderActivitiesModel to record the refund metadata. If any validation
398 * fails or the refund process encounters an error, it returns an appropriate JSON response.
399 *
400 * @since 3.0.0
401 *
402 * @return void
403 */
404 public function make_refund() {
405 if ( ! tutor_utils()->is_nonce_verified() ) {
406 $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
407 }
408
409 if ( ! current_user_can( 'manage_options' ) ) {
410 $this->json_response( tutor_utils()->error_message( HttpHelper::STATUS_UNAUTHORIZED ), null, HttpHelper::STATUS_UNAUTHORIZED );
411 }
412
413 $order_id = Input::post( 'order_id', 0, Input::TYPE_INT );
414 $amount = (float) Input::post( 'amount' );
415 $reason = Input::post( 'reason' );
416 $cancel_enrollment = Input::post( 'is_remove_enrolment', false, Input::TYPE_BOOL );
417
418 if ( $amount <= 0 ) {
419 $this->json_response( __( 'Invalid refund amount provided', 'tutor' ), null, HttpHelper::STATUS_BAD_REQUEST );
420 }
421
422 $meta_value = array(
423 'amount' => $amount,
424 'reason' => $reason,
425 'message' => __( 'Order refunded by ', 'tutor' ) . get_userdata( get_current_user_id() )->display_name,
426 );
427
428 $order_data = $this->model->get_order_by_id( $order_id );
429 $cancel_enrollment = apply_filters( 'tutor_cancel_enrollment_on_refund', $cancel_enrollment, $order_data );
430
431 if ( $amount > (float) $order_data->net_payment ) {
432 $this->json_response(
433 __( 'Refund amount exceeded.', 'tutor' ),
434 null,
435 HttpHelper::STATUS_BAD_REQUEST
436 );
437 }
438
439 $order_status = $order_data->order_status;
440 $payment_status = $order_data->payment_status;
441
442 try {
443 do_action( 'tutor_before_initiate_refund', $order_data );
444 } catch ( \Throwable $th ) {
445 $this->json_response(
446 $th->getMessage(),
447 '',
448 HttpHelper::STATUS_BAD_REQUEST
449 );
450 }
451
452 $meta_key = OrderActivitiesModel::META_KEY_REFUND;
453 if ( $amount < (float) $order_data->net_payment ) {
454 $meta_key = OrderActivitiesModel::META_KEY_PARTIALLY_REFUND;
455 }
456
457 if ( OrderActivitiesModel::META_KEY_PARTIALLY_REFUND === $meta_key ) {
458 $meta_value['message'] = __( 'Partially refunded by ', 'tutor' ) . get_userdata( get_current_user_id() )->display_name;
459 }
460
461 $params = array(
462 'order_id' => $order_id,
463 'meta_key' => $meta_key,
464 'meta_value' => wp_json_encode( $meta_value ),
465 );
466
467 // Validate request.
468 $validation = $this->validate( $params );
469 if ( ! $validation->success ) {
470 $this->json_response(
471 tutor_utils()->error_message( HttpHelper::STATUS_BAD_REQUEST ),
472 $validation->errors,
473 HttpHelper::STATUS_BAD_REQUEST
474 );
475 }
476
477 $payload = new \stdClass();
478 $payload->order_id = $params['order_id'];
479 $payload->meta_key = $params['meta_key'];
480 $payload->meta_value = $params['meta_value'];
481
482 $activity_model = new OrderActivitiesModel();
483 $response = $activity_model->add_order_meta( $payload );
484
485 if ( $response ) {
486 // Update net payment.
487 $refund_amount = $this->model->get_refund_amount( $order_id );
488
489 $net_payment = floatval( $order_data->total_price ) - floatval( $refund_amount );
490 if ( 'refund' === $meta_key ) {
491 $payment_status = $this->model::PAYMENT_REFUNDED;
492 } else {
493 $payment_status = $this->model::PAYMENT_PARTIALLY_REFUNDED;
494 }
495
496 if ( $cancel_enrollment ) {
497 $order_status = $this->model::ORDER_CANCELLED;
498 }
499
500 $update_data = array(
501 'net_payment' => $net_payment,
502 'refund_amount' => $refund_amount,
503 'payment_status' => $payment_status,
504 'order_status' => $order_status,
505 );
506
507 $this->model->update_order( $order_id, $update_data );
508
509 do_action( 'tutor_order_payment_status_changed', $order_data->id, $order_data->payment_status, $payment_status );
510
511 $order_data->payment_status = $update_data['payment_status'];
512 $order_data->order_status = $update_data['order_status'];
513 do_action( 'tutor_after_order_refund', $order_id, $amount, $reason );
514
515 $res_msg = __( 'Order refund successful', 'tutor' );
516
517 try {
518 $this->refund_from_payment_gateway( $order_id, $amount, $reason );
519 $this->json_response( $res_msg );
520 } catch ( \Throwable $th ) {
521 $res_msg = __( 'Order refunded successfully, but pending payment gateway issuance.', 'tutor' );
522 $this->json_response( $res_msg );
523 }
524 } else {
525 $this->json_response(
526 __( 'Failed to make refund', 'tutor' ),
527 null,
528 HttpHelper::STATUS_INTERNAL_SERVER_ERROR
529 );
530 }
531 }
532
533 /**
534 * Handle adding a comment to an order.
535 *
536 * This method processes the addition of a comment to an order. It verifies the nonce and user capabilities,
537 * triggers necessary actions before and after the comment addition, validates input data, and
538 * interacts with the OrderActivitiesModel to record the comment metadata. If any validation
539 * fails or the comment addition process encounters an error, it returns an appropriate JSON response.
540 *
541 * @since 3.0.0
542 *
543 * @return void
544 */
545 public function add_comment() {
546 if ( ! tutor_utils()->is_nonce_verified() ) {
547 $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
548 }
549
550 if ( ! current_user_can( 'manage_options' ) ) {
551 $this->json_response( tutor_utils()->error_message( HttpHelper::STATUS_UNAUTHORIZED ), null, HttpHelper::STATUS_UNAUTHORIZED );
552 }
553
554 $params = array(
555 'order_id' => Input::post( 'order_id' ),
556 'meta_key' => OrderActivitiesModel::META_KEY_COMMENT,
557 'meta_value' => Input::post( 'comment' ),
558 );
559
560 do_action( 'tutor_before_order_comment', $params );
561
562 // Validate request.
563 $validation = $this->validate( $params );
564 if ( ! $validation->success ) {
565 $this->json_response(
566 tutor_utils()->error_message( HttpHelper::STATUS_BAD_REQUEST ),
567 $validation->errors,
568 HttpHelper::STATUS_BAD_REQUEST
569 );
570 }
571
572 $payload = new \stdClass();
573 $payload->order_id = $params['order_id'];
574 $payload->meta_key = $params['meta_key'];
575 $payload->meta_value = $params['meta_value'];
576
577 $activity_model = new OrderActivitiesModel();
578 $response = $activity_model->add_order_meta( $payload );
579
580 do_action( 'tutor_after_order_comment', $params );
581
582 if ( ! $response ) {
583 $this->json_response(
584 __( 'Failed to make a comment', 'tutor' ),
585 null,
586 HttpHelper::STATUS_INTERNAL_SERVER_ERROR
587 );
588 }
589
590 $this->json_response( __( 'Order comment successful added', 'tutor' ) );
591 }
592
593 /**
594 * Add a discount to an order.
595 *
596 * This function handles the request to add a discount to an order. It verifies the nonce,
597 * checks user permissions, validates the input, and then updates the order with the discount details.
598 *
599 * @since 3.0.0
600 *
601 * @return void This function outputs a JSON response and does not return a value.
602 */
603 public function add_discount() {
604 if ( ! tutor_utils()->is_nonce_verified() ) {
605 $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
606 }
607
608 if ( ! current_user_can( 'manage_options' ) ) {
609 $this->json_response( tutor_utils()->error_message( HttpHelper::STATUS_UNAUTHORIZED ), null, HttpHelper::STATUS_UNAUTHORIZED );
610 }
611
612 $request = Input::sanitize_array( $_POST ); //phpcs:ignore --already sanitized.
613
614 // Validate request.
615 $validation = $this->validate( $request );
616 if ( ! $validation->success ) {
617 $this->json_response(
618 tutor_utils()->error_message( HttpHelper::STATUS_BAD_REQUEST ),
619 $validation->errors,
620 HttpHelper::STATUS_BAD_REQUEST
621 );
622 }
623
624 $request = (object) $request;
625
626 try {
627 $order = $this->model->get_order_by_id( $request->order_id );
628 $subtotal = $order->subtotal_price;
629
630 $discount_amount = $this->model->calculate_discount_amount( $request->discount_type, $request->discount_amount, $subtotal );
631
632 $deducted_amount = $discount_amount;
633 if ( ! empty( $order->coupon_code ) && $order->coupon_amount > 0 ) {
634 $deducted_amount += $order->coupon_amount;
635 }
636
637 $total_price = $subtotal - $deducted_amount;
638
639 $tax_rate = Tax::get_user_tax_rate( $order->user_id );
640 $tax_amount = Tax::calculate_tax( $total_price, $tax_rate );
641 if ( ! Tax::is_tax_included_in_price() ) {
642 $total_price += $tax_amount;
643 }
644
645 $order_data = array(
646 'discount_amount' => $request->discount_amount,
647 'discount_reason' => $request->discount_reason,
648 'discount_type' => $request->discount_type,
649 'subtotal_price' => $subtotal,
650 'tax_rate' => $tax_rate,
651 'tax_amount' => $tax_amount,
652 'net_payment' => $total_price,
653 'total_price' => $total_price,
654 );
655
656 $update = $this->model->update_order( $request->order_id, $order_data );
657 if ( ! $update ) {
658 $this->json_response(
659 __( 'Failed to add a discount', 'tutor' ),
660 null,
661 HttpHelper::STATUS_INTERNAL_SERVER_ERROR
662 );
663 }
664
665 do_action( 'tutor_after_add_order_discount', $order, $discount_amount );
666
667 $this->json_response( __( 'Order discount successful added', 'tutor' ) );
668 } catch ( \Throwable $th ) {
669 $this->json_response(
670 __( 'Failed to add a discount', 'tutor' ),
671 $th->getMessage(),
672 HttpHelper::STATUS_INTERNAL_SERVER_ERROR
673 );
674 }
675 }
676
677 /**
678 * Prepare bulk actions that will show on dropdown options
679 *
680 * @return array
681 * @since 3.0.0
682 */
683 public function prepare_bulk_actions(): array {
684 $actions = array(
685 $this->bulk_action_default(),
686 );
687
688 $active_tab = Input::get( 'data', '' );
689
690 if ( $this->model::ORDER_TRASH !== $active_tab ) {
691 $actions[] = $this->bulk_action_mark_order_trash();
692 }
693
694 if ( ! empty( $active_tab ) ) {
695 switch ( $active_tab ) {
696 case $this->model::ORDER_INCOMPLETE:
697 $actions[] = $this->bulk_action_mark_order_paid();
698 break;
699 case $this->model::ORDER_COMPLETED:
700 $actions[] = $this->bulk_action_mark_order_unpaid();
701 break;
702 case $this->model::ORDER_TRASH:
703 $actions[] = $this->bulk_action_delete();
704 break;
705 default:
706 // code...
707 break;
708 }
709 }
710
711 return apply_filters( 'tutor_order_bulk_actions', $actions );
712 }
713
714 /**
715 * Available tabs that will visible on the right side of page navbar
716 *
717 * @return array
718 *
719 * @since 3.0.0
720 */
721 public function tabs_key_value(): array {
722 $url = apply_filters( 'tutor_data_tab_base_url', get_pagenum_link() );
723
724 $date = Input::get( 'date', '' );
725 $payment_status = Input::get( 'payment-status', '' );
726 $search = Input::get( 'search', '' );
727
728 $where = array(
729 'order_type' => OrderModel::TYPE_SINGLE_ORDER,
730 );
731
732 if ( ! empty( $date ) ) {
733 $where['date(o.created_at_gmt)'] = tutor_get_formated_date( '', $date );
734 }
735
736 if ( ! empty( $payment_status ) ) {
737 $where['o.payment_status'] = $payment_status;
738 }
739
740 $order_status = $this->model->get_order_status();
741
742 $tabs = array();
743
744 $tabs [] = array(
745 'key' => '',
746 'title' => __( 'All', 'tutor' ),
747 'value' => $this->model->get_order_count( $where, $search ),
748 'url' => $url . '&data=all',
749 );
750
751 foreach ( $order_status as $key => $value ) {
752 $where['order_status'] = $key;
753
754 $tabs[] = array(
755 'key' => $key,
756 'title' => $value,
757 'value' => $this->model->get_order_count( $where, $search ),
758 'url' => $url . '&data=' . $key,
759 );
760 }
761
762 return apply_filters( 'tutor_order_tabs', $tabs );
763 }
764
765 /**
766 * Count orders by status & filters
767 * Count all | min | published | pending | draft
768 *
769 * @param string $status | required.
770 * @param string $order_id selected order id | optional.
771 * @param string $date selected date | optional.
772 * @param string $search_term search by user name or email | optional.
773 *
774 * @return int
775 *
776 * @since 3.0.0
777 */
778 protected static function count_order( string $status, $order_id = '', $date = '', $search_term = '' ): int {
779 $user_id = get_current_user_id();
780 $status = sanitize_text_field( $status );
781 $order_id = sanitize_text_field( $order_id );
782 $date = sanitize_text_field( $date );
783 $search_term = sanitize_text_field( $search_term );
784
785 $args = array(
786 'post_type' => tutor()->order_post_type,
787 );
788
789 if ( 'all' === $status || 'mine' === $status ) {
790 $args['post_status'] = array( 'publish', 'pending', 'draft', 'private', 'future' );
791 } else {
792 $args['post_status'] = array( $status );
793 }
794
795 // Author query.
796 if ( 'mine' === $status || ! current_user_can( 'administrator' ) ) {
797 $args['author'] = $user_id;
798 }
799
800 $date_filter = sanitize_text_field( $date );
801
802 $year = gmdate( 'Y', strtotime( $date_filter ) );
803 $month = gmdate( 'm', strtotime( $date_filter ) );
804 $day = gmdate( 'd', strtotime( $date_filter ) );
805
806 // Add date query.
807 if ( '' !== $date_filter ) {
808 $args['date_query'] = array(
809 array(
810 'year' => $year,
811 'month' => $month,
812 'day' => $day,
813 ),
814 );
815 }
816
817 if ( '' !== $order_id ) {
818 $args['p'] = $order_id;
819 }
820
821 // Search filter.
822 if ( '' !== $search_term ) {
823 $args['s'] = $search_term;
824 }
825
826 $the_query = new \WP_Query( $args );
827
828 return ! is_null( $the_query ) && isset( $the_query->found_posts ) ? $the_query->found_posts : $the_query;
829 }
830
831 /**
832 * Handle order bulk action
833 *
834 * @since 3.0.0
835 *
836 * @return void send wp_json response
837 */
838 public function bulk_action_handler() {
839
840 tutor_utils()->checking_nonce();
841
842 // Check if user is privileged.
843 if ( ! current_user_can( 'administrator' ) ) {
844 wp_send_json_error( tutor_utils()->error_message() );
845 }
846
847 $request = Input::sanitize_array( $_POST ); //phpcs:ignore -- already sanitized.
848 $bulk_action = $request['bulk-action'];
849
850 $bulk_ids = isset( $request['bulk-ids'] ) ? array_map( 'intval', explode( ',', $request['bulk-ids'] ) ) : array();
851
852 $allowed_bulk_actions = array(
853 $this->model::PAYMENT_PAID,
854 $this->model::PAYMENT_UNPAID,
855 $this->model::ORDER_TRASH,
856 'delete',
857 );
858
859 if ( ! in_array( $bulk_action, $allowed_bulk_actions, true ) ) {
860 wp_send_json_error( __( 'Please select appropriate action', 'tutor' ) );
861 }
862
863 if ( empty( $bulk_ids ) ) {
864 wp_send_json_error( __( 'No items selected for the bulk action.', 'tutor' ) );
865 }
866
867 do_action( 'tutor_before_order_bulk_action', $bulk_action, $bulk_ids );
868
869 $response = false;
870 if ( 'delete' === $bulk_action ) {
871 $response = $this->model->delete_order( $bulk_ids );
872 } else {
873 $data = null;
874
875 switch ( $bulk_action ) {
876 case $this->model::PAYMENT_PAID:
877 $data = array(
878 'payment_status' => $this->model::PAYMENT_PAID,
879 'order_status' => $this->model::ORDER_COMPLETED,
880 );
881 break;
882 case $this->model::PAYMENT_UNPAID:
883 $data = array(
884 'payment_status' => $this->model::PAYMENT_UNPAID,
885 'order_status' => $this->model::ORDER_INCOMPLETE,
886 );
887 break;
888 case $this->model::ORDER_TRASH:
889 $data = array(
890 'order_status' => $this->model::ORDER_TRASH,
891 );
892 break;
893 default:
894 // code...
895 break;
896 }
897
898 if ( ! empty( $data ) ) {
899 $response = $this->model->update_order( $bulk_ids, $data );
900 }
901 }
902
903 if ( $response ) {
904 if ( 'delete' !== $bulk_action ) {
905 foreach ( $bulk_ids as $id ) {
906 do_action( 'tutor_order_payment_status_changed', $id, '', $bulk_action );
907 }
908 }
909 wp_send_json_success( __( 'Order updated successfully.', 'tutor' ) );
910 } else {
911 wp_send_json_error( __( 'Failed to update order.', 'tutor' ) );
912 }
913 }
914
915 /**
916 * Execute bulk delete action
917 *
918 * @param string $bulk_ids ids that need to update.
919 * @return bool
920 * @since 3.0.0
921 */
922 public function bulk_delete_order( $bulk_ids ): bool {
923 $bulk_ids = explode( ',', sanitize_text_field( $bulk_ids ) );
924
925 $response = false;
926 try {
927 $response = QueryHelper::bulk_delete_by_ids( $this->model->get_table_name(), $bulk_ids );
928 } catch ( \Throwable $th ) {
929 error_log( $th->getMessage() . ' Line: ' . $th->getLine() . ' File: ' . $th->getFile() );
930 }
931
932 return $response;
933 }
934
935 /**
936 * Update order status
937 *
938 * @param string $status for updating order status.
939 * @param string $bulk_ids comma separated ids.
940 *
941 * @return bool
942 *
943 * @since 3.0.0
944 */
945 public static function update_order_status( string $status, $bulk_ids ): bool {
946 global $wpdb;
947 $post_table = $wpdb->posts;
948 $status = sanitize_text_field( $status );
949 $bulk_ids = sanitize_text_field( $bulk_ids );
950
951 $ids = array_map( 'intval', explode( ',', $bulk_ids ) );
952 $in_clause = QueryHelper::prepare_in_clause( $ids );
953
954 $update = $wpdb->query(
955 $wpdb->prepare(
956 "UPDATE {$post_table} SET post_status = %s WHERE ID IN ($in_clause)", //phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
957 $status
958 )
959 );
960
961 return true;
962 }
963
964 /**
965 * Get orders
966 *
967 * @since 3.0.0
968 *
969 * @param integer $limit List limit.
970 * @param integer $offset List offset.
971 *
972 * @return array
973 */
974 public function get_orders( $limit = 10, $offset = 0 ) {
975
976 $active_tab = Input::get( 'data', 'all' );
977
978 $date = Input::get( 'date', '' );
979 $search_term = Input::get( 'search', '' );
980 $payment_status = Input::get( 'payment-status', '' );
981
982 $where_clause = array(
983 'order_type' => OrderModel::TYPE_SINGLE_ORDER,
984 );
985
986 if ( $date ) {
987 $where_clause['date(o.created_at_gmt)'] = tutor_get_formated_date( '', $date );
988 }
989
990 if ( $payment_status ) {
991 $where_clause['o.payment_status'] = $payment_status;
992 }
993
994 if ( 'all' !== $active_tab ) {
995 $where_clause['o.order_status'] = $active_tab;
996 }
997
998 $list_order = Input::get( 'order', 'DESC' );
999 $list_order_by = 'id';
1000
1001 return $this->model->get_orders( $where_clause, $search_term, $limit, $offset, $list_order_by, $list_order );
1002 }
1003
1004 /**
1005 * Filter discount data if monetization is Tutor
1006 *
1007 * @since 3.0.0
1008 *
1009 * @param int $user_id Current user id.
1010 * @param string $period Period for filter refund data.
1011 * @param string $start_date Filter start date.
1012 * @param string $end_date Filter end date.
1013 * @param int $course_id Course id.
1014 *
1015 * @return array
1016 */
1017 public function get_discount_data( $user_id = 0, $period = '', $start_date = '', $end_date = '', $course_id = 0 ) {
1018 // Sanitize params.
1019 $user_id = is_admin() ? 0 : $user_id;
1020 $period = Input::sanitize( $period );
1021 $start_date = Input::sanitize( $start_date );
1022 $end_date = Input::sanitize( $end_date );
1023 $course_id = (int) $course_id;
1024
1025 return $this->model->get_discounts_by_user( $user_id, $period, $start_date, $end_date, $course_id );
1026 }
1027
1028 /**
1029 * Filter refund data if monetization is Tutor
1030 *
1031 * @since 3.0.0
1032 *
1033 * @param int $user_id Current user id.
1034 * @param string $period Period for filter refund data.
1035 * @param string $start_date Filter start date.
1036 * @param string $end_date Filter end date.
1037 * @param int $course_id Course id.
1038 *
1039 * @return array
1040 */
1041 public function get_refund_data( $user_id = 0, $period = '', $start_date = '', $end_date = '', $course_id = 0 ) {
1042 // Sanitize params.
1043 $user_id = is_admin() ? 0 : $user_id;
1044 $period = Input::sanitize( $period );
1045 $start_date = Input::sanitize( $start_date );
1046 $end_date = Input::sanitize( $end_date );
1047 $course_id = (int) $course_id;
1048
1049 return $this->model->get_refunds_by_user( $user_id, $period, $start_date, $end_date, $course_id );
1050 }
1051
1052 /**
1053 * Validate input data based on predefined rules.
1054 *
1055 * This protected method validates the provided data array against a set of
1056 * predefined validation rules. The rules specify that 'order_id' is required
1057 * and must be numeric. The method will skip validation rules for fields that
1058 * are not present in the data array.
1059 *
1060 * @since 3.0.0
1061 *
1062 * @param array $data The data array to validate.
1063 *
1064 * @return object The validation result. It returns validation object.
1065 */
1066 protected function validate( array $data ) {
1067
1068 $validation_rules = array(
1069 'order_id' => 'required|numeric',
1070 'meta_key' => 'required',
1071 'meta_value' => 'required',
1072 'discount_type' => 'required',
1073 'discount_amount' => 'required',
1074 );
1075
1076 // Skip validation rules for not available fields in data.
1077 foreach ( $validation_rules as $key => $value ) {
1078 if ( ! array_key_exists( $key, $data ) ) {
1079 unset( $validation_rules[ $key ] );
1080 }
1081 }
1082
1083 return ValidationHelper::validate( $validation_rules, $data );
1084 }
1085
1086 /**
1087 * Process refund from payment gateway
1088 *
1089 * @since 3.1.0
1090 *
1091 * @param int $order_id Order id.
1092 * @param string $amount Refund amount.
1093 * @param string $reason Refund reason.
1094 *
1095 * @throws \Throwable If an error occurs during the refund process.
1096 *
1097 * @return void
1098 */
1099 public function refund_from_payment_gateway( $order_id, $amount, $reason ) {
1100 $order = $this->model->get_order_by_id( $order_id );
1101 if ( $order && ! $this->model->is_manual_payment( $order->payment_method ) ) {
1102 $refund_data = $this->prepare_refund_data( $order, $amount, $reason );
1103 try {
1104 $payment_gateway_ref = Ecommerce::payment_gateways_with_ref( $order->payment_method );
1105 if ( $payment_gateway_ref ) {
1106 $gateway_obj = Ecommerce::get_payment_gateway_object( $payment_gateway_ref['gateway_class'] );
1107 $gateway_obj->make_refund( $refund_data );
1108 }
1109 } catch ( \Throwable $th ) {
1110 throw $th;
1111 }
1112 }
1113 }
1114
1115 /**
1116 * Prepare refund data
1117 *
1118 * @since 3.1.0
1119 *
1120 * @param object $order Order object.
1121 * @param string $amount Raw amount.
1122 * @param string $reason Refund reason.
1123 *
1124 * @return object
1125 */
1126 public function prepare_refund_data( $order, $amount, $reason ) {
1127 $currency = tutor_get_currencies_info_by_code( tutor_utils()->get_option( OptionKeys::CURRENCY_CODE ) );
1128
1129 $refund_data = array(
1130 'type' => 'refund',
1131 'amount' => $amount,
1132 'payment_payload' => $order->payment_payloads, // JSON string representing the payment payload.
1133 'order_id' => $order->id,
1134 'reason' => $reason,
1135 'refund_type' => $order->net_payment == $amount ? 'full' : 'partial',
1136 'currency' => (object) array(
1137 'code' => $currency['code'],
1138 'symbol' => $currency['symbol'],
1139 'name' => $currency['name'],
1140 'locale' => $currency['locale'],
1141 'numeric_code' => $currency['numeric_code'],
1142 ),
1143 );
1144
1145 return (object) $refund_data;
1146 }
1147
1148 /**
1149 * Prepare sanitized and calculated order data for database insertion or update.
1150 *
1151 * @since 3.9.2
1152 *
1153 * @param int $user_id The ID of the user placing or updating the order.
1154 * @param array $items The list of items included in the order.
1155 * @param string $payment_status The current payment status (e.g., 'paid', 'unpaid').
1156 * @param string $order_type The type of order (e.g., single, subscription, renewal).
1157 * @param string|null $coupon_code Optional coupon code applied to the order.
1158 * @param array $args Optional additional arguments, such as discounts or custom data.
1159 *
1160 * @return array The fully prepared order data array, ready to be stored or updated.
1161 *
1162 * @throws \Exception If the payment status is invalid or required data is missing.
1163 */
1164 private function prepare_order_data( int $user_id, array $items, string $payment_status, string $order_type, $coupon_code = null, array $args = array() ): array {
1165 $items = Input::sanitize_array( $items );
1166 $payment_status = Input::sanitize( $payment_status );
1167 $coupon_code = Input::sanitize( $coupon_code );
1168
1169 $allowed_item_fields = $this->model->get_order_items_fillable_fields();
1170 unset( $allowed_item_fields['order_id'] );
1171
1172 // Validate order items.
1173 if ( ! isset( $items[0] ) ) {
1174 $items = array( $items );
1175 }
1176
1177 // Validate payment status.
1178 if ( ! in_array( $payment_status, array_keys( $this->model->get_payment_status() ) ) ) {
1179 throw new \Exception( __( 'Invalid payment status', 'tutor' ) );
1180 }
1181
1182 $subtotal_price = 0;
1183 $total_price = 0;
1184
1185 if ( $this->model::TYPE_SINGLE_ORDER === $order_type ) {
1186 $item_price = $this->model::calculate_order_price( $items );
1187 $subtotal_price = $item_price->subtotal;
1188 $total_price = $item_price->total;
1189 } else {
1190 // For subscription and renewal order.
1191 $prices = apply_filters( 'tutor_create_order_prices_for_subscription', null, $items, $order_type, $user_id );
1192 if ( $prices ) {
1193 $subtotal_price = $prices->subtotal_price;
1194 $total_price = $prices->total_price;
1195 }
1196 }
1197
1198 $order_data = array(
1199 'items' => $items,
1200 'payment_status' => $payment_status,
1201 'order_type' => $order_type,
1202 'coupon_code' => $coupon_code,
1203 'coupon_amount' => isset( $args['coupon_amount'] ) ? $args['coupon_amount'] : null,
1204 'subtotal_price' => $subtotal_price,
1205 'pre_tax_price' => $total_price,
1206 'total_price' => $total_price,
1207 'net_payment' => $total_price,
1208 'user_id' => $user_id,
1209 'order_status' => $this->model::PAYMENT_PAID === $payment_status ? $this->model::ORDER_COMPLETED : $this->model::ORDER_INCOMPLETE,
1210 'created_at_gmt' => current_time( 'mysql', true ),
1211 'created_by' => get_current_user_id(),
1212 'updated_at_gmt' => current_time( 'mysql', true ),
1213 'updated_by' => get_current_user_id(),
1214 );
1215
1216 if ( isset( $args['discount_amount'] ) && $args['discount_amount'] > 0 ) {
1217 $order_data['discount_type'] = 'flat';
1218 $order_data['discount_amount'] = floatval( $args['discount_amount'] );
1219 $order_data['discount_reason'] = __( 'Sale discount', 'tutor' );
1220 }
1221
1222 $calculate_tax = apply_filters( 'tutor_calculate_order_tax', Tax::should_calculate_tax(), $args );
1223
1224 if ( $calculate_tax ) {
1225 /**
1226 * Tax calculation for order.
1227 */
1228 $tax_rate = Tax::get_user_tax_rate( $user_id );
1229 if ( $tax_rate ) {
1230 $tax_amount = apply_filters( 'tutor_calculate_order_tax_amount', 0, $total_price, $tax_rate, $order_type, $items );
1231
1232 $order_data['tax_type'] = Tax::get_tax_type();
1233 $order_data['tax_rate'] = $tax_rate;
1234 $order_data['tax_amount'] = $tax_amount;
1235
1236 if ( ! Tax::is_tax_included_in_price() ) {
1237 $total_price += $tax_amount;
1238 $order_data['total_price'] = $total_price;
1239 $order_data['net_payment'] = $total_price;
1240 } else {
1241 $order_data['pre_tax_price'] = $total_price - $tax_amount;
1242 }
1243 }
1244 }
1245
1246 return $order_data;
1247 }
1248
1249 /**
1250 * Update an existing order with validated and recalculated data.
1251 *
1252 * @since 3.9.2
1253 *
1254 * @param int $order_id The unique ID of the order to update.
1255 * @param int $user_id The ID of the user associated with the order.
1256 * @param array $items The list of items to update for the order.
1257 * @param string $payment_status The payment status (e.g., 'paid', 'unpaid').
1258 * @param string $order_type The order type (e.g., single, subscription, renewal).
1259 * @param string|null $coupon_code Optional coupon code applied to the order.
1260 * @param array $args Optional additional arguments (discounts, tax data, etc.).
1261 * @param bool $return_data Whether to return the updated order data on success.
1262 *
1263 * @return array|bool The updated order data if $return_data is true and the update succeeds,
1264 * or false otherwise.
1265 */
1266 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 ) {
1267
1268 $update_data = $this->prepare_order_data( $user_id, $items, $payment_status, $order_type, $coupon_code, $args );
1269 $order_items = $update_data['items'];
1270
1271 unset( $update_data['items'], $update_data['created_at_gmt'], $update_data['created_by'] );
1272
1273 if ( $this->model->update_order( $order_id, $update_data, $order_items ) ) {
1274 $update_data['id'] = $order_id;
1275 $update_data['items'] = $order_items;
1276 do_action( 'tutor_order_updated', $update_data );
1277 return $return_data ? $update_data : true;
1278 }
1279
1280 return false;
1281 }
1282 }
1283