PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / trunk
Tutor LMS – eLearning and online course solution vtrunk
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 / ecommerce / HooksHandler.php
tutor / ecommerce Last commit date
Cart 10 months ago PaymentGateways 3 weeks ago AdminMenu.php 9 months ago BillingController.php 1 year ago CartController.php 1 year ago CheckoutController.php 2 months ago CouponController.php 5 months ago Ecommerce.php 1 year ago EmailController.php 11 months ago HooksHandler.php 2 months ago OptionKeys.php 1 year ago OrderActivitiesController.php 1 year ago OrderController.php 6 months ago PaymentHandler.php 9 months ago Settings.php 9 months ago Tax.php 9 months ago currency.php 5 months ago
HooksHandler.php
572 lines
1 <?php
2 /**
3 * Handle ecommerce hooks
4 *
5 * @package Tutor\Ecommerce
6 * @author Themeum <support@themeum.com>
7 * @link https://themeum.com
8 * @since 3.0.0
9 */
10
11 namespace Tutor\Ecommerce;
12
13 use TUTOR\Input;
14 use TUTOR\Course;
15 use TUTOR\Earnings;
16 use Tutor\Models\CartModel;
17 use Tutor\Models\OrderModel;
18 use Tutor\Helpers\QueryHelper;
19 use Tutor\Models\OrderMetaModel;
20 use Tutor\Models\OrderActivitiesModel;
21 use TutorPro\CourseBundle\Models\BundleModel;
22 use TutorPro\CourseBundle\CustomPosts\CourseBundle;
23
24 /**
25 * Handle custom hooks
26 */
27 class HooksHandler {
28
29 /**
30 * OrderModel
31 *
32 * @since 3.0.0
33 *
34 * @var OrderModel
35 */
36 private $order_model;
37
38 /**
39 * OrderActivitiesModel
40 *
41 * @since 3.0.0
42 *
43 * @var OrderActivitiesModel
44 */
45 private $order_activities_model;
46
47 /**
48 * Coupon controller instance
49 *
50 * @since 3.5.0
51 *
52 * @var CouponController
53 */
54 private $coupon_ctrl;
55
56 /**
57 * Register hooks & resolve props
58 *
59 * @since 3.0.0
60 */
61 public function __construct() {
62 $this->order_activities_model = new OrderActivitiesModel();
63 $this->order_model = new OrderModel();
64 $this->coupon_ctrl = new CouponController( false );
65
66 // Register hooks.
67 add_filter( 'tutor_course_sell_by', array( $this, 'alter_course_sell_by' ) );
68 add_filter( 'get_tutor_course_price', array( $this, 'alter_course_price' ), 10, 2 );
69
70 // Order hooks.
71 add_action( 'tutor_order_payment_updated', array( $this, 'handle_payment_updated_webhook' ) );
72
73 add_action( 'tutor_order_payment_status_changed', array( $this, 'handle_payment_status_changed' ), 10, 3 );
74
75 add_action( 'tutor_order_placement_success', array( $this, 'handle_order_placement_success' ) );
76
77 /**
78 * Clear order menu badge count
79 *
80 * @since 3.0.0
81 */
82 add_action( 'tutor_order_placed', array( $this, 'clear_order_badge_count' ) );
83 add_action( 'tutor_order_payment_status_changed', array( $this, 'clear_order_badge_count' ) );
84 add_action( 'tutor_before_order_bulk_action', array( $this, 'clear_order_badge_count' ) );
85 add_filter( 'tutor_before_order_create', array( $this, 'update_order_data' ) );
86 add_action( 'tutor_order_placed', array( $this, 'handle_free_checkout' ) );
87 add_filter( 'tutor_redirect_url_after_checkout', array( $this, 'redirect_to_the_course' ), 10, 3 );
88
89 /**
90 * Store customer billing information for each order.
91 *
92 * @since 3.5.0
93 */
94 add_action( 'tutor_order_placed', array( $this, 'store_billing_address_for_order' ) );
95 add_action( 'tutor_order_updated', array( $this, 'store_billing_address_for_order' ) );
96 }
97
98 /**
99 * Clear order menu badge count
100 *
101 * @since 3.0.0
102 *
103 * @return void
104 */
105 public function clear_order_badge_count() {
106 delete_transient( OrderModel::TRANSIENT_ORDER_BADGE_COUNT );
107 }
108
109 /**
110 * Store order activity before bulk action.
111 *
112 * @since 3.0.0
113 *
114 * @param string $bulk_action The bulk action being performed.
115 * @param array $bulk_ids The IDs of the orders being acted upon.
116 *
117 * @return void
118 */
119 public function after_order_bulk_action( $bulk_action, $bulk_ids ) {
120 $order_status = $this->order_model->get_order_status_by_payment_status( $bulk_action );
121
122 $cancel_reason = Input::post( 'cancel_reason', '' );
123 foreach ( $bulk_ids as $order_id ) {
124 try {
125 $this->manage_earnings_and_enrollments( $order_status, $order_id );
126 $data = (object) array(
127 'order_id' => $order_id,
128 'meta_key' => $this->order_activities_model::META_KEY_HISTORY,
129 'meta_value' => "Order mark as {$bulk_action} {$cancel_reason}",
130 );
131 $this->order_activities_model->add_order_meta( $data );
132 } catch ( \Throwable $th ) {
133 // Log message with line & file.
134 error_log( $th->getMessage() . ' in ' . $th->getFile() . ' at line ' . $th->getLine() );
135 }
136 }
137 }
138
139 /**
140 * Alter course sell by value
141 *
142 * @since 3.0.0
143 *
144 * @param mixed $sell_by Default sell by.
145 *
146 * @return mixed
147 */
148 public function alter_course_sell_by( $sell_by ) {
149 if ( tutor_utils()->is_monetize_by_tutor() ) {
150 $sell_by = Ecommerce::MONETIZE_BY;
151 }
152
153 return $sell_by;
154 }
155
156 /**
157 * Alter course price to show price on the course
158 * entry box
159 *
160 * @since 3.0.0
161 *
162 * @param mixed $price Course price.
163 * @param int $course_id Course id.
164 *
165 * @return mixed
166 */
167 public function alter_course_price( $price, $course_id ) {
168 $price_type = tutor_utils()->price_type( $course_id );
169 if ( tutor_utils()->is_monetize_by_tutor() && Course::PRICE_TYPE_PAID === $price_type ) {
170 $price = tutor_get_course_formatted_price_html( $course_id, false );
171 }
172
173 return $price;
174 }
175
176 /**
177 * Handle payment updated webhook
178 *
179 * @since 3.0.0
180 *
181 * @param object $res Response data.
182 * {order_id, transaction_id, payment_status, payment_method, redirectUrl}.
183 *
184 * @return void
185 */
186 public function handle_payment_updated_webhook( $res ) {
187 $order_id = $res->id;
188 $new_payment_status = $res->payment_status;
189 $transaction_id = $res->transaction_id;
190
191 $order_details = $this->order_model->get_order_by_id( $order_id );
192
193 /**
194 * Ignore canceled/failed webhooks for old/failed payment session to avoid unenrolling paid users.
195 *
196 * @since 3.9.7
197 */
198 $is_valid_paid_order = OrderModel::ORDER_COMPLETED === $order_details->order_status && OrderModel::PAYMENT_PAID === $order_details->payment_status;
199
200 if ( $order_details && ! $is_valid_paid_order ) {
201 $prev_payment_status = $order_details->payment_status;
202
203 $order_data = array(
204 'order_status' => $order_details->order_status,
205 'payment_status' => $new_payment_status,
206 'payment_method' => $res->payment_method,
207 'payment_payloads' => $res->payment_payload,
208 'transaction_id' => $transaction_id,
209 'earnings' => $res->earnings,
210 'fees' => $res->fees,
211 'updated_at_gmt' => current_time( 'mysql', true ),
212 );
213
214 switch ( $new_payment_status ) {
215 case $this->order_model::PAYMENT_PAID:
216 $order_data['order_status'] = $this->order_model::ORDER_COMPLETED;
217 break;
218 case $this->order_model::PAYMENT_FAILED:
219 case $this->order_model::PAYMENT_REFUNDED:
220 $order_data['order_status'] = $this->order_model::ORDER_CANCELLED;
221 break;
222 case $this->order_model::PAYMENT_PENDING:
223 $order_data['order_status'] = $this->order_model::ORDER_PENDING;
224 break;
225 }
226
227 $update = $this->order_model->update_order( $order_id, $order_data );
228 if ( $update ) {
229 // Provide hook after update order.
230 do_action( 'tutor_order_payment_status_changed', $order_id, $prev_payment_status, $new_payment_status );
231 }
232 }
233 }
234
235 /**
236 * Update enrollment & earnings based on payment status
237 *
238 * @since 3.0.0
239 *
240 * @param int $order_id Order id.
241 * @param string $prev_payment_status previous payment status.
242 * @param string $new_payment_status new payment status.
243 *
244 * @return void
245 */
246 public function handle_payment_status_changed( $order_id, $prev_payment_status, $new_payment_status ) {
247
248 $order_status = $this->order_model->get_order_status_by_payment_status( $new_payment_status );
249
250 $cancel_reason = Input::post( 'cancel_reason' );
251 $remove_enrollment = Input::post( 'is_remove_enrolment', false, Input::TYPE_BOOL );
252
253 // Store activity.
254 $data = (object) array(
255 'order_id' => $order_id,
256 'meta_key' => $this->order_activities_model::META_KEY_HISTORY,
257 'meta_value' => 'Order marked as ' . $new_payment_status,
258 );
259
260 if ( $cancel_reason ) {
261 $meta_value = array(
262 'message' => 'Order marked as ' . $new_payment_status,
263 'cancel_reason' => $cancel_reason,
264 );
265 $data->meta_value = json_encode( $meta_value );
266 }
267
268 $this->order_activities_model->add_order_meta( $data );
269
270 if ( $remove_enrollment ) {
271 $order_status = OrderModel::ORDER_CANCELLED;
272 }
273
274 $this->manage_earnings_and_enrollments( $order_status, $order_id );
275
276 // Store coupon usage.
277 $this->coupon_ctrl->store_coupon_usage( $order_id );
278 }
279
280 /**
281 * Handle new order placement
282 *
283 * Clear cart items, managing enrollment & earnings
284 *
285 * @since 3.0.0
286 *
287 * @param int $order_id Order id.
288 *
289 * @return void
290 */
291 public function handle_order_placement_success( int $order_id ) {
292 $order_data = $this->order_model->get_order_by_id( $order_id );
293 if ( $order_data ) {
294 $user_id = $order_data->student->id;
295
296 ( new CartModel() )->clear_user_cart( $user_id );
297
298 // Manage enrollment & earnings.
299 $order = ( new OrderModel() )->get_order_by_id( $order_id );
300 $payment_status = $order->payment_status;
301
302 $order_status = $this->order_model->get_order_status_by_payment_status( $payment_status );
303
304 $this->manage_earnings_and_enrollments( $order_status, $order_id );
305 }
306 }
307
308 /**
309 * Check if order is bundle order
310 *
311 * @since 3.0.0
312 *
313 * @param object $order order object.
314 * @param int $object_id object id.
315 *
316 * @return boolean
317 */
318 private function is_bundle_order( $order, $object_id ) {
319 return tutor_utils()->is_addon_enabled( 'course-bundle' )
320 && in_array( $order->order_type, array( $this->order_model::TYPE_SINGLE_ORDER, $this->order_model::TYPE_SUBSCRIPTION ), true )
321 && 'course-bundle' === get_post_type( $object_id );
322 }
323
324 /**
325 * Manage earnings after order bulk action
326 *
327 * @since 3.0.0
328 *
329 * @param string $order_status Order status.
330 * @param int $order_id Order ID.
331 *
332 * @return void
333 */
334 public function manage_earnings_and_enrollments( string $order_status, int $order_id ) {
335 $earnings = Earnings::get_instance();
336 $order = $this->order_model->get_order_by_id( $order_id );
337 $student_id = $order->student->id;
338
339 $enrollment_status = ( OrderModel::ORDER_COMPLETED === $order_status ? 'completed' : ( OrderModel::ORDER_INCOMPLETE === $order->order_status ? 'pending' : 'cancel' ) );
340
341 foreach ( $order->items as $item ) {
342 $object_id = $item->id; // It could be course/bundle/plan id.
343 $is_gift_item = apply_filters( 'tutor_is_gift_item', false, $item->primary_id );
344 if ( $is_gift_item ) {
345 continue;
346 }
347
348 if ( $this->order_model::TYPE_SINGLE_ORDER !== $order->order_type ) {
349 /**
350 * Do not process enrollment for membership plan.
351 *
352 * @since 3.2.0
353 */
354 $plan_info = apply_filters( 'tutor_get_plan_info', null, $object_id );
355 if ( $plan_info && isset( $plan_info->is_membership_plan ) && $plan_info->is_membership_plan ) {
356 continue;
357 } else {
358 $object_id = apply_filters( 'tutor_subscription_course_by_plan', $item->id, $order );
359 }
360
361 /**
362 * Do not process enrollment for subscription order refund.
363 * It will be handled by subscription controller's handle_order_refund method.
364 *
365 * @since 3.3.0
366 */
367 if ( Input::has( 'is_cancel_subscription' ) ) {
368 continue;
369 }
370 }
371
372 $has_enrollment = tutor_utils()->is_enrolled( $object_id, $student_id, false );
373 if ( $has_enrollment ) {
374 // Update enrollment status based on order status.
375 $update = tutor_utils()->update_enrollments( $enrollment_status, array( $has_enrollment->ID ) );
376 if ( $update ) {
377 if ( $this->is_bundle_order( $order, $object_id ) && $this->order_model->is_single_order( $order ) ) {
378 if ( 'completed' === $enrollment_status ) {
379 BundleModel::enroll_to_bundle_courses( $object_id, $student_id );
380 } else {
381 BundleModel::disenroll_from_bundle_courses( $object_id, $student_id );
382 }
383 }
384
385 /**
386 * For subscription, renewal no need to update order id.
387 */
388 if ( $this->order_model->is_single_order( $order ) ) {
389 update_post_meta( $has_enrollment->ID, '_tutor_enrolled_by_order_id', $order_id );
390
391 /**
392 * Update enrollment expiry date if it is set in a course.
393 */
394 if ( tutor()->course_post_type === get_post_type( $object_id ) ) {
395 $is_set_enrollment_expiry = (int) get_tutor_course_settings( $object_id, 'enrollment_expiry' );
396 $enrollment_expiry_enabled = (bool) get_tutor_option( 'enrollment_expiry_enabled' );
397 if ( $enrollment_expiry_enabled && $is_set_enrollment_expiry ) {
398 global $wpdb;
399 QueryHelper::update(
400 $wpdb->posts,
401 array(
402 'post_date' => current_time( 'mysql' ),
403 'post_date_gmt' => current_time( 'mysql', true ),
404 ),
405 array(
406 'ID' => $has_enrollment->ID,
407 'post_type' => tutor()->enrollment_post_type,
408 )
409 );
410 }
411 }
412
413 if ( OrderModel::ORDER_COMPLETED === $order_status ) {
414 do_action( 'tutor_after_enrolled', $object_id, $student_id, $has_enrollment->ID );
415 }
416 }
417
418 if ( 'completed' === $enrollment_status ) {
419 do_action( 'tutor_order_enrolled', $order, $has_enrollment->ID );
420 }
421 }
422 } else {
423 if ( $order->order_status === $this->order_model::ORDER_COMPLETED ) {
424 // Insert enrollment.
425 add_filter( 'tutor_enroll_data', fn( $enroll_data) => array_merge( $enroll_data, array( 'post_status' => 'completed' ) ) );
426
427 $enrollment_id = tutor_utils()->do_enroll( $object_id, $order_id, $student_id );
428 if ( $enrollment_id ) {
429 if ( $this->is_bundle_order( $order, $object_id ) && $this->order_model->is_single_order( $order ) ) {
430 BundleModel::enroll_to_bundle_courses( $object_id, $student_id );
431 }
432 update_post_meta( $enrollment_id, '_tutor_enrolled_by_order_id', $order_id );
433
434 do_action( 'tutor_order_enrolled', $order, $enrollment_id );
435 } else {
436 // Log error message with student id and course id.
437 error_log( "Error updating enrollment for student {$student_id} and course {$object_id}" );
438 }
439 }
440 }
441 }
442
443 // Update earnings.
444 $earnings->prepare_order_earnings( $order_id );
445 $earnings->remove_before_store_earnings();
446 }
447
448 /**
449 * Update order data for the free checkout
450 *
451 * @since 3.4.0
452 *
453 * @param array $order_data Order data.
454 *
455 * @return array
456 */
457 public function update_order_data( array $order_data ) {
458 if ( empty( $order_data['total_price'] ) && OrderModel::TYPE_SINGLE_ORDER === $order_data['order_type'] ) {
459 $order_data['order_status'] = OrderModel::ORDER_COMPLETED;
460 $order_data['payment_status'] = OrderModel::PAYMENT_PAID;
461 $order_data['payment_method'] = 'free';
462 }
463 return $order_data;
464 }
465
466 /**
467 * Enroll user to the course when free checkout
468 *
469 * @since 3.4.0
470 *
471 * @param array $order_data Order data.
472 *
473 * @return array
474 */
475 public function handle_free_checkout( array $order_data ) {
476 if ( empty( $order_data['total_price'] ) && OrderModel::TYPE_SINGLE_ORDER === $order_data['order_type'] ) {
477 $order_id = $order_data['id'];
478 $user_id = $order_data['user_id'];
479 $items = $order_data['items'];
480 foreach ( $items as $item ) {
481 add_filter( 'tutor_enroll_data', fn( $enroll_data ) => array_merge( $enroll_data, array( 'post_status' => 'completed' ) ) );
482
483 $enrolled_id = tutor_utils()->do_enroll( $item['item_id'], $order_data['id'], $user_id );
484 if ( $enrolled_id && tutor_utils()->is_addon_enabled( 'course-bundle' ) && get_post_type( $item['item_id'] ) === CourseBundle::POST_TYPE ) {
485 BundleModel::enroll_to_bundle_courses( $item['item_id'], $user_id );
486 }
487 }
488
489 // Store coupon usage.
490 $this->coupon_ctrl->store_coupon_usage( $order_id );
491 }
492 return $order_data;
493 }
494
495 /**
496 * Redirect user to the course after free checkout when item is 1.
497 * If user checkout multiple items and keep the default behavior.
498 *
499 * @since 3.4.0
500 *
501 * @param string $url Default redirect url.
502 * @param string $status Order placement status.
503 * @param integer $order_id Order id.
504 *
505 * @return string
506 */
507 public function redirect_to_the_course( string $url, string $status, int $order_id ): string {
508 $user_id = get_current_user_id();
509 if ( OrderModel::ORDER_PLACEMENT_SUCCESS === $status ) {
510 $order = $this->order_model->get_order_by_id( $order_id );
511 if ( $order && count( $order->items ) === 1 && empty( $order->total_price ) && OrderModel::TYPE_SINGLE_ORDER === $order->order_type ) {
512
513 // Firing hook to clear cart.
514 do_action( 'tutor_order_placement_success', $order_id );
515
516 // Clear the alert message.
517 delete_transient( CheckoutController::PAY_NOW_ALERT_MSG_TRANSIENT_KEY . $user_id );
518 delete_transient( CheckoutController::PAY_NOW_ERROR_TRANSIENT_KEY . $user_id );
519 $course_id = $order->items[0]->id;
520 $url = get_the_permalink( $course_id );
521 }
522 }
523 return $url;
524 }
525
526 /**
527 * Store billing address for an order when order is placed.
528 *
529 * @since 3.5.0
530 *
531 * @param array $order_data order data.
532 *
533 * @return void
534 */
535 public function store_billing_address_for_order( array $order_data ) {
536 $order_id = $order_data['id'];
537 $user_id = $order_data['user_id'];
538 $billing_info = ( new BillingController( false ) )->get_billing_info( $user_id );
539
540 /**
541 * JSON_UNESCAPED_UNICODE is used to ensure that the billing info is stored in a readable format.
542 * This is important for languages that use non-ASCII characters like ñ, á, é, í, ó, ú, ü, etc.
543 *
544 * @since 3.7.1
545 */
546 $meta_value = '{}';
547 if ( $billing_info ) {
548 $meta_value = wp_json_encode( $billing_info, JSON_UNESCAPED_UNICODE );
549 } else {
550 /**
551 * Store user data as billing info
552 * If user has no billing info during order like manual enrollment from CSV.
553 */
554 $user_data = get_userdata( $user_id );
555 $meta_value = wp_json_encode(
556 array(
557 'billing_first_name' => $user_data->first_name,
558 'billing_last_name' => $user_data->last_name,
559 'billing_email' => $user_data->user_email,
560 ),
561 JSON_UNESCAPED_UNICODE
562 );
563 }
564
565 OrderMetaModel::add_meta(
566 $order_id,
567 OrderModel::META_KEY_BILLING_ADDRESS,
568 $meta_value
569 );
570 }
571 }
572