Skip to content

Commit 060e07f

Browse files
authored
fix: preserve pending_site in transient on membership cancellation (#910)
When a membership transitions to cancelled, the pending_site stored in membership meta is no longer silently orphaned. The new handle_pending_site_on_cancellation() handler in Membership_Manager: 1. Detects a pending_site on the cancelled membership 2. Saves it to a 24h transient keyed by customer email hash (wu_transferable_pending_ . md5($email)) for retry reclaim 3. Deletes the pending_site meta from the membership Fixes the production incident where a customer paid via WC order with a 100% coupon and received no site because the watchdog auto-cancel had already orphaned the pending_site (Membership 479). Resolves #902
1 parent 25414b6 commit 060e07f

2 files changed

Lines changed: 180 additions & 0 deletions

File tree

inc/managers/class-membership-manager.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ function () {
8181

8282
add_action('wu_transition_membership_status', [$this, 'transition_membership_status'], 10, 3);
8383

84+
add_action('wu_transition_membership_status', [$this, 'handle_pending_site_on_cancellation'], 10, 3);
85+
8486
/*
8587
* Deal with delayed/schedule swaps
8688
*/
@@ -352,6 +354,56 @@ public function mark_cancelled_date($old_value, $new_value, $item_id): void {
352354
}
353355
}
354356

357+
/**
358+
* Preserve pending_site data in a transient when a membership is cancelled.
359+
*
360+
* When a membership transitions to `cancelled`, any orphaned pending_site
361+
* stored in membership meta is moved to a 24-hour transient keyed by the
362+
* customer's email hash. This allows a retry checkout flow to reclaim the
363+
* pending site rather than losing it permanently.
364+
*
365+
* Transient key format: `wu_transferable_pending_` . md5( $email )
366+
*
367+
* @since 2.3.2
368+
*
369+
* @param string $old_status The previous membership status.
370+
* @param string $new_status The new membership status.
371+
* @param int $membership_id The ID of the membership.
372+
* @return void
373+
*/
374+
public function handle_pending_site_on_cancellation($old_status, $new_status, $membership_id): void {
375+
376+
if ('cancelled' !== $new_status) {
377+
return;
378+
}
379+
380+
$membership = wu_get_membership($membership_id);
381+
382+
if ( ! $membership) {
383+
return;
384+
}
385+
386+
$pending_site = $membership->get_pending_site();
387+
388+
if ( ! $pending_site) {
389+
return;
390+
}
391+
392+
$customer = $membership->get_customer();
393+
394+
if ( ! $customer) {
395+
$membership->delete_pending_site();
396+
return;
397+
}
398+
399+
$email = $customer->get_email_address();
400+
$transient_key = 'wu_transferable_pending_' . md5($email);
401+
402+
set_transient($transient_key, $pending_site, DAY_IN_SECONDS);
403+
404+
$membership->delete_pending_site();
405+
}
406+
355407
/**
356408
* Transfer a membership from a user to another.
357409
*

tests/WP_Ultimo/Managers/Membership_Manager_Test.php

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,4 +681,132 @@ public function test_log_file_name_constant(): void {
681681

682682
$this->assertEquals('memberships', Membership_Manager::LOG_FILE_NAME);
683683
}
684+
685+
// ========================================================================
686+
// handle_pending_site_on_cancellation()
687+
// ========================================================================
688+
689+
/**
690+
* Test init registers the handle_pending_site_on_cancellation hook.
691+
*/
692+
public function test_init_registers_handle_pending_site_on_cancellation_hook(): void {
693+
694+
$manager = $this->get_manager_instance();
695+
696+
$this->assertIsInt(
697+
has_action('wu_transition_membership_status', [$manager, 'handle_pending_site_on_cancellation'])
698+
);
699+
}
700+
701+
/**
702+
* Test pending_site is deleted from membership meta on cancellation.
703+
*/
704+
public function test_handle_pending_site_on_cancellation_cleans_up_pending_site(): void {
705+
706+
$membership = $this->create_membership(['status' => Membership_Status::ACTIVE]);
707+
$manager = $this->get_manager_instance();
708+
709+
$membership->create_pending_site(
710+
[
711+
'title' => 'Test Pending Site',
712+
'path' => '/testpending/',
713+
]
714+
);
715+
716+
$this->assertNotFalse($membership->get_pending_site(), 'pending_site should exist before cancellation');
717+
718+
$manager->handle_pending_site_on_cancellation(
719+
Membership_Status::ACTIVE,
720+
Membership_Status::CANCELLED,
721+
$membership->get_id()
722+
);
723+
724+
$refreshed = wu_get_membership($membership->get_id());
725+
726+
$this->assertFalse($refreshed->get_pending_site(), 'pending_site should be removed after cancellation');
727+
}
728+
729+
/**
730+
* Test transient is set with the correct key and 24-hour TTL on cancellation.
731+
*/
732+
public function test_handle_pending_site_on_cancellation_sets_transient_with_correct_key(): void {
733+
734+
$membership = $this->create_membership(['status' => Membership_Status::ACTIVE]);
735+
$manager = $this->get_manager_instance();
736+
737+
$membership->create_pending_site(
738+
[
739+
'title' => 'Transient Test Site',
740+
'path' => '/transientpending/',
741+
]
742+
);
743+
744+
$email = $this->customer->get_email_address();
745+
$transient_key = 'wu_transferable_pending_' . md5($email);
746+
747+
// Ensure transient does not exist before the call.
748+
delete_transient($transient_key);
749+
$this->assertFalse(get_transient($transient_key), 'transient should not exist before cancellation');
750+
751+
$manager->handle_pending_site_on_cancellation(
752+
Membership_Status::ACTIVE,
753+
Membership_Status::CANCELLED,
754+
$membership->get_id()
755+
);
756+
757+
$stored = get_transient($transient_key);
758+
759+
$this->assertNotFalse($stored, 'transient should be set after cancellation');
760+
}
761+
762+
/**
763+
* Test no transient is set and no error occurs when there is no pending_site.
764+
*/
765+
public function test_handle_pending_site_on_cancellation_skips_when_no_pending_site(): void {
766+
767+
$membership = $this->create_membership(['status' => Membership_Status::ACTIVE]);
768+
$manager = $this->get_manager_instance();
769+
770+
// Confirm no pending site exists.
771+
$this->assertFalse($membership->get_pending_site());
772+
773+
$email = $this->customer->get_email_address();
774+
$transient_key = 'wu_transferable_pending_' . md5($email);
775+
delete_transient($transient_key);
776+
777+
$manager->handle_pending_site_on_cancellation(
778+
Membership_Status::ACTIVE,
779+
Membership_Status::CANCELLED,
780+
$membership->get_id()
781+
);
782+
783+
$this->assertFalse(get_transient($transient_key), 'transient should NOT be set when there is no pending_site');
784+
}
785+
786+
/**
787+
* Test non-cancellation transitions do not affect pending_site.
788+
*/
789+
public function test_handle_pending_site_on_cancellation_ignores_non_cancelled_status(): void {
790+
791+
$membership = $this->create_membership(['status' => Membership_Status::ACTIVE]);
792+
$manager = $this->get_manager_instance();
793+
794+
$membership->create_pending_site(
795+
[
796+
'title' => 'Should Stay',
797+
'path' => '/shouldstay/',
798+
]
799+
);
800+
801+
// Trigger with a non-cancelled new status.
802+
$manager->handle_pending_site_on_cancellation(
803+
Membership_Status::ACTIVE,
804+
Membership_Status::ON_HOLD,
805+
$membership->get_id()
806+
);
807+
808+
$refreshed = wu_get_membership($membership->get_id());
809+
810+
$this->assertNotFalse($refreshed->get_pending_site(), 'pending_site should remain intact for non-cancelled transitions');
811+
}
684812
}

0 commit comments

Comments
 (0)