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