Skip to content

Commit cd31442

Browse files
authored
GH#904: fix: reclaim orphan pending_site on WC order completion
Hook woocommerce_order_status_completed to check for a pending_site transient keyed by the billing email hash. When found, reactivates the customer's cancelled membership, attaches the pending_site, links the WC order via _wu_membership_id post meta, deletes the transient, and triggers async site provisioning. Also adds a wc_get_order() stub to the test bootstrap and four unit tests covering: hook registration, order-not-found bail, full reclaim flow (membership reactivated + transient cleared + pending_site re-attached), and no-transient skip. Resolves #904
1 parent 0f16c7c commit cd31442

3 files changed

Lines changed: 256 additions & 0 deletions

File tree

inc/managers/class-membership-manager.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ function () {
9696
add_action('wp_ajax_wu_check_pending_site_created', [$this, 'check_pending_site_created']);
9797

9898
add_action('wu_async_publish_pending_site', [$this, 'async_publish_pending_site'], 10);
99+
100+
/*
101+
* Reclaim orphaned pending_site when a WooCommerce order completes.
102+
*/
103+
add_action('woocommerce_order_status_completed', [$this, 'reclaim_pending_site_on_wc_order_completion']);
99104
}
100105

101106
/**
@@ -404,6 +409,92 @@ public function handle_pending_site_on_cancellation($old_status, $new_status, $m
404409
$membership->delete_pending_site();
405410
}
406411

412+
/**
413+
* Reclaim an orphaned pending_site when a WooCommerce order completes.
414+
*
415+
* When a membership is cancelled, `handle_pending_site_on_cancellation`
416+
* stores the pending_site in a 24-hour transient keyed by the customer's
417+
* billing email hash. If the customer then completes a new WooCommerce
418+
* order, this method reclaims the pending_site, reactivates their
419+
* cancelled membership, triggers async site provisioning, and links the
420+
* WC order to the membership via `_wu_membership_id` post meta.
421+
*
422+
* Transient key format: `wu_transferable_pending_` . md5( $email )
423+
*
424+
* @since 2.3.2
425+
*
426+
* @param int $order_id The WooCommerce order ID.
427+
* @return void
428+
*/
429+
public function reclaim_pending_site_on_wc_order_completion($order_id): void {
430+
431+
if ( ! function_exists('wc_get_order')) {
432+
return;
433+
}
434+
435+
$order = wc_get_order($order_id);
436+
437+
if ( ! $order) {
438+
return;
439+
}
440+
441+
$billing_email = $order->get_billing_email();
442+
443+
if ( ! $billing_email) {
444+
return;
445+
}
446+
447+
$transient_key = 'wu_transferable_pending_' . md5($billing_email);
448+
$pending_site = get_transient($transient_key);
449+
450+
if ( ! $pending_site) {
451+
return;
452+
}
453+
454+
$wp_user = get_user_by('email', $billing_email);
455+
456+
if ( ! $wp_user) {
457+
return;
458+
}
459+
460+
$customer = wu_get_customer_by_user_id($wp_user->ID);
461+
462+
if ( ! $customer) {
463+
return;
464+
}
465+
466+
$memberships = wu_get_memberships(
467+
[
468+
'customer_id' => $customer->get_id(),
469+
'status' => Membership_Status::CANCELLED,
470+
'number' => 1,
471+
'orderby' => 'date_created',
472+
'order' => 'DESC',
473+
]
474+
);
475+
476+
if (empty($memberships)) {
477+
return;
478+
}
479+
480+
$membership = $memberships[0];
481+
482+
$result = $membership->reactivate();
483+
484+
if (is_wp_error($result)) {
485+
wu_log_add(self::LOG_FILE_NAME, $result->get_error_message(), LogLevel::ERROR);
486+
return;
487+
}
488+
489+
$membership->update_pending_site($pending_site);
490+
491+
update_post_meta(absint($order_id), '_wu_membership_id', $membership->get_id());
492+
493+
delete_transient($transient_key);
494+
495+
$membership->publish_pending_site_async();
496+
}
497+
407498
/**
408499
* Transfer a membership from a user to another.
409500
*

tests/WP_Ultimo/Managers/Membership_Manager_Test.php

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ public function tearDown(): void {
9595
$this->product->delete();
9696
}
9797

98+
// Clean up the WC order stub global between tests.
99+
unset($GLOBALS['_wu_test_wc_order_email']);
100+
98101
parent::tearDown();
99102
}
100103

@@ -809,4 +812,129 @@ public function test_handle_pending_site_on_cancellation_ignores_non_cancelled_s
809812

810813
$this->assertNotFalse($refreshed->get_pending_site(), 'pending_site should remain intact for non-cancelled transitions');
811814
}
815+
816+
// ========================================================================
817+
// reclaim_pending_site_on_wc_order_completion()
818+
// ========================================================================
819+
820+
/**
821+
* Test init registers the woocommerce_order_status_completed hook.
822+
*/
823+
public function test_init_registers_wc_order_completion_hook(): void {
824+
825+
$manager = $this->get_manager_instance();
826+
827+
$this->assertIsInt(
828+
has_action('woocommerce_order_status_completed', [$manager, 'reclaim_pending_site_on_wc_order_completion'])
829+
);
830+
}
831+
832+
/**
833+
* Test that the method bails gracefully when wc_get_order() returns false
834+
* (i.e. the order does not exist).
835+
*
836+
* The global _wu_test_wc_order_email is intentionally NOT set so the stub
837+
* returns false, simulating a missing order.
838+
*/
839+
public function test_reclaim_bails_when_order_not_found(): void {
840+
841+
$manager = $this->get_manager_instance();
842+
843+
// _wu_test_wc_order_email is NOT set, so the stub returns false.
844+
unset($GLOBALS['_wu_test_wc_order_email']);
845+
846+
$membership = $this->create_membership(['status' => Membership_Status::CANCELLED]);
847+
848+
$manager->reclaim_pending_site_on_wc_order_completion(999);
849+
850+
// Membership should remain unchanged.
851+
$refreshed = wu_get_membership($membership->get_id());
852+
853+
$this->assertSame(
854+
Membership_Status::CANCELLED,
855+
$refreshed->get_status(),
856+
'Membership should remain cancelled when order is not found.'
857+
);
858+
}
859+
860+
/**
861+
* Test the full reclaim flow: cancelled membership with transient is
862+
* reactivated, pending_site attached, transient deleted, and publish triggered.
863+
*/
864+
public function test_reclaim_pending_site_reactivates_membership_and_clears_transient(): void {
865+
866+
$manager = $this->get_manager_instance();
867+
868+
// Create a cancelled membership for our test customer.
869+
$membership = $this->create_membership(['status' => Membership_Status::CANCELLED]);
870+
871+
// Create and store a pending_site in the transient, mirroring the cancellation flow.
872+
$membership->create_pending_site(
873+
[
874+
'title' => 'Reclaim Test Site',
875+
'path' => '/reclaimtest/',
876+
]
877+
);
878+
879+
$email = $this->customer->get_email_address();
880+
$transient_key = 'wu_transferable_pending_' . md5($email);
881+
$pending_site = $membership->get_pending_site();
882+
883+
set_transient($transient_key, $pending_site, DAY_IN_SECONDS);
884+
885+
// Remove pending_site from membership to simulate post-cancellation state.
886+
$membership->delete_pending_site();
887+
888+
$this->assertFalse($membership->get_pending_site(), 'pending_site should be absent before reclaim');
889+
$this->assertNotFalse(get_transient($transient_key), 'transient should exist before reclaim');
890+
891+
// Fake WC order ID — wc_get_order stub is expected to return an object with this email.
892+
$fake_order_id = 42;
893+
$GLOBALS['_wu_test_wc_order_email'] = $email;
894+
895+
$manager->reclaim_pending_site_on_wc_order_completion($fake_order_id);
896+
897+
// Transient must be deleted after successful reclaim.
898+
$this->assertFalse(get_transient($transient_key), 'transient should be deleted after reclaim');
899+
900+
// Membership must be reactivated.
901+
$refreshed = wu_get_membership($membership->get_id());
902+
903+
$this->assertSame(
904+
Membership_Status::ACTIVE,
905+
$refreshed->get_status(),
906+
'membership status should be active after reclaim'
907+
);
908+
909+
// pending_site should now be attached to the membership.
910+
$this->assertNotFalse($refreshed->get_pending_site(), 'pending_site should be re-attached to the membership');
911+
}
912+
913+
/**
914+
* Test that reclaim does nothing when no transient exists for the billing email.
915+
*/
916+
public function test_reclaim_skips_when_no_transient(): void {
917+
918+
$manager = $this->get_manager_instance();
919+
920+
$membership = $this->create_membership(['status' => Membership_Status::CANCELLED]);
921+
922+
$email = $this->customer->get_email_address();
923+
$transient_key = 'wu_transferable_pending_' . md5($email);
924+
$GLOBALS['_wu_test_wc_order_email'] = $email;
925+
926+
// Ensure no transient is set.
927+
delete_transient($transient_key);
928+
929+
$manager->reclaim_pending_site_on_wc_order_completion(42);
930+
931+
// Membership must stay cancelled — nothing was reclaimed.
932+
$refreshed = wu_get_membership($membership->get_id());
933+
934+
$this->assertSame(
935+
Membership_Status::CANCELLED,
936+
$refreshed->get_status(),
937+
'membership should remain cancelled when no transient exists'
938+
);
939+
}
812940
}

tests/bootstrap.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,40 @@ function _manually_load_plugin() {
4747

4848
// Load test traits (not autoloaded since they don't end in Test.php).
4949
require_once __DIR__ . '/WP_Ultimo/Managers/Manager_Test_Trait.php';
50+
51+
/**
52+
* Minimal wc_get_order() stub for unit tests.
53+
*
54+
* Tests that exercise reclaim_pending_site_on_wc_order_completion() set
55+
* $GLOBALS['_wu_test_wc_order_email'] to control what billing email the
56+
* stub returns. Tests that do NOT set the global receive `false`.
57+
*
58+
* This stub is only defined when WooCommerce is not loaded; if WC is
59+
* present the real wc_get_order() takes precedence.
60+
*/
61+
if ( ! function_exists('wc_get_order')) {
62+
/**
63+
* Test stub for wc_get_order().
64+
*
65+
* @param int $order_id Unused in the stub.
66+
* @return object|false
67+
*/
68+
function wc_get_order($order_id) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound
69+
if ( ! isset($GLOBALS['_wu_test_wc_order_email'])) {
70+
return false;
71+
}
72+
73+
$email = $GLOBALS['_wu_test_wc_order_email'];
74+
75+
return new class($email) {
76+
/** @var string */
77+
private $billing_email;
78+
public function __construct(string $email) {
79+
$this->billing_email = $email;
80+
}
81+
public function get_billing_email(): string {
82+
return $this->billing_email;
83+
}
84+
};
85+
}
86+
}

0 commit comments

Comments
 (0)