diff --git a/inc/managers/class-membership-manager.php b/inc/managers/class-membership-manager.php index cb297294..ae9ae527 100644 --- a/inc/managers/class-membership-manager.php +++ b/inc/managers/class-membership-manager.php @@ -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']); } /** @@ -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. * diff --git a/tests/WP_Ultimo/Managers/Membership_Manager_Test.php b/tests/WP_Ultimo/Managers/Membership_Manager_Test.php index d81dfb28..5aa65f8b 100644 --- a/tests/WP_Ultimo/Managers/Membership_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Membership_Manager_Test.php @@ -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(); } @@ -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' + ); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index b159800f..8190c6d9 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -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; + } + }; + } +}