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(); + } +}