Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/krevaestudio/kreva-tutor-order-guard/archive/refs/heads/main.zipPrevents Tutor LMS from revoking course access when a duplicate WooCommerce order is cancelled, refunded or failed and the student still holds another valid order for the same product.
A student buys the same course twice by mistake. The site owner cancels one of the two orders. Tutor LMS revokes course access, even though the other order is still paid and valid.
User-facing documentation lives in readme.txt (WordPress.org format). This file
is the maintainer's view.
Why it happens
Tutor LMS keeps the enrollment record (custom post type tutor_enrolled, with
post_status in completed / pending / cancel) in sync with the WooCommerce
order status, hooked to woocommerce_order_status_changed at priority 10
(themeum/tutor, classes/WooCommerce.php → enrolled_courses_status_change()).
That logic looks only at the order that changed. It never checks whether the same student holds another valid order for the same product.
The critical invariant
Tutor LMS creates one tutor_enrolled record per order, not one per
student+course. This is visible in Tutor's own API: enrollments are looked up
with tutor_utils()->get_course_enrolled_ids_by_order_id( $order_id ).
Everything in this plugin follows from that. When a duplicate is found, the plugin activates the enrollment belonging to the valid order, and never touches the one attached to the cancelled order.
Reactivating the cancelled order's enrollment — which version 1.0.0 did, because it selected the most recent enrollment for the student and course — produces this failure:
- Student has order A (completed) and order B (completed). Two enrollment records exist.
- Owner cancels B. Tutor sets B's enrollment to
cancel. - Plugin finds A is valid, but reactivates B's enrollment (highest ID).
- Owner later refunds A. Tutor cancels A's enrollment. The plugin finds no other valid order and correctly does nothing.
- B's enrollment is still
completed. The student keeps access with both orders refunded.
Flow
On woocommerce_order_status_changed at priority 999 (after Tutor), for the
statuses cancelled, refunded and failed:
- Skip unless
tutor_utils()->is_tutor_order()— mirrors Tutor's own gate, so stores selling non-course products skip unrelated cancellations. - Resolve the Tutor course(s) linked to each product in the order.
- Find another order from the same customer, in a valid status (
completedby default), containing that product. - If found, activate the enrollment belonging to that order via
tutor_utils()->course_enrol_status_change( $enrolled_id, 'completed' ). If that order has no enrollment record, create one withtutor_utils()->do_enroll(). - If not found, do nothing. The cancellation stands.
On the same hook, when an order moves to a valid status:
- Resolve the Tutor course(s) linked to each product in the order. The
is_tutor_order()gate is not applied here: the orders this path exists for are exactly the ones Tutor never flagged. - Skip if the order already has its own enrollment record (Tutor manages it) or the student already holds an active enrollment for the course.
- Otherwise create the enrollment against that order with
do_enroll()and activate it. This covers a duplicate order paid after the original one was cancelled, which Tutor leaves without access.
Every decision is written to the audit log at WooCommerce → Tutor Order Guard.
Deploying to a new site
- Install and activate. WooCommerce is required; Tutor LMS is required for the plugin to act.
- Confirm the product → course mapping on that specific site (below).
- Test with the manual repair tool on a case that is already broken, using the student's email and the course ID. Confirm the log reads Enrollment restored or Student re-enrolled.
Product ↔ course mapping
The course is resolved from Tutor LMS's native _tutor_course_product_id meta on
the course post.
This works in Tutor LMS Free as well as Pro. Free users attach an existing
WooCommerce product to a course from the course settings; Pro additionally lets
you create the product from the course editor. Both write the same meta — see
themeum/tutor classes/Course.php → attach_product_with_course(), where the
tutor()->has_pro === false branch calls
update_post_meta( $post_ID, self::COURSE_PRODUCT_ID_META, $product_id ). The
Pro gate is on product creation, not on the link itself.
For sites where the link was not built with Tutor's native mechanism (custom enrollment logic, a legacy mapping table, courses migrated from another system), add a site-specific snippet hooking these two filters — do not edit this plugin, so it stays updatable everywhere:
// Product -> course (used when processing cancellations/refunds)
add_filter( 'krv_tog_resolve_course_ids_from_product', function( $course_ids, $product_id ) {
$map = array(
123 => 45, // product_id => course_id
124 => 46,
);
if ( isset( $map[ $product_id ] ) ) {
$course_ids[] = $map[ $product_id ];
}
return $course_ids;
}, 10, 2 );
// Course -> product (used only by the manual repair screen)
add_filter( 'krv_tog_resolve_product_id_from_course', function( $product_id, $course_id ) {
$map = array(
45 => 123, // course_id => product_id
46 => 124,
);
return isset( $map[ $course_id ] ) ? $map[ $course_id ] : $product_id;
}, 10, 2 );
Other filters
Sites using the manual mapping filters above may have orders Tutor never flagged
as course orders. The is_tutor_order() gate must then be disabled, or the
plugin will never act:
add_filter( 'krv_tog_enforce_tutor_order_check', '__return_false' );
Only completed counts as paid by default, mirroring Tutor LMS. processing is
left out because cash on delivery orders sit there unpaid. Sites where every
processing order is paid, or that grant access on on-hold (bank transfer
approved by hand), can add those statuses:
add_filter( 'krv_tog_valid_statuses', function( $statuses ) {
$statuses[] = 'processing';
return $statuses;
} );
Compatibility
Declares WooCommerce High-Performance Order Storage (HPOS) compatibility. All
order reads go through the WooCommerce CRUD API (wc_get_orders(), WC_Order
getters), and admin order links are built with WC_Order::get_edit_order_url(),
which is correct under both storage backends.
Translations
languages/kreva-tutor-order-guard.pot— source catalogue, regenerate after changing any translatable string.languages/kreva-tutor-order-guard-es_ES.{po,mo}— bundled Spanish.
Known limitations
- Only acts on line items with a
product_id. Variations with course logic that differs from the parent product are not handled. - Does nothing on partial refunds — correctly: WooCommerce does not change the order status on a partial refund, so Tutor never revokes access.
- Guest orders are skipped; there is no user account to protect.
- The audit log is a single site option written with a read-modify-write cycle. Two cancellations processed in the same instant can drop one log entry. The enrollment repair itself is unaffected.
- Relies on the
tutor_enrolledpost type and itscompleted/cancelstatuses, plus thetutor_utils()helpers listed above. Stable across Tutor Free and Pro, but revalidate on a major Tutor version bump.