Skip to content

Commit 553236f

Browse files
authored
Merge pull request #360 from Ultimate-Multisite/fix/free-trial-lost-on-abandoned-checkout
Fix: free trial lost on abandoned WooCommerce checkout
2 parents 8d1e00f + d5bc1b0 commit 553236f

9 files changed

Lines changed: 525 additions & 5 deletions

File tree

.github/workflows/code-quality.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ jobs:
4141
run: |
4242
echo "Running PHPStan on changed files: ${{ steps.changed-files.outputs.all_changed_files }}"
4343
vendor/bin/phpstan analyse --error-format=checkstyle --no-progress ${{ steps.changed-files.outputs.all_changed_files }} > phpstan-report.xml || true
44+
[ -s phpstan-report.xml ] || echo '<?xml version="1.0" encoding="UTF-8"?><checkstyle></checkstyle>' > phpstan-report.xml
4445
4546
- name: Annotate PR with PHPCS results
4647
uses: staabm/annotate-pull-request-from-checkstyle-action@v1

.phpcs.xml.dist

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
<exclude-pattern>/tests/</exclude-pattern>
1515
</rule>
1616

17+
<!-- Test files legitimately need direct DB access for data setup/teardown — no caching needed. -->
18+
<rule ref="WordPress.DB.DirectDatabaseQuery">
19+
<exclude-pattern>/tests/</exclude-pattern>
20+
</rule>
21+
1722
<!-- How to scan -->
1823
<!-- Usage instructions: https://github.com/squizlabs/PHP_CodeSniffer/wiki/Usage -->
1924
<!-- Annotated ruleset: https://github.com/squizlabs/PHP_CodeSniffer/wiki/Annotated-ruleset.xml -->

assets/js/thank-you.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ document.addEventListener("DOMContentLoaded", () => {
9696
creating: wu_thank_you.creating,
9797
next_queue: parseInt(wu_thank_you.next_queue, 10) + 5,
9898
random: 0,
99-
progress_in_seconds: 0
99+
progress_in_seconds: 0,
100+
stopped_count: 0
100101
};
101102
},
102103
computed: {
@@ -131,9 +132,21 @@ document.addEventListener("DOMContentLoaded", () => {
131132
const response = await fetch(url).then((request) => request.json());
132133
if (response.publish_status === "completed") {
133134
window.location.reload();
134-
} else {
135-
this.creating = response.publish_status === "running";
135+
} else if (response.publish_status === "running") {
136+
this.creating = true;
137+
this.stopped_count = 0;
136138
setTimeout(this.check_site_created, 3e3);
139+
} else {
140+
// status === "stopped": async job not started yet or site already created.
141+
// Reload after 3 consecutive stopped responses (9 seconds total) to
142+
// avoid showing "Creating..." forever when the site is already ready.
143+
this.creating = false;
144+
this.stopped_count++;
145+
if (this.stopped_count >= 3) {
146+
window.location.reload();
147+
} else {
148+
setTimeout(this.check_site_created, 3e3);
149+
}
137150
}
138151
}
139152
}

assets/js/thank-you.min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

inc/managers/class-payment-manager.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,16 @@ public function check_pending_payments($user): void {
154154
}
155155

156156
foreach ($customer->get_memberships() as $membership) {
157+
/*
158+
* Skip memberships that never completed checkout. A pending
159+
* membership represents an abandoned checkout — showing a popup
160+
* for it is misleading and may point to a WC order that no
161+
* longer exists.
162+
*/
163+
if (in_array($membership->get_status(), ['pending', 'cancelled'], true)) {
164+
continue;
165+
}
166+
157167
$pending_payment = $membership->get_last_pending_payment();
158168

159169
if ($pending_payment) {
@@ -236,6 +246,10 @@ public function render_pending_payments(): void {
236246
$pending_payments = [];
237247

238248
foreach ($customer->get_memberships() as $membership) {
249+
if (in_array($membership->get_status(), ['pending', 'cancelled'], true)) {
250+
continue;
251+
}
252+
239253
$pending_payment = $membership->get_last_pending_payment();
240254

241255
if ($pending_payment) {

inc/models/class-customer.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,10 +398,23 @@ public function has_trialed() {
398398
$this->has_trialed = $this->get_meta(self::META_HAS_TRIALED);
399399

400400
if ( ! $this->has_trialed) {
401+
/*
402+
* Exclude pending memberships from this check.
403+
*
404+
* WP Ultimo sets date_trial_end at form submit, before payment is
405+
* collected. Without this filter an abandoned checkout permanently
406+
* blocks future trials because has_trialed() finds the pending
407+
* membership and returns true immediately.
408+
*
409+
* We intentionally keep 'cancelled' in scope: a user who started a
410+
* trial, then cancelled their active membership, genuinely consumed
411+
* their trial and should not receive a second one.
412+
*/
401413
$trial = wu_get_memberships(
402414
[
403415
'customer_id' => $this->get_id(),
404416
'date_trial_end__not_in' => [null, '0000-00-00 00:00:00'],
417+
'status__not_in' => ['pending'],
405418
'fields' => 'ids',
406419
'number' => 1,
407420
]

phpstan.neon.dist

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ parameters:
1010
- ./views
1111
- ./inc
1212
- ./ultimate-multisite.php
13+
excludePaths:
14+
- ./tests
1315
ignoreErrors:
1416
-
1517
message: '#Variable \$.* might not be defined.#'
1618
path: ./views/*
1719
-
1820
message: '#Path in require_once\(\) "\./?/wp-admin/includes/.*" is not a file or it does not exist\.#'
19-
path: ./inc/*
21+
path: ./inc/*
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
/**
3+
* Regression tests for Payment_Manager pending-payment popup behaviour.
4+
*
5+
* @package WP_Ultimo
6+
*/
7+
8+
namespace WP_Ultimo\Managers;
9+
10+
use WP_UnitTestCase;
11+
use WP_Ultimo\Models\Customer;
12+
13+
/**
14+
* Regression tests for Payment_Manager::check_pending_payments() and
15+
* render_pending_payments().
16+
*
17+
* Ensures that pending and cancelled memberships (from abandoned checkouts)
18+
* do not trigger the "pending payment" popup on user login, which previously
19+
* pointed users at WC orders that may no longer exist.
20+
*
21+
* @see https://github.com/Ultimate-Multisite/ultimate-multisite/pull/360
22+
*/
23+
class Payment_Manager_Pending_Popup_Test extends WP_UnitTestCase {
24+
25+
private Payment_Manager $manager;
26+
private Customer $customer;
27+
private \WP_User $wp_user;
28+
29+
/**
30+
* Set up a fresh customer and payment manager instance before each test.
31+
*/
32+
public function setUp(): void {
33+
34+
parent::setUp();
35+
36+
$uid = uniqid('popup_');
37+
38+
$this->customer = wu_create_customer(
39+
[
40+
'username' => $uid,
41+
'email' => $uid . '@example.com',
42+
'password' => 'password123',
43+
]
44+
);
45+
46+
$this->wp_user = $this->customer->get_user();
47+
48+
$this->manager = Payment_Manager::get_instance();
49+
50+
delete_user_meta($this->wp_user->ID, 'wu_show_pending_payment_popup');
51+
}
52+
53+
/**
54+
* A pending membership (abandoned checkout) must NOT trigger the popup.
55+
*
56+
* Before the fix the loop did not skip pending memberships, so any
57+
* abandoned checkout with a linked WU payment would silently set the meta
58+
* on every subsequent login.
59+
*/
60+
public function test_pending_membership_does_not_trigger_popup(): void {
61+
62+
$product = wu_create_product(
63+
[
64+
'name' => 'Plan',
65+
'slug' => 'plan-popup-pending-' . uniqid(),
66+
'amount' => 50.00,
67+
'type' => 'plan',
68+
'active' => true,
69+
'pricing_type' => 'paid',
70+
'recurring' => true,
71+
'duration' => 1,
72+
'duration_unit' => 'month',
73+
]
74+
);
75+
76+
$membership = wu_create_membership(
77+
[
78+
'customer_id' => $this->customer->get_id(),
79+
'plan_id' => $product->get_id(),
80+
'status' => 'pending',
81+
'recurring' => true,
82+
]
83+
);
84+
85+
wu_create_payment(
86+
[
87+
'customer_id' => $this->customer->get_id(),
88+
'membership_id' => $membership->get_id(),
89+
'status' => 'pending',
90+
'total' => 50.00,
91+
'gateway' => 'woocommerce',
92+
]
93+
);
94+
95+
$this->manager->check_pending_payments($this->wp_user);
96+
97+
$this->assertEmpty(
98+
get_user_meta($this->wp_user->ID, 'wu_show_pending_payment_popup', true),
99+
'A pending membership must not trigger the pending payment popup.'
100+
);
101+
102+
$membership->delete();
103+
$product->delete();
104+
}
105+
106+
/**
107+
* A cancelled membership must NOT trigger the popup.
108+
*/
109+
public function test_cancelled_membership_does_not_trigger_popup(): void {
110+
111+
$product = wu_create_product(
112+
[
113+
'name' => 'Plan',
114+
'slug' => 'plan-popup-cancelled-' . uniqid(),
115+
'amount' => 50.00,
116+
'type' => 'plan',
117+
'active' => true,
118+
'pricing_type' => 'paid',
119+
'recurring' => true,
120+
'duration' => 1,
121+
'duration_unit' => 'month',
122+
]
123+
);
124+
125+
$membership = wu_create_membership(
126+
[
127+
'customer_id' => $this->customer->get_id(),
128+
'plan_id' => $product->get_id(),
129+
'status' => 'cancelled',
130+
'recurring' => true,
131+
]
132+
);
133+
134+
wu_create_payment(
135+
[
136+
'customer_id' => $this->customer->get_id(),
137+
'membership_id' => $membership->get_id(),
138+
'status' => 'pending',
139+
'total' => 50.00,
140+
'gateway' => 'woocommerce',
141+
]
142+
);
143+
144+
$this->manager->check_pending_payments($this->wp_user);
145+
146+
$this->assertEmpty(
147+
get_user_meta($this->wp_user->ID, 'wu_show_pending_payment_popup', true),
148+
'A cancelled membership must not trigger the pending payment popup.'
149+
);
150+
151+
$membership->delete();
152+
$product->delete();
153+
}
154+
155+
/**
156+
* An active membership with a genuine pending payment MUST trigger the popup.
157+
* Validates that the skip only applies to pending/cancelled memberships and
158+
* does not suppress legitimate payment reminders.
159+
*/
160+
public function test_active_membership_with_pending_payment_triggers_popup(): void {
161+
162+
$product = wu_create_product(
163+
[
164+
'name' => 'Plan',
165+
'slug' => 'plan-popup-active-' . uniqid(),
166+
'amount' => 50.00,
167+
'type' => 'plan',
168+
'active' => true,
169+
'pricing_type' => 'paid',
170+
'recurring' => true,
171+
'duration' => 1,
172+
'duration_unit' => 'month',
173+
]
174+
);
175+
176+
$membership = wu_create_membership(
177+
[
178+
'customer_id' => $this->customer->get_id(),
179+
'plan_id' => $product->get_id(),
180+
'status' => 'active',
181+
'recurring' => true,
182+
]
183+
);
184+
185+
wu_create_payment(
186+
[
187+
'customer_id' => $this->customer->get_id(),
188+
'membership_id' => $membership->get_id(),
189+
'status' => 'pending',
190+
'total' => 50.00,
191+
'gateway' => 'woocommerce',
192+
]
193+
);
194+
195+
$this->manager->check_pending_payments($this->wp_user);
196+
197+
$this->assertNotEmpty(
198+
get_user_meta($this->wp_user->ID, 'wu_show_pending_payment_popup', true),
199+
'An active membership with a pending payment must trigger the popup.'
200+
);
201+
202+
$membership->delete();
203+
$product->delete();
204+
}
205+
206+
/**
207+
* Delete test data and reset state after each test.
208+
*/
209+
public function tearDown(): void {
210+
211+
global $wpdb;
212+
$wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}wu_memberships WHERE customer_id = %d", $this->customer->get_id()));
213+
$wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}wu_payments WHERE customer_id = %d", $this->customer->get_id()));
214+
delete_user_meta($this->wp_user->ID, 'wu_show_pending_payment_popup');
215+
$this->customer->delete();
216+
217+
parent::tearDown();
218+
}
219+
}

0 commit comments

Comments
 (0)