Skip to content

Commit ce0f729

Browse files
authored
Merge branch 'main' into fix/domain-mapping-option-home-and-rewrite-flush
2 parents b54de87 + cb70b76 commit ce0f729

31 files changed

Lines changed: 703 additions & 120 deletions

.github/workflows/release.yml

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
- name: Set up Node.js
4343
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
4444
with:
45-
node-version: '18'
45+
node-version: '20'
4646
cache: 'npm'
4747

4848
- name: Install PHP dependencies
@@ -89,6 +89,13 @@ jobs:
8989
mkdir -p build
9090
mv ultimate-multisite.zip build/ultimate-multisite-${{ env.VERSION }}.zip
9191
92+
- name: Upload build artifact
93+
uses: actions/upload-artifact@v4
94+
with:
95+
name: ultimate-multisite-zip
96+
path: build/ultimate-multisite-${{ env.VERSION }}.zip
97+
retention-days: 7
98+
9299
- name: Create Release
93100
id: create_release
94101
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
@@ -130,30 +137,20 @@ jobs:
130137
with:
131138
fetch-depth: 0
132139

133-
- name: Setup PHP
134-
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2
135-
with:
136-
php-version: '7.4'
137-
extensions: mbstring, intl, curl
138-
tools: composer, wp-cli
140+
- name: Get the version
141+
run: |
142+
VERSION="${GITHUB_REF#refs/tags/v}"
143+
echo "VERSION=$VERSION" >> $GITHUB_ENV
139144
140-
- name: Set up Node.js
141-
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
145+
- name: Download build artifact
146+
uses: actions/download-artifact@v4
142147
with:
143-
node-version: '18'
144-
cache: 'npm'
145-
146-
- name: Install PHP dependencies
147-
run: composer install --no-dev --optimize-autoloader
148-
149-
- name: Install Node dependencies
150-
run: npm ci
148+
name: ultimate-multisite-zip
149+
path: build/
151150

152-
- name: Build plugin
153-
run: npm run build
154-
env:
155-
MU_CLIENT_ID: ${{ secrets.MU_CLIENT_ID }}
156-
MU_CLIENT_SECRET: ${{ secrets.MU_CLIENT_SECRET }}
151+
- name: Unzip build artifact into workspace
152+
run: |
153+
unzip -o build/ultimate-multisite-${{ env.VERSION }}.zip -d .
157154
158155
- name: Deploy to WordPress.org SVN
159156
uses: 10up/action-wordpress-plugin-deploy@stable

.task-counter

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
508
1+
509

AGENTS.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,3 +157,21 @@ Set up with: `npm run setup:hooks` or `bash bin/setup-hooks.sh`.
157157
## JSON / YAML
158158

159159
Indent with 2 spaces (not tabs). See `.editorconfig`.
160+
161+
## Local Development Environment
162+
163+
The shared WordPress dev install for testing this plugin is at `../wordpress` (relative to this repo root).
164+
165+
- **URL**: http://wordpress.local:8080
166+
- **Admin**: http://wordpress.local:8080/wp-admin`admin` / `admin`
167+
- **WordPress version**: 7.0-RC2
168+
- **This plugin**: symlinked into `../wordpress/wp-content/plugins/$(basename $PWD)`
169+
- **Reset to clean state**: `cd ../wordpress && ./reset.sh`
170+
171+
WP-CLI is configured via `wp-cli.yml` in this repo root — run `wp` commands directly from here without specifying `--path`.
172+
173+
```bash
174+
wp plugin activate $(basename $PWD) # activate this plugin
175+
wp plugin deactivate $(basename $PWD) # deactivate
176+
wp db reset --yes && cd ../wordpress && ./reset.sh # full reset
177+
```

TODO.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,5 @@ Overall coverage: **35%** (20,720 / 59,212 statements). 90 files at 0% coverage.
114114
- [x] t521 chore: remove stale @todo comments for already-implemented methods #enhancement #auto-dispatch ~1h ref:GH#691 pr:#693 completed:2026-03-29
115115
- [x] t522 test(integrations): write unit tests for integration provider classes (bunnynet, cloudways, enhance, laravel-forge, plesk, rocket, serverpilot, wpengine, wpmudev) #testing #auto-dispatch ~4h ref:GH#697 pr:#698 completed:2026-03-29
116116
- [x] t523 feat(paypal): PayPal PPCP integration review compliance — disconnect disclaimer, onboarding failure UI, merchant status validation, payee field, debug ID logging @superdav42 #paypal #compliance ~8h ref:GH#725 pr:#726 completed:2026-04-01
117-
- [x] t524 feat(checkout): add simple checkout form template with auto-generated credentials (re-implement PR #740 which was closed due to merge conflicts) #enhancement #auto-dispatch ~4h ref:GH#746 pr:#737 completed:2026-04-04
117+
- [x] t524 feat(checkout): add simple checkout form template with auto-generated credentials (re-implement PR #740 which was closed due to merge conflicts) #enhancement #auto-dispatch ~4h ref:GH#746 pr:#737 completed:2026-04-04
118+
- [x] t525 fix(dashboard): enqueue wu-styling on network admin dashboard for activity-stream widget #bug #auto-dispatch ~1h ref:GH#767 pr:#768 completed:2026-04-08

assets/js/checkout.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -585,10 +585,45 @@
585585
*/
586586
validate_client_side(values) {
587587

588-
const rules = (typeof wu_checkout !== 'undefined' && wu_checkout.validation_rules) ? wu_checkout.validation_rules : {};
588+
const allRules = (typeof wu_checkout !== 'undefined' && wu_checkout.validation_rules) ? wu_checkout.validation_rules : {};
589589
const i18n = (typeof wu_checkout !== 'undefined' && wu_checkout.i18n) ? wu_checkout.i18n : {};
590590
const errors = [];
591591

592+
/*
593+
* Restrict validation to the fields on the current step only.
594+
*
595+
* The PHP side exposes wu_checkout.step_fields as a map of
596+
* step_id => [field_ids]. We read the current step from the
597+
* hidden checkout_step input and filter the rules accordingly.
598+
* This prevents required fields on later steps (e.g. email,
599+
* username, password on step 4) from blocking submission of
600+
* earlier steps that do not include those fields.
601+
*
602+
* Falls back to all rules when step_fields is unavailable
603+
* (legacy single-step forms).
604+
*/
605+
const stepFields = (typeof wu_checkout !== 'undefined' && wu_checkout.step_fields) ? wu_checkout.step_fields : null;
606+
const currentStep = jQuery('input[name="checkout_step"]').val();
607+
let rules = allRules;
608+
609+
if (stepFields && currentStep && stepFields[ currentStep ]) {
610+
611+
const allowedFields = stepFields[ currentStep ];
612+
613+
rules = {};
614+
615+
allowedFields.forEach(function(fieldId) {
616+
617+
if (allRules[ fieldId ]) {
618+
619+
rules[ fieldId ] = allRules[ fieldId ];
620+
621+
}
622+
623+
});
624+
625+
}
626+
592627
/**
593628
* Retrieve a display label for a field, falling back to the field ID.
594629
*

composer.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "devstone/ultimate-multisite",
33
"homepage": "https://UltimateMultisite.com",
44
"description": "The Multisite Website as a Service (WaaS) plugin.",
5-
"version": "2.4.13-beta.1",
5+
"version": "2.5.0",
66
"authors": [
77
{
88
"name": "Arindo Duque",
@@ -148,7 +148,13 @@
148148
"deploy.sh",
149149
".vscode",
150150
"coverage.xml",
151-
"coverage-html"
151+
"coverage-html",
152+
"AGENTS.md",
153+
"TODO.md",
154+
"todo",
155+
"docs",
156+
".intelephense",
157+
".task-counter"
152158
]
153159
},
154160
"extra": {

inc/checkout/class-cart.php

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,104 @@ protected function build_from_membership($membership_id): bool {
836836
return true;
837837
}
838838

839+
/*
840+
* Reactivation flow: detect cancelled/expired memberships.
841+
*
842+
* When a customer with a cancelled or expired membership submits a checkout,
843+
* this is a reactivation — not an upgrade. We set the cart_type accordingly
844+
* and ensure the product list includes the full plan + any addons from the
845+
* original membership.
846+
*
847+
* @since 2.5.0
848+
*/
849+
$inactive_statuses = [
850+
Membership_Status::CANCELLED,
851+
Membership_Status::EXPIRED,
852+
'on-hold',
853+
'suspended',
854+
];
855+
856+
if (in_array($membership->get_status(), $inactive_statuses, true)) {
857+
858+
$this->cart_type = 'reactivation';
859+
860+
/*
861+
* Mark the customer as having trialed in memory only.
862+
*
863+
* This prevents has_trial() from returning true during THIS
864+
* checkout session. The flag is NOT persisted to the database
865+
* here — it will be saved when the membership is renewed after
866+
* payment completes. This avoids permanently removing trial
867+
* eligibility if the user abandons checkout.
868+
*
869+
* @since 2.5.0
870+
*/
871+
if ($this->customer && method_exists($this->customer, 'set_has_trialed')) {
872+
$this->customer->set_has_trialed(true);
873+
}
874+
875+
/*
876+
* Always rebuild products from the membership, ignoring any
877+
* user-supplied products in the request. This prevents a
878+
* malicious user from injecting arbitrary product IDs into
879+
* a reactivation cart.
880+
*
881+
* @since 2.5.0
882+
*/
883+
$plan_id = $membership->get_plan_id();
884+
885+
// Set up country and currency before building products
886+
if (! $this->country && $this->customer) {
887+
$this->country = $this->customer->get_country();
888+
}
889+
890+
$this->set_currency($membership->get_currency());
891+
892+
/*
893+
* Set duration from the plan BEFORE calling add_product() so that
894+
* line items are created with the correct billing period. Previously
895+
* this was set after add_product(), meaning products were added with
896+
* the default/empty duration and the correct values were never used.
897+
*
898+
* @since 2.5.0
899+
*/
900+
$plan_product = $membership->get_plan();
901+
902+
if ($plan_product && ! $membership->is_free()) {
903+
$this->duration = $plan_product->get_duration();
904+
$this->duration_unit = $plan_product->get_duration_unit();
905+
}
906+
907+
if (! $plan_id) {
908+
$this->errors->add('no_plan', __('This membership has no plan to reactivate.', 'ultimate-multisite'));
909+
910+
return true;
911+
}
912+
913+
/*
914+
* Rebuild the product list from the membership, preserving addon
915+
* quantities. get_addon_ids() discards quantities; get_addon_products()
916+
* returns [['product' => $obj, 'quantity' => N], ...] so each addon
917+
* is added with the correct quantity.
918+
*
919+
* @since 2.5.0
920+
*/
921+
$this->add_product($plan_id);
922+
923+
$addon_products = $membership->get_addon_products();
924+
925+
foreach ($addon_products as $addon_item) {
926+
$addon_product = wu_get_isset($addon_item, 'product');
927+
$addon_qty = (int) wu_get_isset($addon_item, 'quantity', 1);
928+
929+
if ($addon_product && method_exists($addon_product, 'get_id')) {
930+
$this->add_product($addon_product->get_id(), $addon_qty);
931+
}
932+
}
933+
934+
return true;
935+
}
936+
839937
/*
840938
* Adds the country to calculate taxes.
841939
*/
@@ -2052,7 +2150,7 @@ public function add_product($product_id_or_slug, $quantity = 1): bool {
20522150
return true;
20532151
}
20542152

2055-
$add_signup_fee = 'renewal' !== $this->get_cart_type();
2153+
$add_signup_fee = ! in_array($this->get_cart_type(), ['renewal', 'reactivation'], true);
20562154

20572155
/**
20582156
* Filters whether or not the signup fee should be applied.
@@ -2144,6 +2242,18 @@ public function get_tax_breakthrough() {
21442242
*/
21452243
public function has_trial() {
21462244

2245+
/*
2246+
* Reactivation carts never get a trial period.
2247+
*
2248+
* A customer reactivating a cancelled membership has already used
2249+
* their trial. Offering another trial would be a revenue loss.
2250+
*
2251+
* @since 2.5.0
2252+
*/
2253+
if ('reactivation' === $this->cart_type) {
2254+
return false;
2255+
}
2256+
21472257
$products = $this->get_all_products();
21482258

21492259
if (empty($products)) {
@@ -2576,6 +2686,18 @@ public function get_billing_start_date() {
25762686
return null;
25772687
}
25782688

2689+
/*
2690+
* Reactivation carts have no trial period, so billing starts now.
2691+
*
2692+
* Return null so downstream consumers (Stripe subscription setup,
2693+
* date_trial_end, etc.) don't receive a trial-derived start date.
2694+
*
2695+
* @since 2.5.0
2696+
*/
2697+
if ('reactivation' === $this->cart_type) {
2698+
return null;
2699+
}
2700+
25792701
/*
25802702
* Set extremely high value at first to prevent any change of errors.
25812703
*/

inc/checkout/class-checkout.php

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,11 +1332,22 @@ protected function maybe_create_site() {
13321332
*/
13331333
if ( ! empty($sites)) {
13341334
/*
1335-
* Returns the first site on that list.
1336-
* This is not ideal, but since we'll usually only have
1337-
* one site here, it's ok. for now.
1335+
* Return the first site that has a valid blog ID.
1336+
*
1337+
* We explicitly check for a site with a positive ID rather
1338+
* than blindly using current($sites), which could return a
1339+
* pending or stale entry if the array pointer was moved.
1340+
*
1341+
* @since 2.5.0
13381342
*/
1339-
return current($sites);
1343+
foreach ($sites as $site) {
1344+
if ($site && method_exists($site, 'get_id') && 0 < $site->get_id()) {
1345+
return $site;
1346+
}
1347+
}
1348+
1349+
// Fallback to actual first entry if no valid site found
1350+
return reset($sites);
13401351
}
13411352

13421353
$site_url = $this->request_or_session('site_url');
@@ -2109,6 +2120,28 @@ public function get_checkout_variables() {
21092120

21102121
$variables['field_labels'] = $field_labels;
21112122

2123+
/*
2124+
* Build a step_fields map (step_id => [field_ids]) so the JS validator
2125+
* can restrict client-side validation to only the fields on the current
2126+
* step. Without this, required fields on later steps (e.g. email/username
2127+
* on step 4) would block submission of earlier steps (e.g. a plan-only
2128+
* step 1). Mirrors the server-side logic in get_validation_rules() which
2129+
* filters rules to $this->step['fields'] for non-final steps.
2130+
*/
2131+
$step_fields = [];
2132+
2133+
if ($this->checkout_form) {
2134+
foreach ($this->checkout_form->get_steps_to_show() as $step) {
2135+
if ( ! empty($step['id']) && ! empty($step['fields'])) {
2136+
$step_fields[ $step['id'] ] = array_values(
2137+
array_filter(array_column($step['fields'], 'id'))
2138+
);
2139+
}
2140+
}
2141+
}
2142+
2143+
$variables['step_fields'] = $step_fields;
2144+
21122145
/**
21132146
* Allow plugin developers to filter the pre-sets of a checkout page.
21142147
*

inc/class-dashboard-widgets.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ public function enqueue_scripts(): void {
7474
return;
7575
}
7676

77+
/*
78+
* The activity-stream widget view wraps its output in <div class="wu-styling">,
79+
* which requires framework.css (registered as 'wu-styling'). The network admin
80+
* dashboard is not a wp-ultimo page, so enqueue_default_admin_styles() skips it —
81+
* we must enqueue wu-styling explicitly here.
82+
*/
83+
wp_enqueue_style('wu-styling');
84+
7785
wp_enqueue_script('wu-vue');
7886

7987
wp_enqueue_script('moment');

0 commit comments

Comments
 (0)