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