PluginProbe
Tutor LMS – eLearning and online course solution / 3.9.2
Tutor LMS – eLearning and online course solution v3.9.2
4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 3.9.15 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 All 191 releases
tutor / models / OrderModel.php

OrderModel.php in Tutor LMS – eLearning and online course solution 3.9.2, at models/OrderModel.php

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