Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<?xml version="1.0" encoding="UTF-8"?><checkstyle></checkstyle>' > phpstan-report.xml

- name: Annotate PR with PHPCS results
uses: staabm/annotate-pull-request-from-checkstyle-action@v1
Expand Down
5 changes: 5 additions & 0 deletions .phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
<exclude-pattern>/tests/</exclude-pattern>
</rule>

<!-- Test files legitimately need direct DB access for data setup/teardown — no caching needed. -->
<rule ref="WordPress.DB.DirectDatabaseQuery">
<exclude-pattern>/tests/</exclude-pattern>
</rule>

<!-- How to scan -->
<!-- Usage instructions: https://github.com/squizlabs/PHP_CodeSniffer/wiki/Usage -->
<!-- Annotated ruleset: https://github.com/squizlabs/PHP_CodeSniffer/wiki/Annotated-ruleset.xml -->
Expand Down
19 changes: 16 additions & 3 deletions assets/js/thank-you.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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);
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion assets/js/thank-you.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions inc/managers/class-payment-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
13 changes: 13 additions & 0 deletions inc/models/class-customer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
Expand Down
4 changes: 3 additions & 1 deletion phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
path: ./inc/*
219 changes: 219 additions & 0 deletions tests/WP_Ultimo/Managers/Payment_Manager_Pending_Popup_Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
<?php
/**
* Regression tests for Payment_Manager pending-payment popup behaviour.
*
* @package WP_Ultimo
*/

namespace WP_Ultimo\Managers;

use WP_UnitTestCase;
use WP_Ultimo\Models\Customer;

/**
* Regression tests for Payment_Manager::check_pending_payments() and
* render_pending_payments().
*
* Ensures that pending and cancelled memberships (from abandoned checkouts)
* do not trigger the "pending payment" popup on user login, which previously
* pointed users at WC orders that may no longer exist.
*
* @see https://github.com/Ultimate-Multisite/ultimate-multisite/pull/360
Comment on lines +14 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

render_pending_payments() is still untested.

All three methods only exercise check_pending_payments(), but the production fix also added the same status guard in inc/managers/class-payment-manager.php:232-309. Without a render assertion, that second half of the fix can regress independently.

Also applies to: 60-204

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/Managers/Payment_Manager_Pending_Popup_Test.php` around lines
14 - 21, Add a unit test in
tests/WP_Ultimo/Managers/Payment_Manager_Pending_Popup_Test.php that exercises
render_pending_payments() (not just check_pending_payments()) using the same
fixtures for pending and cancelled memberships; call
Payment_Manager::render_pending_payments() and assert that the popup markup is
NOT rendered (or that the specific pending-payment indicator is absent) for
those statuses so the status guard added in
class-payment-manager.php::render_pending_payments is covered and cannot regress
independently of check_pending_payments().

*/
class Payment_Manager_Pending_Popup_Test extends WP_UnitTestCase {

private Payment_Manager $manager;
private Customer $customer;
private \WP_User $wp_user;

/**
* Set up a fresh customer and payment manager instance before each test.
*/
public function setUp(): void {

parent::setUp();

$uid = uniqid('popup_');

$this->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',
]
);
Comment on lines +85 to +93
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Assert the pending payment fixture was created.

The pending/cancelled cases pass trivially if wu_create_payment() fails, because check_pending_payments() then sees no pending payment at all. Capture the return value and assert the fixture exists before checking the popup flag.

Suggested guard
-		wu_create_payment(
+		$payment = wu_create_payment(
 			[
 				'customer_id'   => $this->customer->get_id(),
 				'membership_id' => $membership->get_id(),
 				'status'        => 'pending',
 				'total'         => 50.00,
 				'gateway'       => 'woocommerce',
 			]
 		);
+		$this->assertNotEmpty($payment, 'Failed to create the pending payment fixture.');

Also applies to: 134-142, 185-193

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/Managers/Payment_Manager_Pending_Popup_Test.php` around lines
85 - 93, Capture and assert the result of wu_create_payment() before proceeding:
assign its return to a variable (e.g., $payment = wu_create_payment(...)) and
add an assertion that the fixture was created (e.g.,
$this->assertNotNull($payment) or assertInstanceOf(WU_Payment::class, $payment))
prior to calling check_pending_payments() and asserting the popup flag; repeat
the same guard for the other similar fixtures referenced in the file (the blocks
around the other wu_create_payment() calls).


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