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