diff --git a/inc/managers/class-membership-manager.php b/inc/managers/class-membership-manager.php index d5a919d8..cb297294 100644 --- a/inc/managers/class-membership-manager.php +++ b/inc/managers/class-membership-manager.php @@ -81,6 +81,8 @@ function () { add_action('wu_transition_membership_status', [$this, 'transition_membership_status'], 10, 3); + add_action('wu_transition_membership_status', [$this, 'handle_pending_site_on_cancellation'], 10, 3); + /* * Deal with delayed/schedule swaps */ @@ -352,6 +354,56 @@ public function mark_cancelled_date($old_value, $new_value, $item_id): void { } } + /** + * Preserve pending_site data in a transient when a membership is cancelled. + * + * When a membership transitions to `cancelled`, any orphaned pending_site + * stored in membership meta is moved to a 24-hour transient keyed by the + * customer's email hash. This allows a retry checkout flow to reclaim the + * pending site rather than losing it permanently. + * + * Transient key format: `wu_transferable_pending_` . md5( $email ) + * + * @since 2.3.2 + * + * @param string $old_status The previous membership status. + * @param string $new_status The new membership status. + * @param int $membership_id The ID of the membership. + * @return void + */ + public function handle_pending_site_on_cancellation($old_status, $new_status, $membership_id): void { + + if ('cancelled' !== $new_status) { + return; + } + + $membership = wu_get_membership($membership_id); + + if ( ! $membership) { + return; + } + + $pending_site = $membership->get_pending_site(); + + if ( ! $pending_site) { + return; + } + + $customer = $membership->get_customer(); + + if ( ! $customer) { + $membership->delete_pending_site(); + return; + } + + $email = $customer->get_email_address(); + $transient_key = 'wu_transferable_pending_' . md5($email); + + set_transient($transient_key, $pending_site, DAY_IN_SECONDS); + + $membership->delete_pending_site(); + } + /** * 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 753f88da..d81dfb28 100644 --- a/tests/WP_Ultimo/Managers/Membership_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Membership_Manager_Test.php @@ -681,4 +681,132 @@ public function test_log_file_name_constant(): void { $this->assertEquals('memberships', Membership_Manager::LOG_FILE_NAME); } + + // ======================================================================== + // handle_pending_site_on_cancellation() + // ======================================================================== + + /** + * Test init registers the handle_pending_site_on_cancellation hook. + */ + public function test_init_registers_handle_pending_site_on_cancellation_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('wu_transition_membership_status', [$manager, 'handle_pending_site_on_cancellation']) + ); + } + + /** + * Test pending_site is deleted from membership meta on cancellation. + */ + public function test_handle_pending_site_on_cancellation_cleans_up_pending_site(): void { + + $membership = $this->create_membership(['status' => Membership_Status::ACTIVE]); + $manager = $this->get_manager_instance(); + + $membership->create_pending_site( + [ + 'title' => 'Test Pending Site', + 'path' => '/testpending/', + ] + ); + + $this->assertNotFalse($membership->get_pending_site(), 'pending_site should exist before cancellation'); + + $manager->handle_pending_site_on_cancellation( + Membership_Status::ACTIVE, + Membership_Status::CANCELLED, + $membership->get_id() + ); + + $refreshed = wu_get_membership($membership->get_id()); + + $this->assertFalse($refreshed->get_pending_site(), 'pending_site should be removed after cancellation'); + } + + /** + * Test transient is set with the correct key and 24-hour TTL on cancellation. + */ + public function test_handle_pending_site_on_cancellation_sets_transient_with_correct_key(): void { + + $membership = $this->create_membership(['status' => Membership_Status::ACTIVE]); + $manager = $this->get_manager_instance(); + + $membership->create_pending_site( + [ + 'title' => 'Transient Test Site', + 'path' => '/transientpending/', + ] + ); + + $email = $this->customer->get_email_address(); + $transient_key = 'wu_transferable_pending_' . md5($email); + + // Ensure transient does not exist before the call. + delete_transient($transient_key); + $this->assertFalse(get_transient($transient_key), 'transient should not exist before cancellation'); + + $manager->handle_pending_site_on_cancellation( + Membership_Status::ACTIVE, + Membership_Status::CANCELLED, + $membership->get_id() + ); + + $stored = get_transient($transient_key); + + $this->assertNotFalse($stored, 'transient should be set after cancellation'); + } + + /** + * Test no transient is set and no error occurs when there is no pending_site. + */ + public function test_handle_pending_site_on_cancellation_skips_when_no_pending_site(): void { + + $membership = $this->create_membership(['status' => Membership_Status::ACTIVE]); + $manager = $this->get_manager_instance(); + + // Confirm no pending site exists. + $this->assertFalse($membership->get_pending_site()); + + $email = $this->customer->get_email_address(); + $transient_key = 'wu_transferable_pending_' . md5($email); + delete_transient($transient_key); + + $manager->handle_pending_site_on_cancellation( + Membership_Status::ACTIVE, + Membership_Status::CANCELLED, + $membership->get_id() + ); + + $this->assertFalse(get_transient($transient_key), 'transient should NOT be set when there is no pending_site'); + } + + /** + * Test non-cancellation transitions do not affect pending_site. + */ + public function test_handle_pending_site_on_cancellation_ignores_non_cancelled_status(): void { + + $membership = $this->create_membership(['status' => Membership_Status::ACTIVE]); + $manager = $this->get_manager_instance(); + + $membership->create_pending_site( + [ + 'title' => 'Should Stay', + 'path' => '/shouldstay/', + ] + ); + + // Trigger with a non-cancelled new status. + $manager->handle_pending_site_on_cancellation( + Membership_Status::ACTIVE, + Membership_Status::ON_HOLD, + $membership->get_id() + ); + + $refreshed = wu_get_membership($membership->get_id()); + + $this->assertNotFalse($refreshed->get_pending_site(), 'pending_site should remain intact for non-cancelled transitions'); + } }