-
Notifications
You must be signed in to change notification settings - Fork 82
Description
There is a bug in classes/WooCommerce.php, function enrolled_courses_status_change() (lines 524-567) that triggers on the woocommerce_order_status_changed hook (registered at line 73) to get enrollment IDs by order ID and changes enrollment status based on new order status, but it doesn't check if the student has another successful payment for the same course. When Stripe auto-cancels an incomplete payment ~24 hours later, WooCommerce fires woocommerce_order_status_changed with status_to = 'cancelled'. The function then cancels the enrollment without verifying if a subsequent successful payment exists.
Importantly, the WooCommerce "order isolation" is incomplete. While it uses get_course_enrolled_ids_by_order_id() (classes/Utils.php, lines 2691-2732) to find enrollments linked to a specific order via the _tutor_order_for_course_id_{course_id} meta stored on the order, this meta is not cleaned up when multiple orders reference the same enrollment. When do_enroll() (classes/Utils.php, lines 2478-2550) is called for a retry payment and finds an existing completed enrollment, it returns that enrollment ID and sets the new order's meta to point to it (line 2533)—but does not remove the failed order's stale reference. As a result, both the failed Order A and successful Order B can have _tutor_order_for_course_id_{course_id} pointing to the same enrollment. When Order A is cancelled, get_course_enrolled_ids_by_order_id(Order A) still finds and cancels the shared enrollment, even though Order B (successful) also references it.
Tutor LMS's Native Ecommerce has the same bug, but worse. In ecommerce/HooksHandler.php, function manage_earnings_and_enrollments() (lines 323-435), when a payment fails or is cancelled, the system finds the enrollment using is_enrolled($course_id, $student_id, false) (line 361). This lookup returns ANY enrollment for that course+student combination—it doesn't filter by order at all. The enrollment status is then updated to 'cancel' via update_enrollments() (line 364, calling classes/Utils.php line 10030) without checking whether this enrollment was created by the failing order, or whether another successful order exists for the same course/student. Unlike the WooCommerce integration, which at least attempts order-level lookup, Native Ecommerce can cancel an enrollment created by a completely different successful order.
The WooCommerce integration has better order isolation than Native Ecommerce, but both lack the critical safeguard: checking for other successful orders before cancelling an enrollment.
The _tutor_enrolled_by_order_id meta key exists (stored on the enrollment post) and tracks which order created or last updated an enrollment, but in Native Ecommerce it is set AFTER the enrollment status change (ecommerce/HooksHandler.php, line 378), not used as validation BEFORE. It is never checked when processing a cancellation to verify order ownership and is not used to prevent cancelling enrollments linked to different orders.
NOTE: While the EDD integration does not have the same bug, I could not locate a handler for refunds or cancellations.