diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index 958e09a5..51b42b24 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -41,6 +41,7 @@ jobs:
run: |
echo "Running PHPStan on changed files: ${{ steps.changed-files.outputs.all_changed_files }}"
vendor/bin/phpstan analyse --error-format=checkstyle --no-progress ${{ steps.changed-files.outputs.all_changed_files }} > phpstan-report.xml || true
+ [ -s phpstan-report.xml ] || echo '' > phpstan-report.xml
- name: Annotate PR with PHPCS results
uses: staabm/annotate-pull-request-from-checkstyle-action@v1
diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist
index e6556dc4..6e810053 100644
--- a/.phpcs.xml.dist
+++ b/.phpcs.xml.dist
@@ -14,6 +14,11 @@
/tests/
+
+
+ /tests/
+
+
diff --git a/assets/js/thank-you.js b/assets/js/thank-you.js
index 7cf6c989..9bc797e9 100644
--- a/assets/js/thank-you.js
+++ b/assets/js/thank-you.js
@@ -96,7 +96,8 @@ document.addEventListener("DOMContentLoaded", () => {
creating: wu_thank_you.creating,
next_queue: parseInt(wu_thank_you.next_queue, 10) + 5,
random: 0,
- progress_in_seconds: 0
+ progress_in_seconds: 0,
+ stopped_count: 0
};
},
computed: {
@@ -131,9 +132,21 @@ document.addEventListener("DOMContentLoaded", () => {
const response = await fetch(url).then((request) => request.json());
if (response.publish_status === "completed") {
window.location.reload();
- } else {
- this.creating = response.publish_status === "running";
+ } else if (response.publish_status === "running") {
+ this.creating = true;
+ this.stopped_count = 0;
setTimeout(this.check_site_created, 3e3);
+ } else {
+ // status === "stopped": async job not started yet or site already created.
+ // Reload after 3 consecutive stopped responses (9 seconds total) to
+ // avoid showing "Creating..." forever when the site is already ready.
+ this.creating = false;
+ this.stopped_count++;
+ if (this.stopped_count >= 3) {
+ window.location.reload();
+ } else {
+ setTimeout(this.check_site_created, 3e3);
+ }
}
}
}
diff --git a/assets/js/thank-you.min.js b/assets/js/thank-you.min.js
index a903406b..d12cfac9 100644
--- a/assets/js/thank-you.min.js
+++ b/assets/js/thank-you.min.js
@@ -1 +1 @@
-document.addEventListener("DOMContentLoaded",()=>{var e,t;document.querySelectorAll(".wu-resend-verification-email").forEach(s=>s.addEventListener("click",async e=>{e.preventDefault();var n,e={classes:[],has_icon:!1,original_value:(n=s).innerHTML,get_icon(){return this.has_icon?'':""},clear_classes(){n.classList.remove(...this.classes)},add_classes(e){this.classes=e,n.classList.add(...e)},text(e,t,s=!1){return this.clear_classes(),s&&(this.has_icon=!this.has_icon),n.animate([{opacity:"1"},{opacity:"0.75"}],{duration:300,iterations:1}),setTimeout(()=>{this.add_classes(t??[]),n.innerHTML=this.get_icon()+e,n.style.opacity="0.75"},300),this},done(e=5e3){return setTimeout(()=>{n.animate([{opacity:"0.75"},{opacity:"1"}],{duration:300,iterations:1}),setTimeout(()=>{this.clear_classes(),n.innerHTML=this.original_value,n.style.opacity="1"},300)},e),this}}.text(wu_thank_you.i18n.resending_verification_email,["wu-text-gray-400"]),t=await(await fetch(wu_thank_you.ajaxurl,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({action:"wu_resend_verification_email",_ajax_nonce:wu_thank_you.resend_verification_email_nonce})})).json();(t.success?e.text(wu_thank_you.i18n.email_sent,["wu-text-green-700"],!0):e.text(t.data[0].message,["wu-text-red-600"],!0)).done()})),document.getElementById("wu-sites")&&({Vue:e,defineComponent:t}=window.wu_vue,window.wu_sites=new e(t({el:"#wu-sites",data(){return{creating:wu_thank_you.creating,next_queue:parseInt(wu_thank_you.next_queue,10)+5,random:0,progress_in_seconds:0}},computed:{progress(){return Math.round(this.progress_in_seconds/this.next_queue*100)}},mounted(){if(wu_thank_you.has_pending_site)this.check_site_created();else if(!(this.next_queue<=0||wu_thank_you.creating)){let e=setInterval(()=>{this.progress_in_seconds++,this.progress_in_seconds>=this.next_queue&&(clearInterval(e),window.location.reload()),this.progress_in_seconds%5==0&&fetch("/wp-cron.php?doing_wp_cron")},1e3)}},methods:{async check_site_created(){var e=new URL(wu_thank_you.ajaxurl),e=(e.searchParams.set("action","wu_check_pending_site_created"),e.searchParams.set("membership_hash",wu_thank_you.membership_hash),await fetch(e).then(e=>e.json()));"completed"===e.publish_status?window.location.reload():(this.creating="running"===e.publish_status,setTimeout(this.check_site_created,3e3))}}})))});
\ No newline at end of file
+document.addEventListener("DOMContentLoaded",()=>{var e,t;document.querySelectorAll(".wu-resend-verification-email").forEach(s=>s.addEventListener("click",async e=>{e.preventDefault();var n,e={classes:[],has_icon:!1,original_value:(n=s).innerHTML,get_icon(){return this.has_icon?'':""},clear_classes(){n.classList.remove(...this.classes)},add_classes(e){this.classes=e,n.classList.add(...e)},text(e,t,s=!1){return this.clear_classes(),s&&(this.has_icon=!this.has_icon),n.animate([{opacity:"1"},{opacity:"0.75"}],{duration:300,iterations:1}),setTimeout(()=>{this.add_classes(t??[]),n.innerHTML=this.get_icon()+e,n.style.opacity="0.75"},300),this},done(e=5e3){return setTimeout(()=>{n.animate([{opacity:"0.75"},{opacity:"1"}],{duration:300,iterations:1}),setTimeout(()=>{this.clear_classes(),n.innerHTML=this.original_value,n.style.opacity="1"},300)},e),this}}.text(wu_thank_you.i18n.resending_verification_email,["wu-text-gray-400"]),t=await(await fetch(wu_thank_you.ajaxurl,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({action:"wu_resend_verification_email",_ajax_nonce:wu_thank_you.resend_verification_email_nonce})})).json();(t.success?e.text(wu_thank_you.i18n.email_sent,["wu-text-green-700"],!0):e.text(t.data[0].message,["wu-text-red-600"],!0)).done()})),document.getElementById("wu-sites")&&({Vue:e,defineComponent:t}=window.wu_vue,window.wu_sites=new e(t({el:"#wu-sites",data(){return{creating:wu_thank_you.creating,next_queue:parseInt(wu_thank_you.next_queue,10)+5,random:0,progress_in_seconds:0,stopped_count:0}},computed:{progress(){return Math.round(this.progress_in_seconds/this.next_queue*100)}},mounted(){if(wu_thank_you.has_pending_site)this.check_site_created();else if(!(this.next_queue<=0||wu_thank_you.creating)){let e=setInterval(()=>{this.progress_in_seconds++,this.progress_in_seconds>=this.next_queue&&(clearInterval(e),window.location.reload()),this.progress_in_seconds%5==0&&fetch("/wp-cron.php?doing_wp_cron")},1e3)}},methods:{async check_site_created(){var e=new URL(wu_thank_you.ajaxurl),e=(e.searchParams.set("action","wu_check_pending_site_created"),e.searchParams.set("membership_hash",wu_thank_you.membership_hash),await fetch(e).then(e=>e.json()));"completed"===e.publish_status?window.location.reload():"running"===e.publish_status?(this.creating=!0,this.stopped_count=0,setTimeout(this.check_site_created,3e3)):(this.creating=!1,this.stopped_count++,3<=this.stopped_count?window.location.reload():setTimeout(this.check_site_created,3e3))}}})))});
\ No newline at end of file
diff --git a/inc/managers/class-payment-manager.php b/inc/managers/class-payment-manager.php
index 34a6a60d..e956e4f7 100644
--- a/inc/managers/class-payment-manager.php
+++ b/inc/managers/class-payment-manager.php
@@ -154,6 +154,16 @@ public function check_pending_payments($user): void {
}
foreach ($customer->get_memberships() as $membership) {
+ /*
+ * Skip memberships that never completed checkout. A pending
+ * membership represents an abandoned checkout — showing a popup
+ * for it is misleading and may point to a WC order that no
+ * longer exists.
+ */
+ if (in_array($membership->get_status(), ['pending', 'cancelled'], true)) {
+ continue;
+ }
+
$pending_payment = $membership->get_last_pending_payment();
if ($pending_payment) {
@@ -236,6 +246,10 @@ public function render_pending_payments(): void {
$pending_payments = [];
foreach ($customer->get_memberships() as $membership) {
+ if (in_array($membership->get_status(), ['pending', 'cancelled'], true)) {
+ continue;
+ }
+
$pending_payment = $membership->get_last_pending_payment();
if ($pending_payment) {
diff --git a/inc/models/class-customer.php b/inc/models/class-customer.php
index 5d053eda..1e62b44f 100644
--- a/inc/models/class-customer.php
+++ b/inc/models/class-customer.php
@@ -398,10 +398,23 @@ public function has_trialed() {
$this->has_trialed = $this->get_meta(self::META_HAS_TRIALED);
if ( ! $this->has_trialed) {
+ /*
+ * Exclude pending memberships from this check.
+ *
+ * WP Ultimo sets date_trial_end at form submit, before payment is
+ * collected. Without this filter an abandoned checkout permanently
+ * blocks future trials because has_trialed() finds the pending
+ * membership and returns true immediately.
+ *
+ * We intentionally keep 'cancelled' in scope: a user who started a
+ * trial, then cancelled their active membership, genuinely consumed
+ * their trial and should not receive a second one.
+ */
$trial = wu_get_memberships(
[
'customer_id' => $this->get_id(),
'date_trial_end__not_in' => [null, '0000-00-00 00:00:00'],
+ 'status__not_in' => ['pending'],
'fields' => 'ids',
'number' => 1,
]
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index ff17651b..926daaf8 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -10,10 +10,12 @@ parameters:
- ./views
- ./inc
- ./ultimate-multisite.php
+ excludePaths:
+ - ./tests
ignoreErrors:
-
message: '#Variable \$.* might not be defined.#'
path: ./views/*
-
message: '#Path in require_once\(\) "\./?/wp-admin/includes/.*" is not a file or it does not exist\.#'
- path: ./inc/*
\ No newline at end of file
+ path: ./inc/*
diff --git a/tests/WP_Ultimo/Managers/Payment_Manager_Pending_Popup_Test.php b/tests/WP_Ultimo/Managers/Payment_Manager_Pending_Popup_Test.php
new file mode 100644
index 00000000..d29adc7c
--- /dev/null
+++ b/tests/WP_Ultimo/Managers/Payment_Manager_Pending_Popup_Test.php
@@ -0,0 +1,219 @@
+customer = wu_create_customer(
+ [
+ 'username' => $uid,
+ 'email' => $uid . '@example.com',
+ 'password' => 'password123',
+ ]
+ );
+
+ $this->wp_user = $this->customer->get_user();
+
+ $this->manager = Payment_Manager::get_instance();
+
+ delete_user_meta($this->wp_user->ID, 'wu_show_pending_payment_popup');
+ }
+
+ /**
+ * A pending membership (abandoned checkout) must NOT trigger the popup.
+ *
+ * Before the fix the loop did not skip pending memberships, so any
+ * abandoned checkout with a linked WU payment would silently set the meta
+ * on every subsequent login.
+ */
+ public function test_pending_membership_does_not_trigger_popup(): void {
+
+ $product = wu_create_product(
+ [
+ 'name' => 'Plan',
+ 'slug' => 'plan-popup-pending-' . uniqid(),
+ 'amount' => 50.00,
+ 'type' => 'plan',
+ 'active' => true,
+ 'pricing_type' => 'paid',
+ 'recurring' => true,
+ 'duration' => 1,
+ 'duration_unit' => 'month',
+ ]
+ );
+
+ $membership = wu_create_membership(
+ [
+ 'customer_id' => $this->customer->get_id(),
+ 'plan_id' => $product->get_id(),
+ 'status' => 'pending',
+ 'recurring' => true,
+ ]
+ );
+
+ wu_create_payment(
+ [
+ 'customer_id' => $this->customer->get_id(),
+ 'membership_id' => $membership->get_id(),
+ 'status' => 'pending',
+ 'total' => 50.00,
+ 'gateway' => 'woocommerce',
+ ]
+ );
+
+ $this->manager->check_pending_payments($this->wp_user);
+
+ $this->assertEmpty(
+ get_user_meta($this->wp_user->ID, 'wu_show_pending_payment_popup', true),
+ 'A pending membership must not trigger the pending payment popup.'
+ );
+
+ $membership->delete();
+ $product->delete();
+ }
+
+ /**
+ * A cancelled membership must NOT trigger the popup.
+ */
+ public function test_cancelled_membership_does_not_trigger_popup(): void {
+
+ $product = wu_create_product(
+ [
+ 'name' => 'Plan',
+ 'slug' => 'plan-popup-cancelled-' . uniqid(),
+ 'amount' => 50.00,
+ 'type' => 'plan',
+ 'active' => true,
+ 'pricing_type' => 'paid',
+ 'recurring' => true,
+ 'duration' => 1,
+ 'duration_unit' => 'month',
+ ]
+ );
+
+ $membership = wu_create_membership(
+ [
+ 'customer_id' => $this->customer->get_id(),
+ 'plan_id' => $product->get_id(),
+ 'status' => 'cancelled',
+ 'recurring' => true,
+ ]
+ );
+
+ wu_create_payment(
+ [
+ 'customer_id' => $this->customer->get_id(),
+ 'membership_id' => $membership->get_id(),
+ 'status' => 'pending',
+ 'total' => 50.00,
+ 'gateway' => 'woocommerce',
+ ]
+ );
+
+ $this->manager->check_pending_payments($this->wp_user);
+
+ $this->assertEmpty(
+ get_user_meta($this->wp_user->ID, 'wu_show_pending_payment_popup', true),
+ 'A cancelled membership must not trigger the pending payment popup.'
+ );
+
+ $membership->delete();
+ $product->delete();
+ }
+
+ /**
+ * An active membership with a genuine pending payment MUST trigger the popup.
+ * Validates that the skip only applies to pending/cancelled memberships and
+ * does not suppress legitimate payment reminders.
+ */
+ public function test_active_membership_with_pending_payment_triggers_popup(): void {
+
+ $product = wu_create_product(
+ [
+ 'name' => 'Plan',
+ 'slug' => 'plan-popup-active-' . uniqid(),
+ 'amount' => 50.00,
+ 'type' => 'plan',
+ 'active' => true,
+ 'pricing_type' => 'paid',
+ 'recurring' => true,
+ 'duration' => 1,
+ 'duration_unit' => 'month',
+ ]
+ );
+
+ $membership = wu_create_membership(
+ [
+ 'customer_id' => $this->customer->get_id(),
+ 'plan_id' => $product->get_id(),
+ 'status' => 'active',
+ 'recurring' => true,
+ ]
+ );
+
+ wu_create_payment(
+ [
+ 'customer_id' => $this->customer->get_id(),
+ 'membership_id' => $membership->get_id(),
+ 'status' => 'pending',
+ 'total' => 50.00,
+ 'gateway' => 'woocommerce',
+ ]
+ );
+
+ $this->manager->check_pending_payments($this->wp_user);
+
+ $this->assertNotEmpty(
+ get_user_meta($this->wp_user->ID, 'wu_show_pending_payment_popup', true),
+ 'An active membership with a pending payment must trigger the popup.'
+ );
+
+ $membership->delete();
+ $product->delete();
+ }
+
+ /**
+ * Delete test data and reset state after each test.
+ */
+ public function tearDown(): void {
+
+ global $wpdb;
+ $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}wu_memberships WHERE customer_id = %d", $this->customer->get_id()));
+ $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}wu_payments WHERE customer_id = %d", $this->customer->get_id()));
+ delete_user_meta($this->wp_user->ID, 'wu_show_pending_payment_popup');
+ $this->customer->delete();
+
+ parent::tearDown();
+ }
+}
diff --git a/tests/WP_Ultimo/Models/Customer_Has_Trialed_Test.php b/tests/WP_Ultimo/Models/Customer_Has_Trialed_Test.php
new file mode 100644
index 00000000..cd0e52d7
--- /dev/null
+++ b/tests/WP_Ultimo/Models/Customer_Has_Trialed_Test.php
@@ -0,0 +1,253 @@
+ 'has_trialed_test_user',
+ 'email' => 'has_trialed_test@example.com',
+ 'password' => 'password123',
+ ]
+ );
+
+ self::$product = wu_create_product(
+ [
+ 'name' => 'Trialed Product',
+ 'slug' => 'trialed-product',
+ 'amount' => 10.00,
+ 'recurring' => true,
+ 'duration' => 1,
+ 'duration_unit' => 'month',
+ 'trial_duration' => 14,
+ 'trial_duration_unit' => 'day',
+ 'type' => 'plan',
+ 'pricing_type' => 'paid',
+ 'active' => true,
+ ]
+ );
+ }
+
+ /**
+ * Clear cached trial meta before each test so results don't bleed between cases.
+ */
+ public function setUp(): void {
+
+ parent::setUp();
+
+ self::$customer->delete_meta(Customer::META_HAS_TRIALED);
+ }
+
+ /**
+ * THE BUG (pre-fix): has_trialed() matched a pending membership (created at
+ * form submit before payment) and permanently blocked future trials.
+ *
+ * Reproduces the exact production sequence:
+ * 1. User submits checkout → WP Ultimo creates membership in 'pending' with
+ * date_trial_end already set.
+ * 2. User abandons (closes tab / navigates away) without paying.
+ * 3. User returns and tries to check out again.
+ * 4. has_trialed() must return false so they still get their trial.
+ */
+ public function test_pending_membership_with_trial_does_not_block_future_trial(): void {
+
+ // Membership created in pending status without a trial date first (avoids
+ // the save() auto-transition to trialing that fires when date_trial_end is
+ // in the future AND no pending payment exists yet).
+ $membership = wu_create_membership(
+ [
+ 'customer_id' => self::$customer->get_id(),
+ 'plan_id' => self::$product->get_id(),
+ 'status' => 'pending',
+ 'recurring' => true,
+ ]
+ );
+
+ // WP Ultimo sets date_trial_end at form submit (before payment is collected).
+ // Replicate that by injecting the value directly into the DB row.
+ global $wpdb;
+ $wpdb->update(
+ $wpdb->prefix . 'wu_memberships',
+ ['date_trial_end' => gmdate('Y-m-d H:i:s', strtotime('+14 days'))],
+ ['id' => $membership->get_id()]
+ );
+
+ // Load a fresh customer instance so there is no internal cache from above.
+ $fresh = wu_get_customer(self::$customer->get_id());
+
+ $this->assertFalse(
+ (bool) $fresh->has_trialed(),
+ 'A pending membership from an abandoned checkout must NOT count as a used trial.'
+ );
+
+ $membership->delete();
+ }
+
+ /**
+ * An active membership with a trial end date must still count as trialed.
+ * Validates that the fix does not break the normal happy-path scenario.
+ */
+ public function test_active_membership_with_trial_counts_as_trialed(): void {
+
+ $membership = wu_create_membership(
+ [
+ 'customer_id' => self::$customer->get_id(),
+ 'plan_id' => self::$product->get_id(),
+ 'status' => 'active',
+ 'recurring' => true,
+ ]
+ );
+
+ global $wpdb;
+ $wpdb->update(
+ $wpdb->prefix . 'wu_memberships',
+ ['date_trial_end' => gmdate('Y-m-d H:i:s', strtotime('+14 days'))],
+ ['id' => $membership->get_id()]
+ );
+
+ $fresh = wu_get_customer(self::$customer->get_id());
+
+ $this->assertTrue(
+ (bool) $fresh->has_trialed(),
+ 'An active membership with date_trial_end set must count as a used trial.'
+ );
+
+ self::$customer->delete_meta(Customer::META_HAS_TRIALED);
+ $membership->delete();
+ }
+
+ /**
+ * A trialing membership must count as trialed.
+ */
+ public function test_trialing_membership_counts_as_trialed(): void {
+
+ $membership = wu_create_membership(
+ [
+ 'customer_id' => self::$customer->get_id(),
+ 'plan_id' => self::$product->get_id(),
+ 'status' => 'trialing',
+ 'recurring' => true,
+ ]
+ );
+
+ global $wpdb;
+ $wpdb->update(
+ $wpdb->prefix . 'wu_memberships',
+ ['date_trial_end' => gmdate('Y-m-d H:i:s', strtotime('+14 days'))],
+ ['id' => $membership->get_id()]
+ );
+
+ $fresh = wu_get_customer(self::$customer->get_id());
+
+ $this->assertTrue(
+ (bool) $fresh->has_trialed(),
+ 'A trialing membership must count as a used trial.'
+ );
+
+ self::$customer->delete_meta(Customer::META_HAS_TRIALED);
+ $membership->delete();
+ }
+
+ /**
+ * A cancelled membership that went through a genuine trial (date_trial_end
+ * still set in the DB) must continue to block a second trial.
+ *
+ * This validates the fix is scoped to 'pending' only — not 'cancelled' —
+ * so users who cancel after actually using a trial cannot get another free one.
+ */
+ public function test_cancelled_membership_after_genuine_trial_still_blocks_second_trial(): void {
+
+ $membership = wu_create_membership(
+ [
+ 'customer_id' => self::$customer->get_id(),
+ 'plan_id' => self::$product->get_id(),
+ 'status' => 'cancelled',
+ 'recurring' => true,
+ ]
+ );
+
+ // Trial was consumed — date_trial_end is in the past but still set.
+ global $wpdb;
+ $wpdb->update(
+ $wpdb->prefix . 'wu_memberships',
+ ['date_trial_end' => gmdate('Y-m-d H:i:s', strtotime('-1 day'))],
+ ['id' => $membership->get_id()]
+ );
+
+ $fresh = wu_get_customer(self::$customer->get_id());
+
+ $this->assertTrue(
+ (bool) $fresh->has_trialed(),
+ 'A cancelled membership with date_trial_end still set must block a second trial.'
+ );
+
+ self::$customer->delete_meta(Customer::META_HAS_TRIALED);
+ $membership->delete();
+ }
+
+ /**
+ * After the hourly cleanup processes an orphaned checkout it cancels the
+ * membership AND clears date_trial_end to NULL. In that state the customer
+ * must be free to get a trial on their next checkout attempt.
+ */
+ public function test_cleaned_orphan_does_not_block_future_trial(): void {
+
+ // Cancelled + date_trial_end NULL (default) — this is the state left by
+ // the cleanup fix in class-woocommerce-gateway.php.
+ $membership = wu_create_membership(
+ [
+ 'customer_id' => self::$customer->get_id(),
+ 'plan_id' => self::$product->get_id(),
+ 'status' => 'cancelled',
+ 'recurring' => true,
+ ]
+ );
+
+ $fresh = wu_get_customer(self::$customer->get_id());
+
+ $this->assertFalse(
+ (bool) $fresh->has_trialed(),
+ 'A cleaned-up orphaned membership (cancelled + date_trial_end cleared) must NOT block future trials.'
+ );
+
+ $membership->delete();
+ }
+
+ /**
+ * Delete shared fixtures after all tests in this class have run.
+ */
+ public static function tear_down_after_class(): void {
+
+ self::$customer->delete();
+ self::$product->delete();
+
+ parent::tear_down_after_class();
+ }
+}