Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions inc/managers/class-membership-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ function () {
add_action('wp_ajax_wu_check_pending_site_created', [$this, 'check_pending_site_created']);

add_action('wu_async_publish_pending_site', [$this, 'async_publish_pending_site'], 10);

/*
* Reclaim orphaned pending_site when a WooCommerce order completes.
*/
add_action('woocommerce_order_status_completed', [$this, 'reclaim_pending_site_on_wc_order_completion']);
}

/**
Expand Down Expand Up @@ -404,6 +409,92 @@ public function handle_pending_site_on_cancellation($old_status, $new_status, $m
$membership->delete_pending_site();
}

/**
* Reclaim an orphaned pending_site when a WooCommerce order completes.
*
* When a membership is cancelled, `handle_pending_site_on_cancellation`
* stores the pending_site in a 24-hour transient keyed by the customer's
* billing email hash. If the customer then completes a new WooCommerce
* order, this method reclaims the pending_site, reactivates their
* cancelled membership, triggers async site provisioning, and links the
* WC order to the membership via `_wu_membership_id` post meta.
*
* Transient key format: `wu_transferable_pending_` . md5( $email )
*
* @since 2.3.2
*
* @param int $order_id The WooCommerce order ID.
* @return void
*/
public function reclaim_pending_site_on_wc_order_completion($order_id): void {

if ( ! function_exists('wc_get_order')) {
return;
}

$order = wc_get_order($order_id);

if ( ! $order) {
return;
}

$billing_email = $order->get_billing_email();

if ( ! $billing_email) {
return;
}

$transient_key = 'wu_transferable_pending_' . md5($billing_email);
$pending_site = get_transient($transient_key);

if ( ! $pending_site) {
return;
}

$wp_user = get_user_by('email', $billing_email);

if ( ! $wp_user) {
return;
}

$customer = wu_get_customer_by_user_id($wp_user->ID);

if ( ! $customer) {
return;
}

$memberships = wu_get_memberships(
[
'customer_id' => $customer->get_id(),
'status' => Membership_Status::CANCELLED,
'number' => 1,
'orderby' => 'date_created',
'order' => 'DESC',
]
);

if (empty($memberships)) {
return;
}

$membership = $memberships[0];

$result = $membership->reactivate();

if (is_wp_error($result)) {
wu_log_add(self::LOG_FILE_NAME, $result->get_error_message(), LogLevel::ERROR);
return;
}

$membership->update_pending_site($pending_site);

update_post_meta(absint($order_id), '_wu_membership_id', $membership->get_id());

delete_transient($transient_key);

$membership->publish_pending_site_async();
}

/**
* Transfer a membership from a user to another.
*
Expand Down
128 changes: 128 additions & 0 deletions tests/WP_Ultimo/Managers/Membership_Manager_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ public function tearDown(): void {
$this->product->delete();
}

// Clean up the WC order stub global between tests.
unset($GLOBALS['_wu_test_wc_order_email']);

parent::tearDown();
}

Expand Down Expand Up @@ -809,4 +812,129 @@ public function test_handle_pending_site_on_cancellation_ignores_non_cancelled_s

$this->assertNotFalse($refreshed->get_pending_site(), 'pending_site should remain intact for non-cancelled transitions');
}

// ========================================================================
// reclaim_pending_site_on_wc_order_completion()
// ========================================================================

/**
* Test init registers the woocommerce_order_status_completed hook.
*/
public function test_init_registers_wc_order_completion_hook(): void {

$manager = $this->get_manager_instance();

$this->assertIsInt(
has_action('woocommerce_order_status_completed', [$manager, 'reclaim_pending_site_on_wc_order_completion'])
);
}

/**
* Test that the method bails gracefully when wc_get_order() returns false
* (i.e. the order does not exist).
*
* The global _wu_test_wc_order_email is intentionally NOT set so the stub
* returns false, simulating a missing order.
*/
public function test_reclaim_bails_when_order_not_found(): void {

$manager = $this->get_manager_instance();

// _wu_test_wc_order_email is NOT set, so the stub returns false.
unset($GLOBALS['_wu_test_wc_order_email']);

$membership = $this->create_membership(['status' => Membership_Status::CANCELLED]);

$manager->reclaim_pending_site_on_wc_order_completion(999);

// Membership should remain unchanged.
$refreshed = wu_get_membership($membership->get_id());

$this->assertSame(
Membership_Status::CANCELLED,
$refreshed->get_status(),
'Membership should remain cancelled when order is not found.'
);
}

/**
* Test the full reclaim flow: cancelled membership with transient is
* reactivated, pending_site attached, transient deleted, and publish triggered.
*/
public function test_reclaim_pending_site_reactivates_membership_and_clears_transient(): void {

$manager = $this->get_manager_instance();

// Create a cancelled membership for our test customer.
$membership = $this->create_membership(['status' => Membership_Status::CANCELLED]);

// Create and store a pending_site in the transient, mirroring the cancellation flow.
$membership->create_pending_site(
[
'title' => 'Reclaim Test Site',
'path' => '/reclaimtest/',
]
);

$email = $this->customer->get_email_address();
$transient_key = 'wu_transferable_pending_' . md5($email);
$pending_site = $membership->get_pending_site();

set_transient($transient_key, $pending_site, DAY_IN_SECONDS);

// Remove pending_site from membership to simulate post-cancellation state.
$membership->delete_pending_site();

$this->assertFalse($membership->get_pending_site(), 'pending_site should be absent before reclaim');
$this->assertNotFalse(get_transient($transient_key), 'transient should exist before reclaim');

// Fake WC order ID — wc_get_order stub is expected to return an object with this email.
$fake_order_id = 42;
$GLOBALS['_wu_test_wc_order_email'] = $email;

$manager->reclaim_pending_site_on_wc_order_completion($fake_order_id);

// Transient must be deleted after successful reclaim.
$this->assertFalse(get_transient($transient_key), 'transient should be deleted after reclaim');

// Membership must be reactivated.
$refreshed = wu_get_membership($membership->get_id());

$this->assertSame(
Membership_Status::ACTIVE,
$refreshed->get_status(),
'membership status should be active after reclaim'
);

// pending_site should now be attached to the membership.
$this->assertNotFalse($refreshed->get_pending_site(), 'pending_site should be re-attached to the membership');
}

/**
* Test that reclaim does nothing when no transient exists for the billing email.
*/
public function test_reclaim_skips_when_no_transient(): void {

$manager = $this->get_manager_instance();

$membership = $this->create_membership(['status' => Membership_Status::CANCELLED]);

$email = $this->customer->get_email_address();
$transient_key = 'wu_transferable_pending_' . md5($email);
$GLOBALS['_wu_test_wc_order_email'] = $email;

// Ensure no transient is set.
delete_transient($transient_key);

$manager->reclaim_pending_site_on_wc_order_completion(42);

// Membership must stay cancelled — nothing was reclaimed.
$refreshed = wu_get_membership($membership->get_id());

$this->assertSame(
Membership_Status::CANCELLED,
$refreshed->get_status(),
'membership should remain cancelled when no transient exists'
);
}
}
37 changes: 37 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,40 @@ function _manually_load_plugin() {

// Load test traits (not autoloaded since they don't end in Test.php).
require_once __DIR__ . '/WP_Ultimo/Managers/Manager_Test_Trait.php';

/**
* Minimal wc_get_order() stub for unit tests.
*
* Tests that exercise reclaim_pending_site_on_wc_order_completion() set
* $GLOBALS['_wu_test_wc_order_email'] to control what billing email the
* stub returns. Tests that do NOT set the global receive `false`.
*
* This stub is only defined when WooCommerce is not loaded; if WC is
* present the real wc_get_order() takes precedence.
*/
if ( ! function_exists('wc_get_order')) {
/**
* Test stub for wc_get_order().
*
* @param int $order_id Unused in the stub.
* @return object|false
*/
function wc_get_order($order_id) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound
if ( ! isset($GLOBALS['_wu_test_wc_order_email'])) {
return false;
}

$email = $GLOBALS['_wu_test_wc_order_email'];

return new class($email) {
/** @var string */
private $billing_email;
public function __construct(string $email) {
$this->billing_email = $email;
}
public function get_billing_email(): string {
return $this->billing_email;
}
};
}
}
Loading