PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / 3.4.1
Tutor LMS – eLearning and online course solution v3.4.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 / models / OrderModel.php
tutor / models Last commit date
BillingModel.php 1 year ago CartModel.php 1 year ago CouponModel.php 1 year ago CourseModel.php 1 year ago LessonModel.php 1 year ago OrderActivitiesModel.php 1 year ago OrderMetaModel.php 1 year ago OrderModel.php 1 year ago QuizModel.php 1 year ago UserModel.php 1 year ago WithdrawModel.php 1 year ago
OrderModel.php
1793 lines
1 <?php
2 /**
3 * Order Model
4 *
5 * @package Tutor\Models
6 * @author Themeum <support@themeum.com>
7 * @link https://themeum.com
8 * @since 3.0.0
9 */
10
11 namespace Tutor\Models;
12
13 use Exception;
14 use Tutor\Ecommerce\Ecommerce;
15 use Tutor\Helpers\QueryHelper;
16 use Tutor\Helpers\DateTimeHelper;
17 use Tutor\Ecommerce\OrderActivitiesController;
18 use Tutor\Ecommerce\Tax;
19
20 /**
21 * OrderModel Class
22 *
23 * @since 3.0.0
24 */
25 class OrderModel {
26
27 /**
28 * Order status
29 *
30 * @since 3.0.0
31 *
32 * @var string
33 */
34 const ORDER_INCOMPLETE = 'incomplete';
35 const ORDER_COMPLETED = 'completed';
36 const ORDER_CANCELLED = 'cancelled';
37 const ORDER_TRASH = 'trash';
38
39 /**
40 * Payment status
41 *
42 * @since 3.0.0
43 *
44 * @var string
45 */
46 const PAYMENT_PAID = 'paid';
47 const PAYMENT_FAILED = 'failed';
48 const PAYMENT_UNPAID = 'unpaid';
49 const PAYMENT_REFUNDED = 'refunded';
50 const PAYMENT_PARTIALLY_REFUNDED = 'partially-refunded';
51 const PAYMENT_MANUAL = 'manual';
52
53 /**
54 * Order Meta keys for history & refunds
55 *
56 * @since 3.0.0
57 *
58 * @var string
59 */
60 const META_KEY_HISTORY = 'history';
61 const META_KEY_REFUND = 'refund';
62 const META_KEY_ORDER_ID = 'tutor_order_id_';
63
64 /**
65 * Tax type constants
66 *
67 * @since 3.0.0
68 *
69 * @var string
70 */
71 const TAX_TYPE_EXCLUSIVE = 'exclusive';
72 const TAX_TYPE_INCLUSIVE = 'inclusive';
73
74
75 /**
76 * Order type
77 *
78 * @since 3.0.0
79 *
80 * @var string
81 */
82 const TYPE_SINGLE_ORDER = 'single_order';
83 const TYPE_SUBSCRIPTION = 'subscription';
84 const TYPE_RENEWAL = 'renewal';
85
86
87 /**
88 * Transient constants
89 *
90 * @since 3.0.0
91 */
92 const TRANSIENT_ORDER_BADGE_COUNT = 'tutor_order_badge_count';
93
94 /**
95 * Order placement success
96 *
97 * @since 3.0.0
98 */
99 const ORDER_PLACEMENT_SUCCESS = 'success';
100
101 /**
102 * Order placement failed
103 *
104 * @since 3.0.0
105 */
106 const ORDER_PLACEMENT_FAILED = 'failed';
107
108 /**
109 * Order table name
110 *
111 * @since 3.0.0
112 *
113 * @var string
114 */
115 private $table_name = 'tutor_orders';
116
117 /**
118 * Order item table name
119 *
120 * @since 3.0.0
121 *
122 * @var string
123 */
124 private $order_item_table = 'tutor_order_items';
125
126 /**
127 * Order item fillable fields
128 *
129 * @since 3.0.0
130 *
131 * @var array
132 */
133 private $order_items_fillable_fields = array(
134 'order_id',
135 'item_id',
136 'regular_price',
137 'sale_price',
138 'discount_price',
139 'coupon_code',
140 );
141
142 /**
143 * Resolve props & dependencies
144 *
145 * @since 3.0.0
146 */
147 public function __construct() {
148 global $wpdb;
149 $this->table_name = $wpdb->prefix . $this->table_name;
150 $this->order_item_table = $wpdb->prefix . $this->order_item_table;
151 }
152
153 /**
154 * Get table name with wp prefix
155 *
156 * @since 3.0.0
157 *
158 * @return string
159 */
160 public function get_table_name() {
161 return $this->table_name;
162 }
163
164 /**
165 * Get recalculated order tax data.
166 *
167 * @since 3.4.0
168 *
169 * @param int|object $order the order id or object.
170 *
171 * @return array
172 */
173 public function get_recalculated_order_tax_data( $order ) {
174 $order = self::get_order( $order );
175 $total_price = $order->total_price;
176 $tax_rate = Tax::get_user_tax_rate( $order->user_id );
177 $order_data = array();
178 if ( $tax_rate ) {
179 $order_data['tax_type'] = Tax::get_tax_type();
180 $order_data['tax_rate'] = $tax_rate;
181 $order_data['tax_amount'] = Tax::calculate_tax( $total_price, $tax_rate );
182
183 if ( ! Tax::is_tax_included_in_price() ) {
184 $total_price += $order_data['tax_amount'];
185 $order_data['total_price'] = $total_price;
186 $order_data['net_payment'] = $total_price;
187 }
188 }
189
190 return $order_data;
191 }
192
193
194 /**
195 * Get all order statuses
196 *
197 * @since 3.0.0
198 *
199 * @return array
200 */
201 public static function get_order_status() {
202 return array(
203 self::ORDER_INCOMPLETE => __( 'Incomplete', 'tutor' ),
204 self::ORDER_COMPLETED => __( 'Completed', 'tutor' ),
205 self::ORDER_CANCELLED => __( 'Cancelled', 'tutor' ),
206 self::ORDER_TRASH => __( 'Trash', 'tutor' ),
207 );
208 }
209
210 /**
211 * Get all payment statuses
212 *
213 * @since 3.0.0
214 *
215 * @return array
216 */
217 public static function get_payment_status() {
218 return array(
219 self::PAYMENT_PAID => __( 'Paid', 'tutor' ),
220 self::PAYMENT_UNPAID => __( 'Unpaid', 'tutor' ),
221 self::PAYMENT_FAILED => __( 'Failed', 'tutor' ),
222 self::PAYMENT_REFUNDED => __( 'Refunded', 'tutor' ),
223 self::PAYMENT_PARTIALLY_REFUNDED => __( 'Partially Refunded', 'tutor' ),
224 );
225 }
226
227 /**
228 * Get order items fillable fields
229 *
230 * @since 3.0.0
231 *
232 * @return array
233 */
234 public function get_order_items_fillable_fields() {
235 return $this->order_items_fillable_fields;
236 }
237
238 /**
239 * Get searchable fields
240 *
241 * This method is intendant to use with get order list
242 *
243 * @since 3.0.0
244 *
245 * @return array
246 */
247 private function get_searchable_fields() {
248 return array(
249 'o.id',
250 'o.transaction_id',
251 'o.coupon_code',
252 'o.payment_method',
253 'o.order_status',
254 'o.payment_status',
255 'u.display_name',
256 'u.user_login',
257 'u.user_email',
258 );
259 }
260
261 /**
262 * Create order
263 *
264 * Note: validate data before using this method
265 *
266 * This method will also insert items if
267 * item is set.
268 *
269 * Ex: data['order_items] = [
270 * user_id => 1,
271 * course_id => 1,
272 * regular_price => 100,
273 * sale_price => 90
274 * ]
275 *
276 * @since 3.0.0
277 *
278 * @param array $data Order data based on db table.
279 *
280 * @throws \Exception Database error if occur.
281 *
282 * @return int Order id on success
283 */
284 public function create_order( array $data ) {
285 $order_items = $data['items'] ?? null;
286 unset( $data['items'] );
287
288 global $wpdb;
289
290 // Start transaction.
291 $wpdb->query( 'START TRANSACTION' );
292
293 try {
294 $order_id = QueryHelper::insert( $this->table_name, $data );
295 if ( $order_id ) {
296 if ( $order_items ) {
297 $insert = $this->insert_order_items( $order_id, $order_items );
298 if ( $insert ) {
299 $wpdb->query( 'COMMIT' );
300 return $order_id;
301 } else {
302 $wpdb->query( 'ROLLBACK' );
303 throw new \Exception( __( 'Failed to insert order items', 'tutor' ) );
304 }
305 } else {
306 $wpdb->query( 'COMMIT' );
307 return $order_id;
308 }
309 }
310 } catch ( \Throwable $th ) {
311 throw new \Exception( $th->getMessage() );
312 }
313 }
314
315 /**
316 * Insert order items
317 *
318 * Note: validate data before using this method
319 *
320 * @since 3.0.0
321 *
322 * @param int $order_id Order ID.
323 * @param array $items Order items.
324 *
325 * @throws Exception Database error if occur.
326 *
327 * @return bool
328 */
329 public function insert_order_items( int $order_id, array $items ): bool {
330 // Check if item is multi dimensional.
331 if ( ! isset( $items[0] ) ) {
332 $items = array( $items );
333 }
334
335 // Set order id on each item.
336 foreach ( $items as $key => $item ) {
337 $items[ $key ]['order_id'] = $order_id;
338 }
339
340 try {
341 $insert = QueryHelper::insert_multiple_rows(
342 $this->order_item_table,
343 $items
344 );
345 return $insert ? true : false;
346 } catch ( \Throwable $th ) {
347 throw new Exception( $th->getMessage() );
348 }
349 }
350
351 /**
352 * Retrieve order details by order ID.
353 *
354 * This function fetches order information from the database based on the given
355 * order ID. It queries the 'tutor_orders' table for the order data, retrieves
356 * the corresponding user information and metadata, and constructs a detailed
357 * student object with placeholder values for billing address and phone.
358 *
359 * The function then assigns this student object to the order data, removes
360 * the user ID from the order data, and returns the modified order data.
361 *
362 * @since 3.0.0
363 *
364 * @global wpdb $wpdb WordPress database abstraction object.
365 *
366 * @param int $order_id The ID of the order to retrieve.
367 *
368 * @return object|false The order data with the student's information included, or false if no order is found.
369 */
370 public function get_order_by_id( $order_id ) {
371 $order_data = QueryHelper::get_row(
372 $this->table_name,
373 array( 'id' => $order_id ),
374 'id'
375 );
376
377 if ( ! $order_data ) {
378 return false;
379 }
380
381 $user_info = get_userdata( $order_data->user_id );
382 if ( ! is_a( $user_info, 'WP_User' ) ) {
383 return false;
384 }
385
386 $student = new \stdClass();
387 $student->id = (int) $user_info->ID;
388 $student->name = $user_info->data->display_name;
389 $student->email = $user_info->data->user_email;
390 $student->phone = get_user_meta( $order_data->user_id, 'phone_number', true );
391 $student->billing_address = $this->get_tutor_customer_data( $order_data->user_id );
392 $student->image = get_avatar_url( $order_data->user_id );
393
394 $order_data->student = $student;
395 $order_data->items = $this->get_order_items_by_id( $order_id );
396
397 $order_data->subtotal_price = (float) $order_data->subtotal_price;
398 $order_data->total_price = (float) $order_data->total_price;
399 $order_data->net_payment = (float) $order_data->net_payment;
400 $order_data->discount_amount = (float) $order_data->discount_amount;
401 $order_data->coupon_amount = (float) $order_data->coupon_amount;
402 $order_data->tax_rate = (float) $order_data->tax_rate;
403 $order_data->tax_amount = (float) $order_data->tax_amount;
404
405 $order_data->payment_method_readable = Ecommerce::get_payment_method_label( $order_data->payment_method );
406 $order_data->created_at_readable = DateTimeHelper::get_gmt_to_user_timezone_date( $order_data->created_at_gmt );
407 $order_data->updated_at_readable = empty( $order_data->updated_at_gmt ) ? '' : DateTimeHelper::get_gmt_to_user_timezone_date( $order_data->updated_at_gmt );
408
409 $order_data->created_by = get_userdata( $order_data->created_by )->display_name ?? '';
410 $order_data->updated_by = get_userdata( $order_data->updated_by )->display_name ?? '';
411
412 $order_activities_model = new OrderActivitiesModel();
413 $order_data->activities = $order_activities_model->get_order_activities( $order_id );
414 $order_data->refunds = $this->get_order_refunds( $order_id );
415
416 unset( $student->billing_address->id );
417 unset( $student->billing_address->user_id );
418
419 return apply_filters( 'tutor_order_details', $order_data );
420 }
421
422 /**
423 * Get order data
424 *
425 * @since 3.1.0
426 *
427 * @param int|object $order order id or object.
428 *
429 * @return object
430 */
431 public static function get_order( $order ) {
432 if ( is_numeric( $order ) ) {
433 $order = ( new self() )->get_order_by_id( $order );
434 }
435
436 return $order;
437 }
438
439 /**
440 * Check order is subscription order
441 *
442 * @since 3.1.0
443 *
444 * @param int|object $order order id or object.
445 *
446 * @return boolean
447 */
448 public static function is_subscription_order( $order ) {
449 $order = self::get_order( $order );
450 return $order && self::TYPE_SUBSCRIPTION === $order->order_type;
451 }
452
453 /**
454 * Check order is single order
455 *
456 * @since 3.2.0
457 *
458 * @param int|object $order order id or object.
459 *
460 * @return boolean
461 */
462 public static function is_single_order( $order ) {
463 $order = self::get_order( $order );
464 return $order && self::TYPE_SINGLE_ORDER === $order->order_type;
465 }
466
467 /**
468 * Mark order Unpaid to Paid.
469 *
470 * @since 3.0.0
471 *
472 * @param int $order_id order id.
473 * @param string $note note.
474 * @param bool $trigger_hooks trigger hooks or not.
475 *
476 * @return bool
477 */
478 public function mark_as_paid( $order_id, $note = '', $trigger_hooks = true ) {
479 if ( $trigger_hooks ) {
480 do_action( 'tutor_before_order_mark_as_paid', $order_id );
481 }
482
483 $data = array(
484 'payment_status' => self::PAYMENT_PAID,
485 'order_status' => self::ORDER_COMPLETED,
486 'note' => $note,
487 );
488
489 $response = $this->update_order( $order_id, $data );
490 if ( ! $response ) {
491 return false;
492 }
493
494 if ( $trigger_hooks ) {
495 do_action( 'tutor_order_payment_status_changed', $order_id, self::PAYMENT_UNPAID, self::PAYMENT_PAID );
496
497 $order = $this->get_order_by_id( $order_id );
498 $discount_amount = $this->calculate_discount_amount( $order->discount_type, $order->discount_amount, $order->subtotal_price );
499 do_action( 'tutor_after_order_mark_as_paid', $order, $discount_amount );
500 }
501
502 return true;
503 }
504
505
506 /**
507 * Retrieve order items by order ID.
508 *
509 * This function fetches order item details from the database based on the given
510 * order ID. It queries the 'tutor_order_items' table and joins it with the 'posts'
511 * table to get the course titles associated with each order item.
512 *
513 * The function then returns the retrieved order items, or an empty array if no
514 * items are found.
515 *
516 * @since 3.0.0
517 *
518 * @global wpdb $wpdb WordPress database abstraction object.
519 *
520 * @param int $order_id The ID of the order to retrieve items for.
521 *
522 * @return array The order items, each containing details and course titles, or an empty array if no items are found.
523 */
524 public function get_order_items_by_id( $order_id ) {
525 global $wpdb;
526
527 $primary_table = "{$wpdb->prefix}tutor_order_items AS oi";
528 $joining_tables = array(
529 array(
530 'type' => 'LEFT',
531 'table' => "{$wpdb->prefix}posts AS p",
532 'on' => 'p.ID = oi.item_id',
533 ),
534 );
535
536 $where = array( 'order_id' => $order_id );
537
538 $select_columns = array( 'oi.item_id AS id', 'oi.regular_price', 'oi.sale_price', 'oi.discount_price', 'oi.coupon_code', 'p.post_title AS title', 'p.post_type AS type' );
539
540 $courses_data = QueryHelper::get_joined_data( $primary_table, $joining_tables, $select_columns, $where, array(), 'id', 0, 0 );
541 $courses = $courses_data['results'];
542
543 if ( tutor()->has_pro ) {
544 $bundle_model = new \TutorPro\CourseBundle\Models\BundleModel();
545 }
546
547 if ( ! empty( $courses_data['total_count'] ) ) {
548 foreach ( $courses as &$course ) {
549 if ( tutor()->has_pro && 'course-bundle' === $course->type ) {
550 $course->total_courses = count( $bundle_model->get_bundle_course_ids( $course->id ) );
551 }
552
553 $course->id = (int) $course->id;
554 $course->regular_price = (float) $course->regular_price;
555 $course->sale_price = (float) $course->sale_price;
556 $course->image = get_the_post_thumbnail_url( $course->id );
557 }
558 }
559
560 unset( $course );
561
562 return ! empty( $courses ) ? $courses : array();
563 }
564
565 /**
566 * Retrieve tutor customer data by user ID.
567 *
568 * This function fetches customer data from the 'tutor_customers' table based on
569 * the given user ID. It utilizes a helper function from the QueryHelper class
570 * to perform the database query.
571 *
572 * The function returns the customer data as an object.
573 *
574 * @global wpdb $wpdb WordPress database abstraction object.
575 *
576 * @param int $user_id The ID of the user to retrieve customer data for.
577 *
578 * @since 3.0.0
579 *
580 * @return object|null The customer data retrieved from the database.
581 */
582 public function get_tutor_customer_data( $user_id ) {
583 global $wpdb;
584
585 // Retrieve customer data for the given user ID from the 'tutor_customers' table.
586 $customer_data = QueryHelper::get_row( "{$wpdb->prefix}tutor_customers", array( 'user_id' => $user_id ), 'id' );
587
588 if ( empty( $customer_data ) ) {
589 return null;
590 }
591
592 $return_data = (object) array(
593 'id' => $customer_data->id,
594 'user_id' => $customer_data->user_id,
595 'name' => $customer_data->billing_first_name . ' ' . $customer_data->billing_last_name,
596 'email' => $customer_data->billing_email,
597 'phone' => $customer_data->billing_phone,
598 'address' => $customer_data->billing_address,
599 'city' => $customer_data->billing_city,
600 'state' => $customer_data->billing_state,
601 'country' => $customer_data->billing_country,
602 'zip_code' => $customer_data->billing_zip_code,
603 );
604
605 return $return_data;
606 }
607
608 /**
609 * Retrieve order refunds by order ID.
610 *
611 * This function fetches all order refunds from the 'tutor_ordermeta' table
612 * based on the given order ID and the 'refund' meta key. It uses a helper
613 * function from the QueryHelper class to perform the database query.
614 *
615 * If no order refunds are found, the function returns an empty array.
616 * Otherwise, it decodes the JSON-encoded meta values and returns them as an array.
617 *
618 * @global wpdb $wpdb WordPress database abstraction object.
619 *
620 * @param int $order_id The ID of the order to retrieve refunds for.
621 *
622 * @since 3.0.0
623 *
624 * @return array An array of order refunds, each decoded from its JSON representation.
625 */
626 public function get_order_refunds( $order_id ) {
627 global $wpdb;
628
629 $meta_keys = array(
630 OrderActivitiesModel::META_KEY_REFUND,
631 OrderActivitiesModel::META_KEY_PARTIALLY_REFUND,
632 );
633
634 // Retrieve order refunds for the given order ID from the 'tutor_ordermeta' table.
635 $order_refunds = QueryHelper::get_all(
636 "{$wpdb->prefix}tutor_ordermeta",
637 array(
638 'order_id' => $order_id,
639 'meta_key' => $meta_keys,
640 ),
641 'created_at_gmt',
642 1000,
643 'ASC'
644 );
645
646 if ( empty( $order_refunds ) ) {
647 return array();
648 }
649
650 $response = array();
651
652 foreach ( $order_refunds as $refund ) {
653 $parsed_meta_value = json_decode( $refund->meta_value );
654 $values = new \stdClass();
655 $values->id = (int) $refund->id;
656
657 foreach ( $parsed_meta_value as $key => $value ) {
658 $values->$key = $value;
659 }
660
661 $values->date = $refund->created_at_gmt;
662
663 $response[] = $values;
664 }
665
666 // Custom comparison function for sorting by date.
667 usort(
668 $response,
669 function ( $a, $b ) {
670 $date_a = strtotime( $a->date );
671 $date_b = strtotime( $b->date );
672
673 return $date_b - $date_a;
674 }
675 );
676
677 return $response;
678 }
679
680 /**
681 * Update an order
682 *
683 * @since 3.0.0
684 *
685 * @param int|array $order_id Integer or array of ids sql escaped.
686 * @param array $data Data to update, escape data.
687 *
688 * @return bool
689 */
690 public function update_order( $order_id, array $data ) {
691 $order_id = is_array( $order_id ) ? $order_id : array( $order_id );
692 $order_id = QueryHelper::prepare_in_clause( $order_id );
693 try {
694 QueryHelper::update_where_in(
695 $this->table_name,
696 $data,
697 $order_id
698 );
699 return true;
700 } catch ( \Throwable $th ) {
701 error_log( $th->getMessage() . ' in ' . $th->getFile() . ' at line ' . $th->getLine() );
702 return false;
703 }
704 }
705
706 /**
707 * Get enrollment ids by order id.
708 *
709 * @since 3.0.0
710 *
711 * @param int $order_id order id.
712 *
713 * @return array
714 */
715 public function get_enrollment_ids( $order_id ) {
716 global $wpdb;
717 $enrollment_ids = array();
718
719 $enrollments = $wpdb->get_results(
720 $wpdb->prepare(
721 "SELECT * FROM {$wpdb->postmeta}
722 WHERE meta_key=%s
723 AND meta_value LIKE %d",
724 '_tutor_enrolled_by_order_id',
725 $order_id
726 )
727 );
728
729 if ( $enrollments ) {
730 $enrollment_ids = array_column( $enrollments, 'post_id' );
731 }
732
733 return $enrollment_ids;
734 }
735
736 /**
737 * Delete an order by order ID.
738 *
739 * This function deletes an order from the 'tutor_orders' table based on the given
740 * order ID. It uses the QueryHelper class to perform the database delete operation.
741 *
742 * @since 3.0.0
743 *
744 * @param int|array $order_id The ID of the order to delete.
745 *
746 * @return bool
747 */
748 public function delete_order( $order_id ) {
749 global $wpdb;
750 $order_ids = is_array( $order_id ) ? $order_id : array( intval( $order_id ) );
751
752 try {
753 $wpdb->query( 'START TRANSACTION' );
754
755 foreach ( $order_ids as $order_id ) {
756 // Delete enrollments if exist.
757 $enrollment_ids = $this->get_enrollment_ids( $order_id );
758 if ( $enrollment_ids ) {
759 QueryHelper::bulk_delete_by_ids( $wpdb->posts, $enrollment_ids );
760 // After enrollment delete, delete the course progress.
761 foreach ( $enrollment_ids as $enrollment_id ) {
762 $course_id = get_post_field( 'post_parent', $enrollment_id );
763 $student_id = get_post_field( 'post_author', $enrollment_id );
764
765 if ( $course_id && $student_id ) {
766 tutor_utils()->delete_course_progress( $course_id, $student_id );
767 }
768 }
769 }
770
771 // Delete earnings.
772 QueryHelper::delete(
773 $wpdb->prefix . 'tutor_earnings',
774 array(
775 'order_id' => $order_id,
776 'process_by' => 'Tutor',
777 )
778 );
779
780 // Now delete order.
781 QueryHelper::delete( $this->table_name, array( 'id' => $order_id ) );
782 }
783
784 $wpdb->query( 'COMMIT' );
785 return true;
786
787 } catch ( \Throwable $th ) {
788 $wpdb->query( 'ROLLBACK' );
789 return false;
790 }
791 }
792
793 /**
794 * Get orders list
795 *
796 * @since 3.0.0
797 *
798 * @param array $where where clause conditions.
799 * @param string $search_term search clause conditions.
800 * @param int $limit limit default 10.
801 * @param int $offset default 0.
802 * @param string $order_by column default 'o.id'.
803 * @param string $order list order default 'desc'.
804 *
805 * @return array
806 */
807 public function get_orders( array $where = array(), $search_term = '', int $limit = 10, int $offset = 0, string $order_by = 'o.id', string $order = 'desc' ) {
808
809 global $wpdb;
810
811 $primary_table = "{$this->table_name} o";
812 $joining_tables = array(
813 array(
814 'type' => 'LEFT',
815 'table' => "{$wpdb->users} u",
816 'on' => 'o.user_id = u.ID',
817 ),
818 );
819
820 $select_columns = array( 'o.*', 'u.user_login' );
821
822 $search_clause = array();
823 if ( '' !== $search_term ) {
824 foreach ( $this->get_searchable_fields() as $column ) {
825 $search_clause[ $column ] = $search_term;
826 }
827 }
828
829 $response = array(
830 'results' => array(),
831 'total_count' => 0,
832 );
833
834 try {
835 return QueryHelper::get_joined_data( $primary_table, $joining_tables, $select_columns, $where, $search_clause, $order_by, $limit, $offset, $order );
836 } catch ( \Throwable $th ) {
837 // Log with error, line & file name.
838 error_log( $th->getMessage() . ' in ' . $th->getFile() . ' at line ' . $th->getLine() );
839 return $response;
840 }
841 }
842
843 /**
844 * Get order count
845 *
846 * @since 3.0.0
847 *
848 * @param array $where Where conditions, sql esc data.
849 * @param string $search_term Search terms, sql esc data.
850 *
851 * @return int
852 */
853 public function get_order_count( $where = array(), string $search_term = '' ) {
854 global $wpdb;
855
856 $search_clause = array();
857 if ( '' !== $search_term ) {
858 foreach ( $this->get_searchable_fields() as $column ) {
859 $search_clause[ $column ] = $search_term;
860 }
861 }
862
863 $join_table = array(
864 array(
865 'type' => 'INNER',
866 'table' => "{$wpdb->users} u",
867 'on' => 'o.user_id = u.ID',
868 ),
869 );
870 $primary_table = "{$this->table_name} o";
871 return QueryHelper::get_joined_count( $primary_table, $join_table, $where, $search_clause );
872 }
873
874 /**
875 * Get order of a user
876 *
877 * @since 3.0.0
878 *
879 * @param string $time_period $time_period Sorting time period,
880 * supported time periods are: today, monthly & yearly.
881 * @param string $start_date $start_date For date range sorting.
882 * @param string $end_date $end_date For date range sorting.
883 * @param int $user_id User id for fetching order list.
884 * @param int $limit Limit to fetch record.
885 * @param int $offset Offset to fetch record.
886 *
887 * @throws \Exception Throw exception if database error occur.
888 *
889 * @return array
890 */
891 public function get_user_orders( $time_period = null, $start_date = null, $end_date = null, int $user_id = 0, $limit = 10, int $offset = 0 ) {
892 $user_id = $user_id ? $user_id : get_current_user_id();
893
894 $response = array(
895 'results' => array(),
896 'total_count' => 0,
897 );
898
899 global $wpdb;
900
901 $time_period_clause = '';
902 $date_range_clause = '';
903
904 if ( $start_date && $end_date ) {
905 $date_range_clause = $wpdb->prepare( 'AND DATE(created_at_gmt) BETWEEN %s AND %s', $start_date, $end_date );
906 } else {
907 if ( $time_period ) {
908 if ( 'today' === $time_period ) {
909 $time_period_clause = 'AND DATE(o.created_at_gmt) = CURDATE()';
910 } elseif ( 'monthly' === $time_period ) {
911 $time_period_clause = 'AND MONTH(o.created_at_gmt) = MONTH(CURDATE()) ';
912 } else {
913 $time_period_clause = 'AND YEAR(o.created_at_gmt) = YEAR(CURDATE()) ';
914 }
915 }
916 }
917
918 $query = $wpdb->prepare(
919 "SELECT
920 SQL_CALC_FOUND_ROWS
921 o.*
922 FROM $this->table_name AS o
923 WHERE o.user_id = %d
924 {$time_period_clause}
925 {$date_range_clause}
926 ORDER BY o.id DESC
927 LIMIT %d OFFSET %d
928 ",
929 $user_id,
930 $limit,
931 $offset
932 );
933
934 $results = $wpdb->get_results( $query );
935
936 if ( $wpdb->last_error ) {
937 throw new \Exception( $wpdb->last_error );
938 } else {
939 $response['results'] = $results;
940 $response['total_count'] = is_array( $results ) && count( $results ) ? (int) $wpdb->get_var( 'SELECT FOUND_ROWS()' ) : 0;
941 }
942
943 return $response;
944 }
945
946 /**
947 * Get total discounts by user_id (instructor), optionally can set period ( today | monthly| yearly )
948 *
949 * Optionally can set start date & end date to get enrollment list from date range
950 *
951 * If period or date range not pass then it will return all time enrollment list
952 *
953 * @since 3.0.0
954 *
955 * @param int $user_id User id, if user not have admin access
956 * then only this user's refund amount will fetched.
957 * @param string $period Time period.
958 * @param string $start_date Start date.
959 * @param string $end_date End date.
960 * @param int $course_id Course id.
961 *
962 * @return array
963 */
964 public function get_discounts_by_user( int $user_id, string $period = '', $start_date = '', string $end_date = '', int $course_id = 0 ): array {
965 $response = array(
966 'discounts' => array(),
967 'total_discounts' => 0,
968 );
969
970 global $wpdb;
971
972 $user_clause = '';
973 $date_range_clause = '';
974 $period_clause = '';
975 $course_clause = '';
976 $group_clause = ' GROUP BY DATE(date_format) ';
977 $discount_clause = 'o.coupon_amount as total';
978
979 if ( $start_date && $end_date ) {
980 $date_range_clause = $wpdb->prepare(
981 'AND o.created_at_gmt BETWEEN %s AND %s',
982 $start_date,
983 $end_date
984 );
985 } else {
986 $period_clause = QueryHelper::get_period_clause( 'o.created_at_gmt', $period );
987 }
988
989 if ( 'today' !== $period ) {
990 $group_clause = ' GROUP BY MONTH(date_format) ';
991 }
992
993 if ( $course_id ) {
994 $course_clause = $wpdb->prepare( 'AND i.item_id = %d', $course_id );
995 $discount_clause = 'i.regular_price - i.discount_price AS total';
996 }
997
998 $item_table = $wpdb->prefix . 'tutor_order_items';
999
1000 if ( $course_id ) {
1001 if ( $user_id ) {
1002 $user_clause = $wpdb->prepare( 'AND c.post_author = %d', $user_id );
1003 }
1004
1005 $discounts = $wpdb->get_results(
1006 $wpdb->prepare(
1007 "SELECT
1008 i.item_id AS course_id,
1009 SUM(
1010 COALESCE(o.coupon_amount, 0) +
1011 COALESCE(
1012 IF(
1013 o.discount_type = 'percentage',
1014 COALESCE(o.subtotal_price * (o.discount_amount / 100), 0),
1015 COALESCE(o.discount_amount, 0)
1016 ),
1017 0
1018 )
1019 ) AS total,
1020 o.created_at_gmt AS date_format
1021 FROM
1022 {$this->table_name} o
1023 JOIN
1024 {$item_table} i ON o.id = i.order_id
1025 JOIN
1026 {$wpdb->posts} c
1027 ON c.ID = i.item_id
1028 AND c.post_type = %s
1029 WHERE
1030 1 = 1
1031 AND i.item_id = %d
1032 {$user_clause}
1033 {$period_clause}
1034 {$date_range_clause}
1035 {$group_clause}
1036 ",
1037 tutor()->course_post_type,
1038 $course_id
1039 )
1040 );
1041 } else {
1042 if ( $user_id ) {
1043 $user_clause = $wpdb->prepare( "AND %d = (SELECT user_id FROM {$wpdb->tutor_earnings} WHERE order_status = 'completed' LIMIT 1) ", $user_id );
1044 }
1045
1046 $discounts = $wpdb->get_results(
1047 $wpdb->prepare(
1048 "SELECT
1049 SUM(
1050 COALESCE(o.coupon_amount, 0) +
1051 COALESCE(
1052 IF(
1053 o.discount_type = 'percentage',
1054 COALESCE(o.subtotal_price * (o.discount_amount / 100), 0),
1055 COALESCE(o.discount_amount, 0)
1056 ),
1057 0
1058 )
1059 ) AS total,
1060 o.created_at_gmt AS date_format
1061 FROM {$this->table_name} AS o
1062 WHERE 1 = %d
1063 AND o.order_status = 'completed'
1064 {$user_clause}
1065 {$period_clause}
1066 {$date_range_clause}
1067 {$course_clause}
1068 {$group_clause}
1069 HAVING total > 0
1070 ",
1071 1
1072 )
1073 );
1074 }
1075
1076 $total_discount = 0;
1077 $discount_items = array();
1078
1079 $response = array(
1080 'discounts' => array(),
1081 'total_discounts' => 0,
1082 );
1083
1084 if ( $discounts ) {
1085 foreach ( $discounts as $discount ) {
1086 $total_discount += $discount->total;
1087 $discount_items[] = $discount;
1088
1089 // Split each discount.
1090 list( $admin_discount, $instructor_discount ) = array_values( tutor_split_amounts( $discount->total ) );
1091
1092 $discount->total = is_admin() ? $admin_discount : $instructor_discount;
1093 }
1094
1095 list( $admin_total, $instructor_total ) = array_values( tutor_split_amounts( $total_discount ) );
1096
1097 $response['discounts'] = $discount_items;
1098 $response['total_discounts'] = is_admin() ? $admin_total : $instructor_total;
1099 }
1100
1101 return $response;
1102 }
1103
1104 /**
1105 * Get total refunds by user_id (instructor), optionally can set period ( today | monthly| yearly )
1106 *
1107 * Optionally can set start date & end date to get enrollment list from date range
1108 *
1109 * If period or date range not pass then it will return all time enrollment list
1110 *
1111 * @since 3.0.0
1112 *
1113 * @param int $user_id User id, if user not have admin access
1114 * then only this user's refund amount will fetched.
1115 * @param string $period Time period.
1116 * @param string $start_date Start date.
1117 * @param string $end_date End date.
1118 * @param int $course_id Course id.
1119 *
1120 * @return array
1121 */
1122 public function get_refunds_by_user( int $user_id, string $period = '', $start_date = '', string $end_date = '', int $course_id = 0 ): array {
1123 $response = array(
1124 'refunds' => array(),
1125 'total_refunds' => 0,
1126 );
1127
1128 global $wpdb;
1129
1130 $user_clause = '';
1131 $date_range_clause = '';
1132 $period_clause = '';
1133 $course_clause = '';
1134 $commission_clause = '';
1135 $group_clause = ' GROUP BY DATE(o.created_at_gmt) ';
1136
1137 if ( $start_date && $end_date ) {
1138 $date_range_clause = $wpdb->prepare(
1139 'AND o.created_at_gmt BETWEEN %s AND %s',
1140 $start_date,
1141 $end_date
1142 );
1143 $group_clause = ' GROUP BY DATE(o.created_at_gmt) ';
1144
1145 } else {
1146 $period_clause = QueryHelper::get_period_clause( 'o.created_at_gmt', $period );
1147 }
1148
1149 if ( 'today' !== $period ) {
1150 $group_clause = ' GROUP BY MONTH(o.created_at_gmt) ';
1151 }
1152
1153 if ( $course_id ) {
1154 if ( $user_id ) {
1155 $user_clause = $wpdb->prepare( 'AND c.post_author = %d', $user_id );
1156 }
1157 } else {
1158 if ( $user_id ) {
1159 $user_clause = $wpdb->prepare( 'AND c.post_author = %d', $user_id );
1160 }
1161 }
1162
1163 // Refund query logic remains the same.
1164 $item_table = $wpdb->prefix . 'tutor_order_items';
1165
1166 if ( $course_id ) {
1167 $refunds = $wpdb->get_results(
1168 $wpdb->prepare(
1169 "SELECT
1170 i.item_id AS course_id,
1171 ROUND(
1172 SUM(
1173 o.refund_amount *
1174 (
1175 CASE
1176 WHEN i.discount_price THEN i.discount_price
1177 WHEN i.sale_price > 0 THEN i.sale_price
1178 ELSE i.regular_price
1179 END / o.total_price
1180 )
1181 ), 2
1182 ) AS total
1183 FROM
1184 {$this->table_name} o
1185 JOIN
1186 {$item_table} i ON o.id = i.order_id
1187 JOIN
1188 {$wpdb->posts} c
1189 ON c.ID = i.item_id
1190 AND c.post_type = %s
1191 WHERE
1192 o.refund_amount > 0
1193 AND i.item_id = %d
1194 {$user_clause}
1195 {$period_clause}
1196 {$date_range_clause}
1197 {$group_clause},
1198 i.item_id
1199 ",
1200 tutor()->course_post_type,
1201 $course_id
1202 )
1203 );
1204 } else {
1205 $earning_table = $wpdb->tutor_earnings;
1206 if ( $user_id ) {
1207 $user_clause = "AND {$user_id} = (SELECT user_id FROM {$earning_table} LIMIT 1)";
1208 }
1209
1210 $refunds = $wpdb->get_results(
1211 $wpdb->prepare(
1212 "SELECT
1213 COALESCE(SUM(o.refund_amount), 0) AS total,
1214 created_at_gmt AS date_format
1215 FROM {$this->table_name} AS o
1216 -- LEFT JOIN {$item_table} AS i ON i.order_id = o.id
1217 -- LEFT JOIN {$wpdb->posts} AS c ON c.id = i.item_id
1218 WHERE 1 = %d
1219 AND o.refund_amount > %d
1220 {$user_clause}
1221 {$period_clause}
1222 {$date_range_clause}
1223 {$group_clause},
1224 o.id",
1225 1,
1226 0
1227 )
1228 );
1229 }
1230
1231 $total_refund = 0;
1232
1233 foreach ( $refunds as $refund ) {
1234 $total_refund += $refund->total;
1235
1236 // Update total amount from list.
1237 $split_refund = (object) tutor_split_amounts( $refund->total );
1238 $refund->total = is_admin() ? $split_refund->admin : $split_refund->instructor;
1239 }
1240
1241 $split_total_refund = (object) tutor_split_amounts( $total_refund );
1242
1243 $response = array(
1244 'refunds' => $refunds,
1245 'total_refunds' => is_admin() ? $split_total_refund->admin : $split_total_refund->instructor,
1246 );
1247
1248 return $response;
1249 }
1250
1251 /**
1252 * Update the payment status of an order.
1253 *
1254 * This function updates the payment status and note of an order in the database.
1255 * It uses the QueryHelper class to perform the update operation.
1256 *
1257 * @since 3.0.0
1258 *
1259 * @param object $data An object containing the payment status, note, and order ID.
1260 * - 'payment_status' (string): The new payment status.
1261 * - 'note' (string): A note regarding the payment status update.
1262 * - 'order_id' (int): The ID of the order to update.
1263 *
1264 * @return bool True on successful update, false on failure.
1265 */
1266 public function payment_status_update( object $data ) {
1267 $response = QueryHelper::update(
1268 $this->table_name,
1269 array(
1270 'payment_status' => $data->payment_status,
1271 'note' => $data->note,
1272 ),
1273 array( 'id' => $data->order_id )
1274 );
1275
1276 if ( $response ) {
1277 $activity_controller = new OrderActivitiesController();
1278 $activity_controller->store_order_activity_for_marked_as_paid( $data->order_id );
1279 }
1280
1281 return $response;
1282 }
1283
1284 /**
1285 * Add a discount to an order.
1286 *
1287 * This function updates the order in the database with the provided discount details.
1288 * It updates the discount type, discount amount, and discount reason for the given order ID.
1289 *
1290 * @since 3.0.0
1291 *
1292 * @param object $data An object containing the discount details:
1293 * - $data->order_id (int) The ID of the order.
1294 * - $data->discount_type (string) The type of the discount.
1295 * - $data->discount_amount(float) The amount of the discount.
1296 * - $data->discount_reason(string) The reason for the discount.
1297 *
1298 * @return bool True on successful update, false on failure.
1299 */
1300 public function add_order_discount( object $data ) {
1301 $response = QueryHelper::update(
1302 $this->table_name,
1303 array(
1304 'discount_type' => $data->discount_type,
1305 'discount_amount' => $data->discount_amount,
1306 'discount_reason' => $data->discount_reason,
1307 ),
1308 array( 'id' => $data->order_id )
1309 );
1310
1311 return $response;
1312 }
1313
1314 /**
1315 * Updates the status of an order and logs the activity.
1316 *
1317 * This function updates the status of an order in the database and, if successful, logs the activity
1318 * with a message indicating the status change. The message includes the current user's display name,
1319 * if available.
1320 *
1321 * The possible order statuses include:
1322 * - ORDER_CANCELLED
1323 * - ORDER_COMPLETED
1324 * - ORDER_INCOMPLETE
1325 * - ORDER_TRASH
1326 *
1327 * If the update is successful, an order activity log entry is created with the current date, time,
1328 * and status change message.
1329 *
1330 * @since 3.0.0
1331 *
1332 * @param object $data An object containing:
1333 * - int $order_id The ID of the order to update.
1334 * - string $order_status The new status of the order.
1335 * - string $cancel_reason The reason for the order cancellation (optional).
1336 *
1337 * @return bool True on successful update, false on failure.
1338 */
1339 public function order_status_update( object $data ) {
1340 $response = QueryHelper::update(
1341 $this->table_name,
1342 array(
1343 'order_status' => $data->order_status,
1344 ),
1345 array( 'id' => $data->order_id )
1346 );
1347
1348 if ( $response ) {
1349 $user_name = '';
1350 $current_user = wp_get_current_user();
1351
1352 if ( $current_user->exists() ) {
1353 $user_name = $current_user->display_name;
1354 }
1355
1356 $message = '';
1357
1358 if ( self::ORDER_CANCELLED === $data->order_status ) {
1359 /* translators: %s: username */
1360 $message = empty( $user_name ) ? __( 'Order marked as cancelled', 'tutor' ) : sprintf( __( 'Order marked as cancelled by %s', 'tutor' ), $user_name );
1361 } elseif ( self::ORDER_COMPLETED === $data->order_status ) {
1362 /* translators: %s: username */
1363 $message = empty( $user_name ) ? __( 'Order marked as completed', 'tutor' ) : sprintf( __( 'Order marked as completed by %s', 'tutor' ), $user_name );
1364 } elseif ( self::ORDER_INCOMPLETE === $data->order_status ) {
1365 /* translators: %s: username */
1366 $message = empty( $user_name ) ? __( 'Order marked as incomplete', 'tutor' ) : sprintf( __( 'Order marked as incomplete by %s', 'tutor' ), $user_name );
1367 } elseif ( self::ORDER_TRASH === $data->order_status ) {
1368 /* translators: %s: username */
1369 $message = empty( $user_name ) ? __( 'Order marked as trash', 'tutor' ) : sprintf( __( 'Order marked as trash by %s', 'tutor' ), $user_name );
1370 }
1371
1372 // insert cancel reason in tutor_ordermeta table.
1373 if ( self::ORDER_CANCELLED === $data->order_status && ! empty( $data->cancel_reason ) ) {
1374 $meta_payload = new \stdClass();
1375 $meta_payload->order_id = $data->order_id;
1376 $meta_payload->meta_key = OrderActivitiesModel::META_KEY_CANCEL_REASON;
1377 $meta_payload->meta_value = $data->cancel_reason;
1378
1379 $order_activities_model = new OrderActivitiesModel();
1380 $order_activities_model->add_order_meta( $meta_payload );
1381 }
1382
1383 if ( $message ) {
1384 $value = wp_json_encode(
1385 array(
1386 'message' => $message,
1387 )
1388 );
1389 OrderActivitiesController::store_order_activity( $data->order_id, OrderActivitiesModel::META_KEY_HISTORY, $value );
1390 }
1391 }
1392
1393 return $response;
1394 }
1395
1396 /**
1397 * Calculate discount amount.
1398 *
1399 * @since 3.0.0
1400 *
1401 * @param string $discount_type The type of discount ('percent' or 'flat').
1402 * @param float $discount_amount The amount of discount to apply.
1403 * @param float $sub_total The subtotal amount before applying the discount.
1404 *
1405 * @return float discount amount.
1406 */
1407 public function calculate_discount_amount( $discount_type, $discount_amount, $sub_total ) {
1408 if ( 'percentage' === $discount_type ) {
1409 $discounted_price = (float) $sub_total * ( ( (float) $discount_amount / 100 ) );
1410 } else {
1411 $discounted_price = (float) $discount_amount;
1412 }
1413 return $discounted_price;
1414 }
1415
1416 /**
1417 * Retrieves the total refund amount for a given order.
1418 *
1419 * This method fetches all refund records for the specified order ID from the database,
1420 * calculates the total refund amount, and returns it. The refund records are retrieved
1421 * from the `tutor_ordermeta` table where the `meta_key` matches the refund meta keys.
1422 *
1423 * @since 3.0.0
1424 *
1425 * @param int $order_id The ID of the order for which the refund amount is to be calculated.
1426 *
1427 * @return float The total refund amount for the order.
1428 */
1429 public function get_refund_amount( $order_id ) {
1430 global $wpdb;
1431
1432 $table = $wpdb->prefix . 'tutor_ordermeta';
1433 $meta_keys = array( OrderActivitiesModel::META_KEY_REFUND, OrderActivitiesModel::META_KEY_PARTIALLY_REFUND );
1434
1435 $where = array(
1436 'meta_key' => $meta_keys,
1437 'order_id' => $order_id,
1438 );
1439 $refund_records = QueryHelper::get_all( $table, $where, 'created_at_gmt' );
1440
1441 $refund_amount = 0;
1442
1443 foreach ( $refund_records as $refund ) {
1444 $refund_data = json_decode( $refund->meta_value );
1445
1446 if ( ! empty( $refund_data->amount ) ) {
1447 $refund_amount += (float) $refund_data->amount;
1448 }
1449 }
1450
1451 return $refund_amount;
1452 }
1453
1454 /**
1455 * Get order status based on the payment status
1456 *
1457 * @since 3.0.0
1458 *
1459 * @param string $payment_status Order payment status.
1460 *
1461 * @return string
1462 */
1463 public function get_order_status_by_payment_status( $payment_status ) {
1464 $status = '';
1465
1466 switch ( $payment_status ) {
1467 case self::PAYMENT_PAID:
1468 $status = self::ORDER_COMPLETED;
1469 break;
1470 case self::PAYMENT_UNPAID:
1471 $status = self::ORDER_INCOMPLETE;
1472 break;
1473 case self::PAYMENT_PARTIALLY_REFUNDED:
1474 $status = self::ORDER_COMPLETED;
1475 break;
1476 case self::PAYMENT_REFUNDED:
1477 $status = self::ORDER_CANCELLED;
1478 break;
1479 case self::PAYMENT_FAILED:
1480 $status = self::ORDER_CANCELLED;
1481 break;
1482 case self::ORDER_TRASH:
1483 $status = self::ORDER_TRASH;
1484 break;
1485 case 'delete':
1486 $status = self::ORDER_CANCELLED;
1487 break;
1488 case self::ORDER_CANCELLED:
1489 $status = self::ORDER_CANCELLED;
1490 break;
1491 }
1492
1493 return $status;
1494 }
1495
1496 /**
1497 * Calculate order price
1498 *
1499 * @since 3.0.0
1500 *
1501 * @param array $items Order items, multi or single dimensional arr.
1502 *
1503 * @return object {subtotal => 10, total => 10}
1504 */
1505 public static function calculate_order_price( array $items ) {
1506 $subtotal = 0;
1507 $total = 0;
1508
1509 if ( isset( $items[0] ) ) {
1510 foreach ( $items as $item ) {
1511 $regular_price = tutor_get_locale_price( $item['regular_price'] );
1512 $sale_price = is_null( $item['sale_price'] ) || '' === $item['sale_price'] ? null : tutor_get_locale_price( $item['sale_price'] );
1513 $discount_price = is_null( $item['discount_price'] ) || '' === $item['discount_price'] ? null : tutor_get_locale_price( $item['discount_price'] );
1514
1515 // Subtotal is the original price (regular price).
1516 $item_subtotal = $regular_price;
1517 $item_total = $regular_price;
1518
1519 // Determine the total based on sale price and discount.
1520 if ( ! is_null( $sale_price ) && $sale_price < $regular_price ) {
1521 $item_subtotal = $sale_price;
1522 $item_total = $sale_price;
1523 } else {
1524 // If there's a discount, apply it to the total price.
1525 if ( ! is_null( $discount_price ) && $discount_price >= 0 ) {
1526 $item_total = max( 0, $discount_price ); // Ensure total doesn't go below 0.
1527 }
1528 }
1529
1530 // $subtotal += $item_subtotal;
1531 $subtotal += $regular_price;
1532 $total += $item_total;
1533 }
1534 } else {
1535 // for single dimensional array.
1536 $regular_price = tutor_get_locale_price( $items['regular_price'] );
1537 $sale_price = is_null( $items['sale_price'] ) || '' === $items['sale_price'] ? null : tutor_get_locale_price( $items['sale_price'] );
1538 $discount_price = is_null( $items['discount_price'] ) || '' === $items['discount_price'] ? null : tutor_get_locale_price( $items['discount_price'] );
1539
1540 // Subtotal is the original price (regular price).
1541 $item_subtotal = $regular_price;
1542 $item_total = $regular_price;
1543
1544 // Determine the total based on sale price and discount.
1545 if ( ! is_null( $sale_price ) && $sale_price < $regular_price ) {
1546 $item_subtotal = $sale_price;
1547 $item_total = $sale_price;
1548 } else {
1549 // If there's a discount, apply it to the total price.
1550 if ( ! is_null( $discount_price ) && $discount_price >= 0 ) {
1551 $item_total = max( 0, $discount_price ); // Ensure total doesn't go below 0.
1552 }
1553 }
1554
1555 // $subtotal = $item_subtotal;
1556 $subtotal = $regular_price;
1557 $total = $item_total;
1558 }
1559
1560 return (object) array(
1561 'subtotal' => tutor_get_locale_price( $subtotal ),
1562 'total' => tutor_get_locale_price( $total ),
1563 );
1564 }
1565
1566 /**
1567 * Check has exclusive type tax.
1568 *
1569 * @since 3.0.0
1570 *
1571 * @param object $order order object.
1572 *
1573 * @return boolean
1574 */
1575 public static function has_exclusive_tax( $order ) {
1576 return self::TAX_TYPE_EXCLUSIVE === $order->tax_type && $order->tax_rate > 0 && $order->tax_amount > 0;
1577 }
1578
1579 /**
1580 * Check has inclusive type tax.
1581 *
1582 * @since 3.0.0
1583 *
1584 * @param object $order order object.
1585 *
1586 * @return boolean
1587 */
1588 public static function has_inclusive_tax( $order ) {
1589 return self::TAX_TYPE_INCLUSIVE === $order->tax_type && $order->tax_rate > 0 && $order->tax_amount > 0;
1590 }
1591
1592 /**
1593 * Get an item
1594 *
1595 * @since 3.0.0
1596 *
1597 * @param integer $item_id Item id.
1598 *
1599 * @return mixed
1600 */
1601 public function get_item( int $item_id ) {
1602 return QueryHelper::get_row(
1603 $this->order_item_table,
1604 array(
1605 'item_id' => $item_id,
1606 ),
1607 'id'
1608 );
1609 }
1610
1611 /**
1612 * Get sellable price
1613 *
1614 * @since 3.0.0
1615 *
1616 * @param mixed $regular_price Regular price.
1617 * @param mixed $sale_price Sale price.
1618 * @param mixed $discount_price Discount price.
1619 *
1620 * @return float item sellable price
1621 */
1622 public static function get_item_sellable_price( $regular_price, $sale_price = null, $discount_price = null ) {
1623 // Ensure prices are numeric and properly formatted.
1624 $sellable_price = (
1625 ! empty( $sale_price )
1626 ? $sale_price
1627 : (
1628 ( ! is_null( $discount_price ) && '' !== $discount_price ) && $discount_price >= 0
1629 ? $discount_price
1630 : $regular_price
1631 )
1632 );
1633
1634 return $sellable_price;
1635 }
1636
1637 /**
1638 * Get item sold price
1639 *
1640 * @since 3.0.0
1641 *
1642 * @param mixed $item_id Item id.
1643 * @param bool $format Item id.
1644 *
1645 * @return mixed item sellable price
1646 */
1647 public static function get_item_sold_price( $item_id, $format = true ) {
1648 $item = ( new self() )->get_item( $item_id );
1649
1650 if ( $item ) {
1651 $sold_price = self::get_item_sellable_price( $item->regular_price, $item->sale_price, $item->discount_price );
1652
1653 return $format ? tutor_get_formatted_price( $sold_price ) : $sold_price;
1654 }
1655
1656 return 0;
1657 }
1658
1659 /**
1660 * Should show pay btn to the user
1661 *
1662 * @since 3.0.0
1663 *
1664 * @param object $order Order object.
1665 *
1666 * @return boolean
1667 */
1668 public static function should_show_pay_btn( object $order ) {
1669 $order_items = ( new self() )->get_order_items_by_id( $order->id );
1670 $is_enrolled_any_course = false;
1671 $is_incomplete_payment = ! empty( $order->payment_method ) && self::ORDER_INCOMPLETE === $order->order_status;
1672 $is_manual_payment = $order->payment_method ? self::is_manual_payment( $order->payment_method ) : true;
1673
1674 if ( $is_incomplete_payment && ! $is_manual_payment && $order_items ) {
1675 if ( self::TYPE_SINGLE_ORDER === $order->order_type ) {
1676 foreach ( $order_items as $item ) {
1677 $course_id = $item->id;
1678 if ( $course_id ) {
1679 $is_enrolled = tutor_utils()->is_enrolled( $course_id );
1680 if ( $is_enrolled ) {
1681 $is_enrolled_any_course = true;
1682 break;
1683 }
1684 }
1685 }
1686 } else {
1687 if ( tutor_utils()->count( $order_items ) ) {
1688 $course_id = apply_filters( 'tutor_subscription_course_by_plan', $order_items[0]->id );
1689 if ( tutor_utils()->is_enrolled( $course_id ) ) {
1690 $is_enrolled_any_course = true;
1691 }
1692 }
1693 }
1694 }
1695
1696 return apply_filters( 'tutor_should_show_pay_btn', $is_incomplete_payment && ! $is_manual_payment && ! $is_enrolled_any_course );
1697 }
1698
1699 /**
1700 * Check is manual payment
1701 *
1702 * @since 3.0.0
1703 *
1704 * @param string $method_name Payment method name.
1705 *
1706 * @return boolean
1707 */
1708 public static function is_manual_payment( $method_name ) {
1709 $payment_methods = tutor_get_manual_payment_gateways();
1710
1711 $is_manual_payment = false;
1712 foreach ( $payment_methods as $payment_method ) {
1713 $is_manual_payment = $payment_method->name === $method_name;
1714 }
1715
1716 return $is_manual_payment;
1717 }
1718
1719 /**
1720 * Render pay button
1721 *
1722 * @since 3.0.1
1723 *
1724 * @param int|object $order Order id or object.
1725 *
1726 * @return void
1727 */
1728 public static function render_pay_button( $order ) {
1729
1730 if ( is_numeric( $order ) ) {
1731 $order = ( new self() )->get_order_by_id( $order );
1732 }
1733
1734 $show_pay_button = self::should_show_pay_btn( $order );
1735
1736 if ( ! self::should_active_pay_button( $order, $show_pay_button ) && $show_pay_button ) : ?>
1737
1738 <div class="tooltip-wrap tooltip-icon">
1739 <span class="tooltip-txt tooltip-left">
1740 <?php esc_html_e( 'Payment Is Pending Due To Gateway Processing.', 'tutor' ); ?>
1741 </span>
1742 </div>
1743 <?php elseif ( $show_pay_button ) :
1744 ob_start();
1745 ?>
1746
1747 <form method="post">
1748 <?php tutor_nonce_field(); ?>
1749 <input type="hidden" name="tutor_action" value="tutor_pay_incomplete_order">
1750 <input type="hidden" name="order_id" value="<?php echo esc_attr( $order->id ); ?>">
1751
1752 <button type="submit" class="tutor-btn tutor-btn-sm tutor-btn-outline-primary">
1753 <?php esc_html_e( 'Pay', 'tutor' ); ?>
1754 </button>
1755 </form>
1756
1757 <?php
1758 echo apply_filters( 'tutor_after_pay_button', ob_get_clean(), $order );
1759 endif;
1760 }
1761
1762 /**
1763 * Checks if the Repay Order-Time expired based on stored expiry time.
1764 *
1765 * @since 3.3.0
1766 *
1767 * @param object $order The order object containing order details.
1768 * @param bool $show_pay_button Whether the pay button should be shown.
1769 *
1770 * @return bool Returns true if the order is expired or expiry time is not set, otherwise false.
1771 */
1772 private static function should_active_pay_button( $order, $show_pay_button ) {
1773
1774 $current_time = time();
1775 $meta_key = self::META_KEY_ORDER_ID . $order->id;
1776 $user_id = get_current_user_id();
1777 $expiry_time = get_user_meta( $user_id, $meta_key, true );
1778
1779 if ( $expiry_time ) {
1780
1781 // If the time is expired or the order is paid then delete the meta key.
1782 if ( $expiry_time < $current_time || ! $show_pay_button ) {
1783 delete_user_meta( $user_id, $meta_key );
1784 return true;
1785 }
1786
1787 return false;
1788 }
1789
1790 return true;
1791 }
1792 }
1793