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