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