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