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