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