Skip to content

Conversation

@superdav42
Copy link
Collaborator

@superdav42 superdav42 commented Jan 25, 2026

and a few other tweaks
closes #330

Summary by CodeRabbit

  • New Features

    • Remote Settings API; Pay‑What‑You‑Want (per‑product custom amounts + recurring); billing‑period controls for discounts/memberships; in‑app change‑email modal; cross‑network magic links.
  • Bug Fixes

    • Country/state selection fix in checkout; improved login autocomplete; suppressed SMTP debug output; clearer error pages.
  • Improvements

    • Conditional billing‑address collection when payment isn’t required; safer API error handling/logging; Vue bindings avoid duplicate name attributes; minor UI/text refinements.
  • Documentation

    • Developer docs replaced with a single external reference link.
  • Tests

    • New focused E2E fixtures/tests added; several legacy E2E suites and checkout helpers removed.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 25, 2026

📝 Walkthrough

Walkthrough

Adds a new Settings REST API endpoint; implements Pay-What-You-Want pricing and per-product custom amounts/recurring; introduces billing-period restrictions for discount codes; updates checkout/payment validation to skip billing when payment isn't required; large docs/hook-generator and e2e test/fixture adjustments.

Changes

Cohort / File(s) Summary
Settings API Endpoint
inc/apis/class-settings-endpoint.php, inc/class-wp-ultimo.php
New Settings_Endpoint class registered during extra-component init: REST routes for GET/POST/PUT/PATCH, sensitive-key filtering, validation, WP_Error responses, optional logging, and singleton integration.
PWYW — Frontend, Cart, Checkout, Admin
assets/js/checkout.js, views/checkout/templates/pricing-table/list.php, inc/models/class-product.php, inc/checkout/class-cart.php, inc/checkout/class-checkout.php, inc/admin-pages/class-product-edit-admin-page.php
Adds Pay-What-You-Want product type, min/suggested amounts, recurring modes; Vue data/methods for per-product custom_amounts and pwyw_recurring; cart/order handling and admin UI fields.
Billing-Period Discount Controls
inc/models/class-discount-code.php, inc/admin-pages/class-discount-code-edit-admin-page.php
Adds allowed/limit billing-period meta, validation accepting duration/duration_unit, admin UI to select/sort billing periods, and helper conversion/formatting methods.
Checkout Validation & Payment Flow
inc/checkout/class-checkout.php, inc/checkout/class-checkout-pages.php, inc/checkout/signup-fields/class-signup-field-billing-address.php, inc/admin-pages/class-membership-list-admin-page.php
Introduces should_collect_payment() to conditionally require billing fields; process_order/create_order accept PWYW extras; Vue wrappers use v-show tied to payment necessity; membership modal parses billing period.
Forms, Vue Bindings & Site Actions
inc/ui/class-login-form-element.php, views/checkout/fields/field-text.php, views/checkout/fields/field-select.php, views/ui/selectize-templates.php, inc/ui/class-site-actions-element.php, inc/list-tables/class-base-list-table.php
Adds autocomplete attrs to login fields; avoids duplicate name attrs when Vue binds v-bind:name; tighter avatar checks; new change-email modal/handler (duplicate insertion present); minor HTML emission tweaks.
Settings & Helpers
inc/class-settings.php, inc/functions/helper.php, inc/class-addon-repository.php, inc/sso/class-magic-link.php
Moves enable_error_reporting to General; wu_kses_allowed_html made filterable; addon API errors logged and tokens cleared on failure; adds cross-network magic-link generation.
BerlinDB Hooks Docs & Dev Docs
inc/documentation/generate-berlindb-hooks.php, inc/documentation/berlindb-dynamic-hooks.php, DEVELOPER-DOCUMENTATION.md, package.json
Adds generator script and large auto-generated hooks reference; replaces local developer docs with external link; build scripts updated to run hook generation.
Falsy-Value Product Filtering
inc/helpers/validation-rules/class-site-template.php, inc/limits/class-site-template-limits.php, inc/ui/class-template-previewer.php
Sanitizes merged product lists with array_filter(array_merge(...)) to remove falsy entries before iteration.
Tests, Fixtures & E2E Changes
tests/e2e/cypress/..., tests/e2e/cypress/fixtures/*, tests/e2e/cypress/support/commands/*, .github/workflows/e2e.yml
Adds multiple E2E fixtures and new Cypress suites for setup, manual checkout, and free trial flows; removes several large existing Cypress suites and custom checkout commands; support command changes (wpCli, wpCliFile, loginByApi); CI workflow simplified.
Misc UI/Text/Config Fixes
views/legacy/signup/steps/step-domain-url-preview.php, views/checkout/fields/*, lang/ultimate-multisite.pot, .wp-env.json, readme.txt, mu-plugins/email-smtp-test/email-smtp-test.php, inc/gateways/class-base-gateway.php, inc/stuff.php, inc/class-ajax.php, views/ui/selectize-templates.php
Vue name-binding guards; placeholder formatting changes; POT updates for PWYW/billing strings; .wp-env sunrise mapping; changelog entry; disabled SMTP debug; small AJAX/escaping/typo fixes and literal updates.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client/Vue
    participant API as Settings Endpoint
    participant Auth as API Auth
    participant Store as Settings Store
    participant Logger as Maybe Logger

    Client->>API: Request (GET/POST/PUT/PATCH /settings...)
    API->>Auth: verify permissions
    Auth-->>API: allowed / denied
    alt allowed
        API->>Store: read or update settings
        Store-->>API: payload / result
        API->>API: filter_sensitive_settings & validate
        API->>Logger: maybe_log_api_call(request)
        API-->>Client: 200 JSON or summary
    else denied
        API-->>Client: 403 WP_Error
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I hopped through settings, keys kept tight,

Customers name prices in the moonlight,
Billing periods sorted in tidy rows,
Checkout asks if coins or breezes flow,
Magic links leap sites — rabbit-approved flight.

🚥 Pre-merge checks | ✅ 3 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes substantial out-of-scope additions beyond issue #330: PWYW pricing features, billing-period discount restrictions, cross-network magic links, email change UI, Vue form enhancements, and test/documentation infrastructure. These appear unrelated to the linked Settings API issue. Either open separate issues for PWYW, billing-period, magic-link, and email-change features and link them to this PR, or split those changes into dedicated PRs with corresponding issue references to clarify scope.
Title check ❓ Inconclusive The title 'Add Settings API and more' is partially related to the main changes; it refers to the Settings API endpoint addition but uses vague language ('and more') that doesn't convey the scope of other significant changes like PWYW pricing, billing-period controls, cross-network magic links, and email change functionality. Consider a more specific title that highlights the primary change (Settings API) and omits vague qualifiers, or expand it to mention key features like PWYW and billing-period support if those are co-equal in importance.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed The PR implements the Settings API endpoint from issue #330 [inc/apis/class-settings-endpoint.php] with GET/POST/PUT/PATCH routes and sensitive setting protections, fully satisfying the linked issue's requirement for API endpoints.
Docstring Coverage ✅ Passed Docstring coverage is 97.89% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch tweask-again

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
inc/functions/helper.php (1)

498-625: Fix array union operator with numeric keys to include all intended tags.

The array union operator (+) preserves the left-hand array's values for overlapping numeric keys. Since array_keys($allowed_html) returns numeric keys [0, 1, ...] and the extra tags array also has numeric keys [0, 1, 2, ...], the + operator will drop extra tags that occupy indices already present in array_keys() (specifically 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'a', 'img', 'input'). Use array_merge() instead to concatenate the arrays and ensure all tags are included.

🔧 Suggested fix
-	] + array_merge_recursive($allowed_html, array_fill_keys(array_keys($allowed_html) + ['div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'a', 'img', 'input', 'textarea'], $vue_and_data_attributes));
+	] + array_merge_recursive(
+		$allowed_html,
+		array_fill_keys(
+			array_unique(array_merge(array_keys($allowed_html), ['div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'a', 'img', 'input', 'textarea'])),
+			$vue_and_data_attributes
+		)
+	);
🤖 Fix all issues with AI agents
In `@inc/apis/class-settings-endpoint.php`:
- Around line 258-270: The loop that calls wu_save_setting() with raw $value
should sanitize/validate via the Field API first: implement a helper (e.g.
sanitize_setting_value(string $key, $value)) that iterates
Settings::get_instance()->get_sections(), finds the field definition for $key,
constructs a \WP_Ultimo\UI\Field($key, $field_def), calls
$field->set_value($value) and returns $field->get_value(); then replace the
direct calls to wu_save_setting($key, $value) in the foreach over
$filtered_settings (and the similar persistence block later) to call $sanitized
= sanitize_setting_value($key, $value) and either pass $sanitized to
wu_save_setting or skip/reject unknown keys (returning null) as appropriate.
- Around line 426-436: The maybe_log_api_call method currently logs raw request
bodies via $request->get_body() which can leak sensitive data; before building
the $payload (or before calling wp_json_encode) sanitize the body by removing or
replacing sensitive keys (e.g., "password", "token", "authorization", "api_key",
"credit_card", "ssn") or omit the body entirely, e.g., call a helper like
redact_request_body($request->get_body()) and use that redacted result for the
'body_params' value so wp_json_encode(wu_log_add(...)) never receives plaintext
secrets; update maybe_log_api_call to perform this redaction and ensure
get_body()/body_params references the sanitized output.

In `@inc/class-settings.php`:
- Around line 711-720: The privacy-policy link in the description passed to
add_field for 'enable_error_reporting' lacks rel="noopener noreferrer" and the
URL isn't escaped; update the sprintf call building the description so the <a
href="%s" ...> uses esc_url( 'https://ultimatemultisite.com/privacy-policy/' )
and add rel="noopener noreferrer" to the anchor (i.e., modify the description
string for add_field('general','enable_error_reporting', ...) to include
rel="noopener noreferrer" and wrap the URL with esc_url()).

In `@readme.txt`:
- Around line 243-249: The changelog contains duplicate "Version [2.4.10]"
entries (the headers "Version [2.4.10]" and the block dated "2026-XX-XX"); merge
the two blocks into a single 2.4.10 entry combining both bullet points, and
replace the placeholder date "2026-XX-XX" with a concrete release date (e.g.,
"2026-01-25") or label the entry "Unreleased" if it isn't finalized; update the
single "Version [2.4.10]" header and remove the redundant header/block so only
one consolidated 2.4.10 section remains.
🧹 Nitpick comments (1)
inc/apis/class-settings-endpoint.php (1)

409-412: Drop unused $value to satisfy PHPMD

Line 409 declares $value but never uses it. Using foreach (array_keys(...)) or $key => $_ avoids the PHPMD warning.

🧹 Suggested tidy‑up
-		foreach ($settings as $key => $value) {
+		foreach ($settings as $key => $_) {
 			if ($this->is_sensitive_setting($key)) {
 				unset($settings[ $key ]);
 			}
 		}

@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
inc/models/class-discount-code.php (1)

500-569: Billing-period restrictions can be bypassed when discount codes are first applied to cart.

The set_discount_code() method (line 1374) validates without duration/unit parameters, allowing a discount code with limit_billing_periods enabled to pass validation. The complete validation only occurs later in apply_discounts_to_item() (line 2415) when product-specific data is available. If callers expect billing period restrictions to be enforced immediately when adding a coupon, this creates a bypass. Consider either deferring coupon acceptance until line-item application or enforcing a hard failure in is_valid() when limit_billing_periods is enabled but no duration params are provided.

🤖 Fix all issues with AI agents
In `@inc/admin-pages/class-discount-code-edit-admin-page.php`:
- Around line 532-588: get_available_billing_periods currently skips products
based solely on $product->is_recurring(), which misses PWYW products with
pwyw_recurring_mode == 'force_recurring'; update the recurrence check in
get_available_billing_periods to treat a product as recurring if
$product->is_recurring() OR ($product->get_pwyw_recurring_mode() ===
'force_recurring') (or the equivalent accessor/name used on the Product class),
then continue using $product->get_duration(), $product->get_duration_unit(),
get_price_variations(), and
Discount_Code::get_billing_period_key/parse_billing_period_key as before so PWYW
forced-recurring products are included in the $periods list and sorting.

In `@inc/admin-pages/class-membership-list-admin-page.php`:
- Around line 233-237: The billing_period parsing accepts untrusted input:
ensure $duration is clamped to >=1 and $duration_unit is validated against a
whitelist before using it to build the cart; specifically, after parsing
$billing_period (the code that sets $billing_parts, $duration and
$duration_unit) replace the loose values with a sanitized $duration = max(1,
absint($billing_parts[0] ?? 1)) and $duration_unit only set if in an allowed
array (e.g. ['day','week','month','year']) otherwise default to 'month' so
invalid units or zero durations cannot create bad variations in the cart.

In `@inc/checkout/class-checkout.php`:
- Around line 1621-1633: process_order() builds the cart params without
including the PWYW fields so submitted custom amounts are lost; update the cart
construction inside process_order() to read the same keys as create_order()—use
$this->request_or_session('custom_amounts', []) and
$this->request_or_session('pwyw_recurring', []) (and propagate them into the
'custom_amounts' and 'pwyw_recurring' keys of the cart array) so the cart passed
to order creation retains the PWYW values.

In `@inc/sso/class-magic-link.php`:
- Around line 167-190: Before generating a token in
generate_cross_network_magic_link, validate that the user actually has access to
the target site and that the redirect URL is safe: call
verify_user_site_access($user_id, $site_id) (or equivalent membership check) and
return false if it fails, then if $redirect_to is provided validate it with
filter_var($redirect_to, FILTER_VALIDATE_URL) and ensure its host matches the
parsed host of $site_url (using parse_url) to prevent mismatched/remote
redirects; only after these checks succeed proceed to generate_token() and
persist $token_data so you do not consume tokens for invalid requests.

In `@views/legacy/signup/steps/step-domain-url-preview.php`:
- Around line 38-40: The subdirectory branch uses a hardcoded 'yoursite'
fallback in the sprintf for the element with id "wu-your-site" (v-html="site_url
? site_url : 'yoursite'"); replace that literal with the translatable
$dynamic_part so both branches use the same localized fallback: update the
sprintf format and the v-html fallback to reference $dynamic_part (escaped via
esc_html) rather than the hardcoded string, keeping the same element ids
"wu-your-site" and "wu-site-domain" and preserving proper escaping and quoting.
🧹 Nitpick comments (1)
inc/sso/class-magic-link.php (1)

201-205: Keep URL filter parity with generate_magic_link().

This method skips the wu_magic_link_url filter, so downstream customizations won’t apply to cross-network links. Consider applying the same filter for consistency and extensibility.

🔧 Proposed change
-    return add_query_arg(
+    $magic_link = add_query_arg(
       [self::TOKEN_QUERY_ARG => $token],
       $site_url
     );
+
+    return apply_filters('wu_magic_link_url', $magic_link, $user_id, $site_id, $redirect_to);

Comment on lines +532 to +588
protected function get_available_billing_periods() {

$periods = [];

foreach (wu_get_products() as $product) {
if ( ! $product->is_recurring()) {
continue;
}

$duration = $product->get_duration();
$duration_unit = $product->get_duration_unit();
$period_key = Discount_Code::get_billing_period_key($duration, $duration_unit);

if ( ! isset($periods[ $period_key ])) {
$periods[ $period_key ] = $this->format_billing_period_label($duration, $duration_unit);
}

// Also check for price variations
$price_variations = $product->get_price_variations();

if ( ! empty($price_variations)) {
foreach ($price_variations as $variation) {
$var_duration = isset($variation['duration']) ? (int) $variation['duration'] : 0;
$var_duration_unit = isset($variation['duration_unit']) ? $variation['duration_unit'] : '';

if ($var_duration > 0 && ! empty($var_duration_unit)) {
$var_period_key = Discount_Code::get_billing_period_key($var_duration, $var_duration_unit);

if ( ! isset($periods[ $var_period_key ])) {
$periods[ $var_period_key ] = $this->format_billing_period_label($var_duration, $var_duration_unit);
}
}
}
}
}

// Sort by duration for consistent display
uksort(
$periods,
function ($a, $b) {
$a_parts = Discount_Code::parse_billing_period_key($a);
$b_parts = Discount_Code::parse_billing_period_key($b);

if ( ! $a_parts || ! $b_parts) {
return 0;
}

// Convert to days for comparison
$a_days = $this->get_period_in_days($a_parts['duration'], $a_parts['duration_unit']);
$b_days = $this->get_period_in_days($b_parts['duration'], $b_parts['duration_unit']);

return $a_days <=> $b_days;
}
);

return $periods;
}
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for is_recurring, pay_what_you_want, and pwyw patterns
rg -n "is_recurring\(|pay_what_you_want|pwyw_recurring_mode" -A 2 -B 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 33826


🏁 Script executed:

# Also search for the Product class definition and is_recurring method
rg -n "class.*Product|function is_recurring|def is_recurring" -A 5 -B 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 14004


🏁 Script executed:

# Check how PWYW products are represented and handled
rg -n "is_pay_what_you_want|pwyw|pay.what.you.want" -i -A 3 -B 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 30256


🏁 Script executed:

# Search for how get_available_billing_periods is used
rg -n "get_available_billing_periods" -B 2 -A 5

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1616


🏁 Script executed:

# Check if discount codes have special handling for PWYW products
rg -n "class.*Discount.*Code|is_pay_what_you_want.*discount" -i -A 3 -B 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 50394


🏁 Script executed:

# Look at the discount code model to understand what billing periods are used for
fd "class-discount" --type f | head -5 | xargs cat

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 36757


PWYW recurring products with force_recurring mode won't appear in available billing periods.

The method relies on is_recurring() which checks the recurring flag and amount > 0, but doesn't account for pwyw_recurring_mode. A PWYW product configured with force_recurring may have recurring=false, causing is_recurring() to return false and the product to be skipped entirely. This means its billing period information never reaches the discount code admin form, preventing admins from restricting codes to periods that only exist on PWYW products.

🤖 Prompt for AI Agents
In `@inc/admin-pages/class-discount-code-edit-admin-page.php` around lines 532 -
588, get_available_billing_periods currently skips products based solely on
$product->is_recurring(), which misses PWYW products with pwyw_recurring_mode ==
'force_recurring'; update the recurrence check in get_available_billing_periods
to treat a product as recurring if $product->is_recurring() OR
($product->get_pwyw_recurring_mode() === 'force_recurring') (or the equivalent
accessor/name used on the Product class), then continue using
$product->get_duration(), $product->get_duration_unit(), get_price_variations(),
and Discount_Code::get_billing_period_key/parse_billing_period_key as before so
PWYW forced-recurring products are included in the $periods list and sorting.

Comment on lines +233 to +237
// Parse the billing period into duration and duration_unit.
$billing_period = wu_request('billing_period', '1-month');
$billing_parts = explode('-', $billing_period, 2);
$duration = isset($billing_parts[0]) ? absint($billing_parts[0]) : 1;
$duration_unit = isset($billing_parts[1]) ? $billing_parts[1] : 'month';
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

Validate and clamp the billing period inputs before building the cart.
billing_period comes from the request; absint() can yield 0, and duration_unit isn’t validated. This can lead to invalid variations or avoidable cart errors. Consider whitelisting units and enforcing duration >= 1.

✅ Suggested hardening
- $billing_period = wu_request('billing_period', '1-month');
- $billing_parts  = explode('-', $billing_period, 2);
- $duration       = isset($billing_parts[0]) ? absint($billing_parts[0]) : 1;
- $duration_unit  = isset($billing_parts[1]) ? $billing_parts[1] : 'month';
+ $billing_period = wu_request('billing_period', '1-month');
+ $billing_parts  = explode('-', $billing_period, 2);
+ $duration       = max(1, absint($billing_parts[0] ?? 1));
+ $duration_unit  = $billing_parts[1] ?? 'month';
+ $allowed_units  = ['day', 'week', 'month', 'year'];
+ if ( ! in_array($duration_unit, $allowed_units, true)) {
+     $duration_unit = 'month';
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Parse the billing period into duration and duration_unit.
$billing_period = wu_request('billing_period', '1-month');
$billing_parts = explode('-', $billing_period, 2);
$duration = isset($billing_parts[0]) ? absint($billing_parts[0]) : 1;
$duration_unit = isset($billing_parts[1]) ? $billing_parts[1] : 'month';
// Parse the billing period into duration and duration_unit.
$billing_period = wu_request('billing_period', '1-month');
$billing_parts = explode('-', $billing_period, 2);
$duration = max(1, absint($billing_parts[0] ?? 1));
$duration_unit = $billing_parts[1] ?? 'month';
$allowed_units = ['day', 'week', 'month', 'year'];
if ( ! in_array($duration_unit, $allowed_units, true)) {
$duration_unit = 'month';
}
🤖 Prompt for AI Agents
In `@inc/admin-pages/class-membership-list-admin-page.php` around lines 233 - 237,
The billing_period parsing accepts untrusted input: ensure $duration is clamped
to >=1 and $duration_unit is validated against a whitelist before using it to
build the cart; specifically, after parsing $billing_period (the code that sets
$billing_parts, $duration and $duration_unit) replace the loose values with a
sanitized $duration = max(1, absint($billing_parts[0] ?? 1)) and $duration_unit
only set if in an allowed array (e.g. ['day','week','month','year']) otherwise
default to 'month' so invalid units or zero durations cannot create bad
variations in the cart.

Comment on lines +167 to +190
public function generate_cross_network_magic_link(int $user_id, int $site_id, string $site_url, string $redirect_to = '') {

if ( ! $this->is_enabled()) {
return false;
}

$user = get_userdata($user_id);

if ( ! $user) {
return false;
}

$token = $this->generate_token();
$user_agent = $this->get_user_agent();
$ip_address = $this->get_client_ip();

$token_data = [
'user_id' => $user_id,
'site_id' => $site_id,
'redirect_to' => $redirect_to,
'created_at' => time(),
'user_agent' => $user_agent,
'ip_address' => $ip_address,
];
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

Validate user/site access and the target URL before issuing a cross-network link.

Right now the method can return a link that will always fail later (user not a member or URL mismatched/invalid) while still consuming a token. Add verify_user_site_access() and basic URL validation (and ideally a host match) before generating the token.

🛠️ Proposed fix
  public function generate_cross_network_magic_link(int $user_id, int $site_id, string $site_url, string $redirect_to = '') {
 
-    if ( ! $this->is_enabled()) {
+    if ( ! $this->is_enabled() ) {
       return false;
     }
 
     $user = get_userdata($user_id);
 
-    if ( ! $user) {
+    if ( ! $user ) {
       return false;
     }
+
+    if ( ! $this->verify_user_site_access($user_id, $site_id) ) {
+      return false;
+    }
+
+    $site_url = wp_http_validate_url($site_url);
+    if ( ! $site_url ) {
+      return false;
+    }
🤖 Prompt for AI Agents
In `@inc/sso/class-magic-link.php` around lines 167 - 190, Before generating a
token in generate_cross_network_magic_link, validate that the user actually has
access to the target site and that the redirect URL is safe: call
verify_user_site_access($user_id, $site_id) (or equivalent membership check) and
return false if it fails, then if $redirect_to is provided validate it with
filter_var($redirect_to, FILTER_VALIDATE_URL) and ensure its host matches the
parsed host of $site_url (using parse_url) to prevent mismatched/remote
redirects; only after these checks succeed proceed to generate_token() and
persist $token_data so you do not consume tokens for invalid requests.

@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

@superdav42 superdav42 changed the title Add Settings API Add Settings API and more Jan 31, 2026
@github-actions
Copy link

github-actions bot commented Feb 2, 2026

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@inc/admin-pages/class-product-edit-admin-page.php`:
- Around line 384-411: The duration and duration_unit inputs inside
pwyw_duration_group are being suppressed from form submission by setting
'v-bind:name' => '""'; remove that attribute for both the 'duration' and
'duration_unit' entries so they behave like the same fields in amount_group
(i.e., allow them to retain their normal name binding and submit their values
when PWYW mode hides amount_group); locate the array keys 'duration' and
'duration_unit' in the pwyw_duration_group definition and delete the
'v-bind:name' => '""' lines (leave other html_attr entries like 'v-model'
intact).

… add hooks documentation generator

- Move cart assignment before validation so should_collect_payment() is available
- Remove billing field requirements when payment is not needed (free trials)
- Hide billing address fields via v-show when payment not collected
- Handle 'autogenerate' site_url value in checkout
- Add array_filter to prevent null products in template limits (3 locations)
- Add sunrise.php mapping and SUNRISE config to wp-env
- Add BerlinDB dynamic hooks documentation generator to build pipeline
- Update encrypted secrets and translation file

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In @.wp-env.json:
- Around line 26-27: The .wp-env.json test environment includes a non-existent
addon path "../addons/ultimate-multisite-woocommerce" (the array entries "." and
"../addons/ultimate-multisite-woocommerce"); remove the
"../addons/ultimate-multisite-woocommerce" entry from the list if the addon is
not required for tests, or add/restore the missing directory at that relative
path (ensuring the plugin contains valid plugin files) so wp-env can bootstrap
successfully; update the array in .wp-env.json accordingly and rerun the test
environment to confirm resolution.

In `@DEVELOPER-DOCUMENTATION.md`:
- Around line 1-3: The DEVELOPER-DOCUMENTATION.md was removed and replaced with
only an external link, which reduces offline availability and versioned context;
restore essential in-repo developer guidance by either (a) re-adding core
sections back into DEVELOPER-DOCUMENTATION.md (setup, architecture overview,
contribution guidelines, key workflows) and keep the external link as
“canonical” reference, or (b) consolidate those essential sections into
README.md under a “Developer Documentation” header and include the external link
and a note that it is the full canonical source; ensure the file(s) reference
the same headings and examples as the external site so reviewers have immediate,
versioned docs in-repo.

In `@inc/berlindb-dynamic-hooks.php`:
- Around line 1-17: Delete the stale duplicate generated file
berlindb-dynamic-hooks.php (the shorter 1674-line version) and leave only the
canonical generator output produced by generate-berlindb-hooks.php in the
documentation output location; remove any references to the removed file from
the repo so the generator’s documentation file is the single source of truth.

In `@lang/ultimate-multisite.pot`:
- Around line 8917-8927: The telemetry disclosure string (msgid "Allow Ultimate
Multisite to collect anonymous usage data..." used in inc/class-settings.php and
views/settings/widget-settings-body.php) is incomplete; either update that
message to enumerate all actually collected fields (MySQL version, server
software, timezone, locale, SSL status, active add-ons,
customer/product/payment/domain counts, memory limits, error file paths and
sanitized stack traces, etc.) and clarify what "error logs" contains, or change
the collection logic in the code paths that assemble telemetry to remove any
fields not currently disclosed; adjust the text in the class-settings.php/views
strings to precisely match the chosen scope and ensure the error-reporting
wording explicitly states inclusion of file paths and sanitized stack traces.
🧹 Nitpick comments (3)
inc/documentation/generate-berlindb-hooks.php (3)

52-67: Consider adding error handling for file() failure.

If Query.php doesn't exist or is unreadable, file() returns false, and the foreach loop will fail. While the script checks for the autoloader, it doesn't verify that $query_php_absolute exists before calling file().

♻️ Proposed fix
 function find_line_number(string $file, string $pattern): int {

 	static $lines = null;

 	if ($lines === null) {
-		$lines = file($file);
+		$lines = file($file);
+		if ($lines === false) {
+			fwrite(STDERR, "Warning: Could not read {$file}\n");
+			$lines = [];
+		}
 	}

 	foreach ($lines as $i => $line) {

523-527: Consider verifying the file write succeeded.

file_put_contents() can return false on failure, but the script always reports success. This could lead to silent failures during build.

♻️ Proposed fix
 $content = ob_get_clean();

-file_put_contents($output_file, $content);
+$bytes = file_put_contents($output_file, $content);

-fwrite(STDERR, "Written to {$output_file}\n");
+if ($bytes === false) {
+	fwrite(STDERR, "Error: Failed to write to {$output_file}\n");
+	exit(1);
+}
+
+fwrite(STDERR, "Written {$bytes} bytes to {$output_file}\n");

152-185: Regex parsing may struggle with complex nested structures.

The pattern at line 154 (/\[\s*(.*?)\s*\]/s) uses non-greedy matching which works for simple column definitions but may fail on deeply nested arrays or columns with array-type defaults. Since this is a documentation generator and the script gracefully skips problematic entries, this is acceptable for now.

Comment on lines +1 to +3
# Ultimate Multisite Developer Documentation

## Table of Contents

1. [Introduction](#introduction)
2. [REST API Reference](#rest-api-reference)
3. [Action Hooks Reference](#action-hooks-reference)
4. [Filter Hooks Reference](#filter-hooks-reference)
5. [Integration Guide](#integration-guide)
6. [Addon Development](#addon-development)
7. [Code Examples](#code-examples)

---

## Introduction

This comprehensive guide provides developers with everything needed to integrate with, extend, or develop addons for Ultimate Multisite. Ultimate Multisite transforms a WordPress Multisite network into a Website-as-a-Service (WaaS) platform.

### Key Features for Developers:
- **REST API** - Complete CRUD operations for all entities
- **Action Hooks** - 200+ hooks for lifecycle events
- **Filter Hooks** - 280+ filters for customization
- **Addon Framework** - Structured addon development system
- **Authentication** - API key-based authentication system

### Requirements:
- WordPress Multisite installation
- PHP 7.4 or higher
- WP Ultimo plugin activated

---

## REST API Reference

### Base Configuration

**Base URL:** `{site_url}/wp-json/wu/v2/`
**Authentication:** API Key & Secret (HTTP Basic Auth or URL Parameters)

### Authentication

#### Enable API
```php
// Enable API in WP Ultimo settings or programmatically
wu_save_setting('enable_api', true);
```

#### Get API Credentials
```php
$api_key = wu_get_setting('api_key');
$api_secret = wu_get_setting('api_secret');
```

#### Authentication Methods

**HTTP Basic Auth (Recommended):**
```bash
curl -u "api_key:api_secret" https://yoursite.com/wp-json/wu/v2/customers
```

**URL Parameters:**
```bash
curl "https://yoursite.com/wp-json/wu/v2/customers?api_key=your_key&api_secret=your_secret"
```

### Core Endpoints

#### 1. Customers API

**Base Route:** `/customers`

**Get All Customers**
```http
GET /wu/v2/customers
```

**Get Single Customer**
```http
GET /wu/v2/customers/{id}
```

**Create Customer**
```http
POST /wu/v2/customers
Content-Type: application/json

{
"user_id": 123,
"email_verification": "verified",
"type": "customer",
"has_trialed": false,
"vip": false
}
```

**Update Customer**
```http
PUT /wu/v2/customers/{id}
Content-Type: application/json

{
"vip": true,
"extra_information": "VIP customer notes"
}
```

**Delete Customer**
```http
DELETE /wu/v2/customers/{id}
```

#### 2. Sites API

**Base Route:** `/sites`

**Create Site**
```http
POST /wu/v2/sites
Content-Type: application/json

{
"customer_id": 5,
"membership_id": 10,
"domain": "example.com",
"path": "/",
"title": "My New Site",
"template_id": 1,
"type": "customer_owned"
}
```

#### 3. Memberships API

**Base Route:** `/memberships`

**Create Membership**
```http
POST /wu/v2/memberships
Content-Type: application/json

{
"customer_id": 5,
"plan_id": 3,
"status": "active",
"gateway": "stripe",
"gateway_subscription_id": "sub_1234567890",
"auto_renew": true
}
```

#### 4. Products API

**Base Route:** `/products`

**Get All Products**
```http
GET /wu/v2/products
```

#### 5. Payments API

**Base Route:** `/payments`

**Create Payment**
```http
POST /wu/v2/payments
Content-Type: application/json

{
"customer_id": 5,
"membership_id": 10,
"status": "completed",
"gateway": "stripe",
"gateway_payment_id": "pi_1234567890",
"total": 29.99,
"currency": "USD"
}
```

#### 6. Domains API

**Base Route:** `/domains`

**Map Domain**
```http
POST /wu/v2/domains
Content-Type: application/json

{
"domain": "custom-domain.com",
"customer_id": 5,
"primary_domain": 1,
"stage": "domain-mapping"
}
```

### Registration Endpoint

The `/register` endpoint provides a complete checkout/registration flow:

```http
POST /wu/v2/register
Content-Type: application/json

{
"customer": {
"username": "newuser",
"password": "securepass123",
"email": "user@example.com"
},
"products": ["basic-plan"],
"duration": 1,
"duration_unit": "month",
"auto_renew": true,
"site": {
"site_url": "mynewsite",
"site_title": "My New Site",
"template_id": 1
},
"payment": {
"status": "completed"
},
"membership": {
"status": "active"
}
}
```

**Response:**
```json
{
"customer": { ... },
"membership": { ... },
"payment": { ... },
"site": { "id": 123 }
}
```

### Error Responses

```json
{
"code": "wu_rest_invalid_parameter",
"message": "Invalid parameter value",
"data": {
"status": 400,
"params": {
"email": "Invalid email format"
}
}
}
```

### Pagination and Filtering

**Query Parameters:**
```http
GET /wu/v2/customers?per_page=20&page=2&search=john&status=active
```

Common parameters:
- `per_page` - Items per page (default: 20, max: 100)
- `page` - Page number
- `search` - Search term
- `orderby` - Sort field
- `order` - Sort direction (asc/desc)
- `status` - Filter by status
- `date_created` - Filter by date range

---

## Action Hooks Reference

### Lifecycle Hooks

#### Plugin Activation
```php
/**
* Fires when WP Ultimo is activated.
*
* @since 2.0.0
*/
do_action('wu_activation');

// Usage Example:
add_action('wu_activation', function() {
// Initialize custom data
add_option('my_addon_version', '1.0.0');
});
```

#### Settings Management
```php
/**
* Fires after settings are saved.
*
* @since 2.0.0
* @param array $settings The settings being saved.
*/
do_action('wu_after_save_settings', $settings);

// Usage Example:
add_action('wu_after_save_settings', function($settings) {
if (isset($settings['enable_feature'])) {
// React to feature toggle
update_option('feature_enabled', $settings['enable_feature']);
}
});
```

### Customer Hooks

#### Customer Creation
```php
/**
* Fires after a customer is created.
*
* @since 2.0.0
* @param WP_Ultimo\Models\Customer $customer The customer object.
*/
do_action('wu_customer_post_create', $customer);

// Usage Example:
add_action('wu_customer_post_create', function($customer) {
// Send welcome email
wp_mail(
$customer->get_email(),
'Welcome!',
'Thanks for joining our platform!'
);
});
```

#### Customer Status Change
```php
/**
* Fires when customer status changes.
*
* @since 2.0.0
* @param WP_Ultimo\Models\Customer $customer The customer object.
* @param string $old_status Previous status.
* @param string $new_status New status.
*/
do_action('wu_customer_status_change', $customer, $old_status, $new_status);
```

### Site Hooks

#### Site Creation
```php
/**
* Fires after a site is published.
*
* @since 2.0.0
* @param WP_Ultimo\Models\Site $site The site object.
* @param WP_Ultimo\Models\Membership $membership The associated membership.
*/
do_action('wu_site_published', $site, $membership);

// Usage Example:
add_action('wu_site_published', function($site, $membership) {
// Set up initial site configuration
switch_to_blog($site->get_id());

// Install default plugins
activate_plugin('essential-plugin/essential-plugin.php');

restore_current_blog();
}, 10, 2);
```

#### Site Template Application
```php
/**
* Fires before applying a site template.
*
* @since 2.0.0
* @param int $site_id The site ID being configured.
* @param int $template_id The template being applied.
*/
do_action('wu_before_apply_template', $site_id, $template_id);

// Usage Example:
add_action('wu_before_apply_template', function($site_id, $template_id) {
// Custom template preparation
switch_to_blog($site_id);

// Set custom options based on template
if ($template_id === 5) { // E-commerce template
update_option('woocommerce_store_setup', 'complete');
}

restore_current_blog();
}, 10, 2);
```

### Membership Hooks

#### Membership Status Changes
```php
/**
* Fires when membership transitions to active status.
*
* @since 2.0.0
* @param WP_Ultimo\Models\Membership $membership The membership object.
*/
do_action('wu_membership_status_to_active', $membership);

/**
* Fires when membership expires.
*
* @since 2.0.0
* @param WP_Ultimo\Models\Membership $membership The membership object.
*/
do_action('wu_membership_status_to_expired', $membership);

// Usage Example:
add_action('wu_membership_status_to_expired', function($membership) {
// Suspend related sites
$sites = $membership->get_sites();
foreach ($sites as $site) {
$site->set_status('suspended');
$site->save();
}
});
```

### Payment Hooks

#### Payment Processing
```php
/**
* Fires when a payment is completed.
*
* @since 2.0.0
* @param WP_Ultimo\Models\Payment $payment The payment object.
*/
do_action('wu_payment_completed', $payment);

/**
* Fires when a payment fails.
*
* @since 2.0.0
* @param WP_Ultimo\Models\Payment $payment The payment object.
* @param string $error_message The error message.
*/
do_action('wu_payment_failed', $payment, $error_message);

// Usage Example:
add_action('wu_payment_failed', function($payment, $error) {
// Notify administrators
$admin_email = get_option('admin_email');
wp_mail(
$admin_email,
'Payment Failed',
sprintf('Payment #%d failed: %s', $payment->get_id(), $error)
);
}, 10, 2);
```

### Checkout Hooks

#### Checkout Flow
```php
/**
* Fires before checkout processing begins.
*
* @since 2.0.0
* @param WP_Ultimo\Checkout\Cart $cart The cart object.
*/
do_action('wu_checkout_before_processing', $cart);

/**
* Fires after successful checkout completion.
*
* @since 2.0.0
* @param WP_Ultimo\Models\Payment $payment The payment object.
* @param WP_Ultimo\Models\Customer $customer The customer object.
* @param WP_Ultimo\Models\Membership $membership The membership object.
*/
do_action('wu_checkout_completed', $payment, $customer, $membership);

// Usage Example:
add_action('wu_checkout_completed', function($payment, $customer, $membership) {
// Track conversion in analytics
if (function_exists('gtag')) {
gtag('event', 'purchase', [
'transaction_id' => $payment->get_id(),
'value' => $payment->get_total(),
'currency' => $payment->get_currency()
]);
}
}, 10, 3);
```

### Domain Hooks

#### Domain Management
```php
/**
* Fires when a domain is mapped successfully.
*
* @since 2.0.0
* @param WP_Ultimo\Models\Domain $domain The domain object.
*/
do_action('wu_domain_mapped', $domain);

/**
* Fires when domain SSL is verified.
*
* @since 2.0.0
* @param WP_Ultimo\Models\Domain $domain The domain object.
*/
do_action('wu_domain_ssl_verified', $domain);

// Usage Example:
add_action('wu_domain_mapped', function($domain) {
// Update CDN configuration
if (function_exists('cloudflare_update_zone')) {
cloudflare_update_zone($domain->get_domain());
}
});
```

---

## Filter Hooks Reference

### Content and Output Filters

#### Customizing Output
```php
/**
* Filters the checkout form fields.
*
* @since 2.0.0
* @param array $fields The form fields.
* @param WP_Ultimo\Models\Checkout_Form $form The checkout form object.
* @return array Modified fields array.
*/
$fields = apply_filters('wu_checkout_form_final_fields', $fields, $form);

// Usage Example:
add_filter('wu_checkout_form_final_fields', function($fields, $form) {
// Add custom field
$fields['company'] = [
'type' => 'text',
'title' => 'Company Name',
'required' => true,
'placeholder' => 'Enter company name'
];

return $fields;
}, 10, 2);
```

#### Email Content
```php
/**
* Filters email content before sending.
*
* @since 2.0.0
* @param string $content The email content.
* @param string $email_type The email type.
* @param WP_Ultimo\Models\Customer $customer The customer object.
* @return string Modified content.
*/
$content = apply_filters('wu_email_content', $content, $email_type, $customer);

// Usage Example:
add_filter('wu_email_content', function($content, $type, $customer) {
if ($type === 'welcome') {
// Add custom welcome message
$content .= "\n\nSpecial offer: Use code WELCOME10 for 10% off!";
}
return $content;
}, 10, 3);
```

### Pricing and Cart Filters

#### Price Modifications
```php
/**
* Filters the final cart total.
*
* @since 2.0.0
* @param float $total The cart total.
* @param WP_Ultimo\Checkout\Cart $cart The cart object.
* @return float Modified total.
*/
$total = apply_filters('wu_cart_total', $total, $cart);

// Usage Example:
add_filter('wu_cart_total', function($total, $cart) {
$customer = $cart->get_customer();

// VIP discount
if ($customer && $customer->is_vip()) {
$total = $total * 0.9; // 10% discount
}

return $total;
}, 10, 2);
```

#### Tax Calculations
```php
/**
* Filters tax rates by location.
*
* @since 2.0.0
* @param float $rate The tax rate.
* @param string $country The country code.
* @param string $state The state code.
* @return float Modified tax rate.
*/
$rate = apply_filters('wu_tax_rate', $rate, $country, $state);

// Usage Example:
add_filter('wu_tax_rate', function($rate, $country, $state) {
// Custom tax rate for specific region
if ($country === 'US' && $state === 'CA') {
return 0.0875; // 8.75% CA tax
}

return $rate;
}, 10, 3);
```

### Limitation Filters

#### Feature Limitations
```php
/**
* Filters whether a feature is allowed for a site.
*
* @since 2.0.0
* @param bool $allowed Whether the feature is allowed.
* @param int $site_id The site ID.
* @param WP_Ultimo\Models\Membership $membership The membership object.
* @return bool Modified permission.
*/
$allowed = apply_filters('wu_limitation_feature_allowed', $allowed, $site_id, $membership);

// Usage Example:
add_filter('wu_limitation_feature_allowed', function($allowed, $site_id, $membership) {
// Allow premium features for VIP customers
if ($membership->get_customer()->is_vip()) {
return true;
}

return $allowed;
}, 10, 3);
```

#### Storage Limits
```php
/**
* Filters disk space limit for a site.
*
* @since 2.0.0
* @param int $limit The disk space limit in MB.
* @param int $site_id The site ID.
* @param WP_Ultimo\Models\Membership $membership The membership object.
* @return int Modified limit.
*/
$limit = apply_filters('wu_disk_space_limit', $limit, $site_id, $membership);

// Usage Example:
add_filter('wu_disk_space_limit', function($limit, $site_id, $membership) {
// Bonus storage for long-term customers
if ($membership->get_days_active() > 365) {
$limit += 500; // Extra 500MB
}

return $limit;
}, 10, 3);
```

### Gateway and Payment Filters

#### Gateway Selection
```php
/**
* Filters available payment gateways.
*
* @since 2.0.0
* @param array $gateways Available gateways.
* @param WP_Ultimo\Checkout\Cart $cart The cart object.
* @return array Modified gateways array.
*/
$gateways = apply_filters('wu_available_gateways', $gateways, $cart);

// Usage Example:
add_filter('wu_available_gateways', function($gateways, $cart) {
// Hide PayPal for enterprise plans
if ($cart->get_total() > 1000) {
unset($gateways['paypal']);
}

return $gateways;
}, 10, 2);
```

### Template and Theme Filters

#### Template Selection
```php
/**
* Filters available site templates.
*
* @since 2.0.0
* @param array $templates Available templates.
* @param WP_Ultimo\Models\Customer $customer The customer object.
* @return array Modified templates array.
*/
$templates = apply_filters('wu_available_templates', $templates, $customer);

// Usage Example:
add_filter('wu_available_templates', function($templates, $customer) {
// Premium templates for VIP customers only
if (!$customer->is_vip()) {
foreach ($templates as $key => $template) {
if ($template['category'] === 'premium') {
unset($templates[$key]);
}
}
}

return $templates;
}, 10, 2);
```

---

## Integration Guide

### Third-Party Service Integration

#### CRM Integration Example
```php
// Hook into customer creation
add_action('wu_customer_post_create', 'sync_customer_to_crm');

function sync_customer_to_crm($customer) {
$crm_api = new Your_CRM_API();

$crm_api->create_contact([
'email' => $customer->get_email(),
'name' => $customer->get_display_name(),
'signup_date' => $customer->get_date_registered(),
'plan' => $customer->get_membership()->get_plan()->get_name()
]);

// Store CRM ID for future reference
$customer->add_meta('crm_contact_id', $crm_api->get_last_contact_id());
}
```

#### Analytics Integration
```php
// Track key events
add_action('wu_checkout_completed', 'track_conversion', 10, 3);
add_action('wu_membership_status_to_cancelled', 'track_churn');
add_action('wu_payment_failed', 'track_payment_failure');

function track_conversion($payment, $customer, $membership) {
// Google Analytics 4
gtag('event', 'purchase', [
'transaction_id' => $payment->get_id(),
'value' => $payment->get_total(),
'currency' => $payment->get_currency(),
'items' => [
[
'item_id' => $membership->get_plan()->get_id(),
'item_name' => $membership->get_plan()->get_name(),
'category' => 'subscription',
'quantity' => 1,
'price' => $payment->get_total()
]
]
]);
}
```

### Custom Gateway Development

#### Create Custom Gateway
```php
class My_Custom_Gateway extends \WP_Ultimo\Gateways\Base_Gateway {

public $id = 'my_gateway';

public function __construct() {
$this->title = 'My Payment Gateway';
$this->description = 'Custom payment processing';
$this->supports = ['one-time', 'recurring'];

parent::__construct();
}

public function process_single_payment($payment, $cart, $order) {
// Process one-time payment
$result = $this->api_call('charge', [
'amount' => $payment->get_total(),
'currency' => $payment->get_currency(),
'customer' => $payment->get_customer_id()
]);

if ($result->success) {
$payment->set_gateway_payment_id($result->transaction_id);
$payment->set_status('completed');
return true;
}

return new WP_Error('payment_failed', $result->error_message);
}

public function process_signup($membership, $customer, $cart, $order) {
// Set up recurring subscription
$subscription = $this->api_call('subscription/create', [
'customer_id' => $customer->get_gateway_customer_id(),
'plan_id' => $membership->get_plan()->get_gateway_plan_id(),
'trial_days' => $membership->get_trial_days()
]);

if ($subscription->success) {
$membership->set_gateway_subscription_id($subscription->id);
return true;
}

return new WP_Error('subscription_failed', $subscription->error);
}
}

// Register the gateway
add_filter('wu_payment_gateways', function($gateways) {
$gateways['my_gateway'] = 'My_Custom_Gateway';
return $gateways;
});
```

### Webhook Handling

#### Custom Webhook Endpoint
```php
// Register webhook endpoint
add_action('rest_api_init', function() {
register_rest_route('my-addon/v1', '/webhook', [
'methods' => 'POST',
'callback' => 'handle_my_webhook',
'permission_callback' => 'verify_webhook_signature'
]);
});

function handle_my_webhook($request) {
$payload = $request->get_json_params();

switch ($payload['event_type']) {
case 'customer.updated':
$customer = wu_get_customer($payload['customer_id']);
if ($customer) {
// Sync changes from external system
$customer->set_vip($payload['data']['is_vip']);
$customer->save();
}
break;

case 'subscription.cancelled':
$membership = wu_get_membership_by_hash($payload['subscription_id']);
if ($membership) {
$membership->cancel();
}
break;
}

return ['status' => 'processed'];
}

function verify_webhook_signature($request) {
$signature = $request->get_header('X-Webhook-Signature');
$payload = $request->get_body();
$secret = get_option('my_webhook_secret');

return hash_hmac('sha256', $payload, $secret) === $signature;
}
```

---

## Addon Development

### Addon Structure

```
my-addon/
├── my-addon.php # Main plugin file
├── inc/
│ ├── class-my-addon.php # Main addon class
│ ├── admin-pages/ # Admin interface
│ ├── models/ # Custom data models
│ └── integrations/ # Third-party integrations
├── assets/
│ ├── js/
│ └── css/
└── templates/ # Template files
```

### Main Addon File Template

```php
<?php
/**
* Plugin Name: My WP Ultimo Addon
* Description: Custom addon for WP Ultimo
* Version: 1.0.0
* Author: Your Name
* Requires PHP: 7.4
* WP Ultimo: 2.0.0
*/

namespace My_Addon;

// Exit if accessed directly
defined('ABSPATH') || exit;

// Define constants
define('MY_ADDON_VERSION', '1.0.0');
define('MY_ADDON_PLUGIN_FILE', __FILE__);
define('MY_ADDON_PATH', plugin_dir_path(__FILE__));
define('MY_ADDON_URL', plugin_dir_url(__FILE__));

// Check if WP Ultimo is active
add_action('plugins_loaded', function() {
if (!class_exists('WP_Ultimo\WP_Ultimo')) {
add_action('admin_notices', function() {
echo '<div class="notice notice-error"><p>';
echo 'My Addon requires WP Ultimo to be installed and activated.';
echo '</p></div>';
});
return;
}

// Initialize addon
My_Addon::get_instance();
});

/**
* Main addon class
*/
class My_Addon {

use \WP_Ultimo\Traits\Singleton;

/**
* Initialize the addon
*/
public function init() {
// Load dependencies
$this->load_dependencies();

// Setup hooks
$this->setup_hooks();

// Initialize components
$this->init_components();
}

/**
* Load required files
*/
private function load_dependencies() {
require_once MY_ADDON_PATH . 'inc/class-my-addon.php';
}

/**
* Setup WordPress hooks
*/
private function setup_hooks() {
// Activation/deactivation
register_activation_hook(MY_ADDON_PLUGIN_FILE, [$this, 'activate']);
register_deactivation_hook(MY_ADDON_PLUGIN_FILE, [$this, 'deactivate']);

// WP Ultimo hooks
add_action('wu_checkout_completed', [$this, 'on_checkout_completed'], 10, 3);
add_filter('wu_checkout_form_fields', [$this, 'add_custom_fields'], 10, 2);
}

/**
* Initialize addon components
*/
private function init_components() {
// Initialize admin pages, models, etc.
}

/**
* Plugin activation
*/
public function activate() {
// Create custom tables, set options, etc.
$this->create_custom_table();
update_option('my_addon_version', MY_ADDON_VERSION);
}

/**
* Plugin deactivation
*/
public function deactivate() {
// Cleanup if needed
}

/**
* Handle checkout completion
*/
public function on_checkout_completed($payment, $customer, $membership) {
// Custom logic when checkout completes
$this->send_welcome_email($customer);
$this->setup_customer_account($customer, $membership);
}

/**
* Add custom checkout fields
*/
public function add_custom_fields($fields, $form) {
$fields['company_size'] = [
'type' => 'select',
'title' => 'Company Size',
'options' => [
'small' => '1-10 employees',
'medium' => '11-100 employees',
'large' => '100+ employees'
],
'required' => false
];

return $fields;
}
}
```

### Custom Model Example

```php
<?php

namespace My_Addon\Models;

/**
* Custom Lead model
*/
class Lead extends \WP_Ultimo\Models\Base_Model {

/**
* Model name
*/
protected $model = 'lead';

/**
* Set the database table
*/
protected function set_table() {
global $wpdb;
$this->table_name = "{$wpdb->prefix}my_addon_leads";
}

/**
* Get the company name
*/
public function get_company() {
return $this->get_meta('company');
}

/**
* Set the company name
*/
public function set_company($company) {
return $this->add_meta('company', $company);
}

/**
* Convert lead to customer
*/
public function convert_to_customer($user_data = []) {
// Create WordPress user
$user_id = wp_create_user(
$user_data['username'] ?? $this->get_email(),
$user_data['password'] ?? wp_generate_password(),
$this->get_email()
);

if (is_wp_error($user_id)) {
return $user_id;
}

// Create WP Ultimo customer
$customer = wu_create_customer([
'user_id' => $user_id,
'email_verification' => 'verified',
'type' => 'customer'
]);

if (is_wp_error($customer)) {
return $customer;
}

// Copy lead data to customer
$customer->add_meta('company', $this->get_company());
$customer->add_meta('lead_source', $this->get_source());

// Mark lead as converted
$this->set_status('converted');
$this->add_meta('converted_customer_id', $customer->get_id());
$this->save();

return $customer;
}
}
```

### Admin Page Integration

```php
<?php

namespace My_Addon\Admin_Pages;

/**
* Custom admin page
*/
class Leads_Admin_Page extends \WP_Ultimo\Admin_Pages\Base_Admin_Page {

/**
* Page ID
*/
protected $id = 'my-addon-leads';

/**
* Menu position
*/
protected $position = 30;

/**
* Initialize page
*/
public function init() {
// Register with WP Ultimo
add_action('wu_register_admin_pages', [$this, 'register']);
}

/**
* Register the admin page
*/
public function register() {
wu_register_admin_page($this->id, [
'title' => __('Leads', 'my-addon'),
'menu_title' => __('Leads', 'my-addon'),
'capability' => 'wu_read_leads',
'position' => $this->position,
'parent' => 'wp-ultimo',
'callback' => [$this, 'render']
]);
}

/**
* Render the page
*/
public function render() {
// Get leads data
$leads = My_Addon\Models\Lead::query([
'number' => 20,
'paged' => absint($_GET['paged'] ?? 1)
]);

// Render template
wu_get_template('admin/leads-list', [
'leads' => $leads,
'page_title' => __('Manage Leads', 'my-addon')
]);
}
}
```

---

## Code Examples

### Advanced Integration Examples

#### 1. Multi-Gateway Payment Processing

```php
/**
* Process payment with fallback gateways
*/
class Smart_Payment_Processor {

private $gateway_priority = ['stripe', 'paypal', 'manual'];

public function process_payment_with_fallback($payment, $cart) {
foreach ($this->gateway_priority as $gateway_id) {
$gateway = wu_get_gateway($gateway_id);

if (!$gateway || !$gateway->is_available()) {
continue;
}

$result = $gateway->process_single_payment($payment, $cart);

if (!is_wp_error($result)) {
// Payment successful
do_action('wu_payment_processed_successfully', $payment, $gateway_id);
return $result;
}

// Log failed attempt
wu_log_add('payment-processing', sprintf(
'Gateway %s failed for payment %d: %s',
$gateway_id,
$payment->get_id(),
$result->get_error_message()
));
}

// All gateways failed
do_action('wu_payment_processing_failed', $payment);
return new WP_Error('all_gateways_failed', 'All payment methods failed');
}
}
```

#### 2. Dynamic Pricing Engine

```php
/**
* Advanced pricing rules engine
*/
class Dynamic_Pricing_Engine {

public function __construct() {
add_filter('wu_cart_total', [$this, 'apply_dynamic_pricing'], 20, 2);
add_filter('wu_product_price', [$this, 'modify_product_price'], 10, 3);
}

public function apply_dynamic_pricing($total, $cart) {
$customer = $cart->get_customer();
$rules = $this->get_pricing_rules();

foreach ($rules as $rule) {
if ($this->rule_applies($rule, $cart, $customer)) {
$total = $this->apply_rule($rule, $total, $cart);
}
}

return $total;
}

private function get_pricing_rules() {
return [
[
'type' => 'volume_discount',
'condition' => ['total_greater_than' => 100],
'discount' => 0.1 // 10%
],
[
'type' => 'loyalty_discount',
'condition' => ['customer_tenure_months' => 12],
'discount' => 0.15 // 15%
],
[
'type' => 'seasonal_promo',
'condition' => ['date_range' => ['2024-11-01', '2024-12-31']],
'discount' => 0.2 // 20%
]
];
}

private function rule_applies($rule, $cart, $customer) {
foreach ($rule['condition'] as $condition => $value) {
switch ($condition) {
case 'total_greater_than':
if ($cart->get_total() <= $value) return false;
break;

case 'customer_tenure_months':
if (!$customer || $customer->get_months_active() < $value) return false;
break;

case 'date_range':
$now = current_time('Y-m-d');
if ($now < $value[0] || $now > $value[1]) return false;
break;
}
}

return true;
}

private function apply_rule($rule, $total, $cart) {
$discount_amount = $total * $rule['discount'];

// Log the discount application
wu_log_add('pricing', sprintf(
'Applied %s rule: %.2f discount on total %.2f',
$rule['type'],
$discount_amount,
$total
));

return $total - $discount_amount;
}
}

new Dynamic_Pricing_Engine();
```

#### 3. Advanced Site Provisioning

```php
/**
* Custom site provisioning with external services
*/
class Advanced_Site_Provisioner {

public function __construct() {
add_action('wu_site_published', [$this, 'provision_site'], 10, 2);
add_action('wu_membership_status_to_expired', [$this, 'suspend_site_services']);
}

public function provision_site($site, $membership) {
$plan = $membership->get_plan();

// Configure based on plan features
switch_to_blog($site->get_id());

// Install plugins based on plan
$this->install_plan_plugins($plan);

// Configure SSL
if ($plan->has_feature('ssl')) {
$this->setup_ssl($site);
}

// Setup CDN
if ($plan->has_feature('cdn')) {
$this->configure_cdn($site);
}

// Configure backups
if ($plan->has_feature('backups')) {
$this->setup_automated_backups($site, $plan->get_backup_frequency());
}

// Setup monitoring
$this->setup_site_monitoring($site, $membership->get_customer());

restore_current_blog();

// Send completion notification
$this->send_provisioning_complete_email($site, $membership);
}

private function install_plan_plugins($plan) {
$plugins = $plan->get_included_plugins();

foreach ($plugins as $plugin_slug) {
if ($this->plugin_exists($plugin_slug)) {
activate_plugin($plugin_slug);

// Configure plugin if needed
$this->configure_plugin($plugin_slug, $plan);
}
}
}

private function setup_ssl($site) {
$domain = $site->get_domain();

// API call to SSL provider
$ssl_service = new SSL_Provider_API();
$result = $ssl_service->request_certificate($domain);

if ($result->success) {
$site->add_meta('ssl_certificate_id', $result->certificate_id);
$site->add_meta('ssl_status', 'active');
}
}

private function configure_cdn($site) {
$cdn_service = new CDN_Provider_API();

$zone = $cdn_service->create_zone([
'name' => $site->get_domain(),
'type' => 'full'
]);

if ($zone->success) {
$site->add_meta('cdn_zone_id', $zone->id);

// Update DNS records
$this->update_cdn_dns($site, $zone);
}
}

private function setup_automated_backups($site, $frequency) {
$backup_service = new Backup_Provider_API();

$schedule = $backup_service->create_schedule([
'site_id' => $site->get_id(),
'frequency' => $frequency,
'retention' => 30 // days
]);

$site->add_meta('backup_schedule_id', $schedule->id);
}

private function setup_site_monitoring($site, $customer) {
$monitoring_service = new Monitoring_API();

$monitor = $monitoring_service->create_monitor([
'url' => $site->get_domain(),
'customer_email' => $customer->get_email(),
'check_interval' => 300 // 5 minutes
]);

$site->add_meta('monitoring_id', $monitor->id);
}
}

new Advanced_Site_Provisioner();
```

#### 4. Custom Limitations System

```php
/**
* Advanced limitations with usage tracking
*/
class Advanced_Limitations {

public function __construct() {
add_filter('wu_limitation_plugins_allowed', [$this, 'check_plugin_limit'], 10, 3);
add_filter('wu_limitation_storage_allowed', [$this, 'check_storage_limit'], 10, 3);
add_action('activated_plugin', [$this, 'track_plugin_activation'], 10, 2);
}

public function check_plugin_limit($allowed, $site_id, $membership) {
$plan = $membership->get_plan();
$max_plugins = $plan->get_limit('max_plugins', 10);

// Count active plugins
switch_to_blog($site_id);
$active_plugins = count(get_option('active_plugins', []));
restore_current_blog();

if ($active_plugins >= $max_plugins) {
// Send warning notification
$this->send_limit_warning($membership->get_customer(), 'plugins', $max_plugins);
return false;
}

return true;
}

public function check_storage_limit($allowed, $site_id, $membership) {
$plan = $membership->get_plan();
$max_storage = $plan->get_limit('max_storage_mb', 1000); // MB

$current_usage = $this->get_site_storage_usage($site_id);

if ($current_usage >= $max_storage) {
// Log limit reached
wu_log_add('limitations', sprintf(
'Site %d reached storage limit: %dMB/%dMB',
$site_id,
$current_usage,
$max_storage
));

return false;
}

// Warn at 80% usage
if ($current_usage >= ($max_storage * 0.8)) {
$this->send_storage_warning($membership->get_customer(), $current_usage, $max_storage);
}

return true;
}

private function get_site_storage_usage($site_id) {
// Calculate actual storage usage
$upload_dir = wp_upload_dir();
$size = $this->get_directory_size($upload_dir['basedir']);

// Convert to MB
return round($size / 1024 / 1024, 2);
}

private function get_directory_size($directory) {
$size = 0;
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory)
);

foreach ($files as $file) {
if ($file->isFile()) {
$size += $file->getSize();
}
}

return $size;
}

private function send_limit_warning($customer, $limit_type, $limit_value) {
wu_mail_customer($customer, 'limit_warning', [
'limit_type' => $limit_type,
'limit_value' => $limit_value,
'upgrade_url' => wu_get_current_url('upgrade')
]);
}
}

new Advanced_Limitations();
```

### Testing Your Integration

#### Unit Test Example

```php
<?php

class Test_My_Integration extends WP_UnitTestCase {

public function setUp() {
parent::setUp();

// Create test customer
$this->customer = wu_create_customer([
'user_id' => $this->factory->user->create(),
'type' => 'customer'
]);

// Create test membership
$this->membership = wu_create_membership([
'customer_id' => $this->customer->get_id(),
'plan_id' => $this->create_test_plan()
]);
}

public function test_custom_field_saves_correctly() {
$checkout = new WP_Ultimo\Checkout\Checkout();

// Simulate form submission
$_POST['company_size'] = 'medium';

$result = $checkout->process_step_data([
'company_size' => 'medium'
]);

$this->assertTrue($result);

// Verify data was saved
$saved_value = $this->customer->get_meta('company_size');
$this->assertEquals('medium', $saved_value);
}

public function test_pricing_rule_applies() {
$engine = new Dynamic_Pricing_Engine();

$cart = new WP_Ultimo\Checkout\Cart([
'customer_id' => $this->customer->get_id(),
'products' => ['test-plan']
]);

$cart->set_total(150); // Above $100 threshold

$new_total = $engine->apply_dynamic_pricing(150, $cart);

// Should have 10% discount
$this->assertEquals(135, $new_total);
}

private function create_test_plan() {
return wu_create_product([
'name' => 'Test Plan',
'type' => 'plan',
'price' => 50,
'duration' => 1,
'duration_unit' => 'month'
])->get_id();
}
}
```

This comprehensive documentation provides developers with all the tools and knowledge needed to integrate with, extend, and build upon WP Ultimo's powerful platform. The extensive API, hook system, and examples enable creation of sophisticated SaaS solutions and custom integrations.
See [Official Ultimate Multisite Documentation Site](https://ultimatemultisite.com/docs/developer)
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for other documentation files in the repository
echo "=== Documentation Files ===" 
fd -e md -e txt -e rst | grep -iE '(doc|readme|guide|api|developer)' | head -20

echo ""
echo "=== Searching for Settings API references ===" 
rg -i "settings.*api|api.*settings" --type php -A 2 | head -30

echo ""
echo "=== Checking for REST API or endpoint documentation ===" 
fd -e md -e txt | xargs grep -l -i "rest.*api\|endpoint\|settings" 2>/dev/null | head -10

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2417


🌐 Web query:

Ultimate Multisite documentation Settings REST API endpoints

💡 Result:

For Ultimate Multisite (a WordPress Multisite plugin), “Settings” REST API endpoints generally refer to WordPress core’s Site Settings endpoint (per-site), not a special Ultimate Multisite-only settings endpoint.

Site Settings (WordPress core)

  • Get site settings: GET /wp-json/wp/v2/settings  [1]
  • Update site settings: POST /wp-json/wp/v2/settings  [1]
  • Discover schema / supported methods: OPTIONS /wp-json/wp/v2/settings  [1]

Multisite note (important)

  • The REST API is site-specific in multisite: call the endpoint on the individual site’s URL (e.g., https://example.com/site1/wp-json/wp/v2/settings). WordPress does not provide “network admin” multisite endpoints by default.  [2]

How to confirm what your install exposes

  • List all available routes on that site: GET /wp-json/  [3]

Sources:
[1] WordPress REST API Handbook – Site Settings (/wp/v2/settings) (developer.wordpress.org)
[2] WordPress StackExchange – REST API with multisite (site-specific; no network endpoints by default) (wordpress.stackexchange.com)
[3] WordPress REST API Handbook – Routes and Endpoints (API index at /wp-json/) (developer.wordpress.org)

Citations:


Removing dedicated in-repository developer documentation weakens maintainability.

While the repository retains other documentation files (README.md, deprecated-apis.md, E2E-TESTING-GUIDE.md), removing the dedicated DEVELOPER-DOCUMENTATION.md and replacing it with only an external link creates concerns:

  1. No offline access to developer guides – Contributors cannot access documentation without internet connectivity.
  2. Lost version control integration – Developer documentation is no longer tracked with code changes, making it harder to keep docs in sync with specific versions.
  3. Reduced contributor context – Reviewers lack immediate in-repository reference material when examining changes.
  4. External dependency risk – The external link may become stale, moved, or out of sync with the codebase.

Consider keeping essential developer documentation in-repository alongside the external link, or consolidating developer content into README.md or a dedicated section if the external site is the source of truth.

🤖 Prompt for AI Agents
In `@DEVELOPER-DOCUMENTATION.md` around lines 1 - 3, The
DEVELOPER-DOCUMENTATION.md was removed and replaced with only an external link,
which reduces offline availability and versioned context; restore essential
in-repo developer guidance by either (a) re-adding core sections back into
DEVELOPER-DOCUMENTATION.md (setup, architecture overview, contribution
guidelines, key workflows) and keep the external link as “canonical” reference,
or (b) consolidate those essential sections into README.md under a “Developer
Documentation” header and include the external link and a note that it is the
full canonical source; ensure the file(s) reference the same headings and
examples as the external site so reviewers have immediate, versioned docs
in-repo.

Comment on lines 8917 to 8927
#: inc/class-settings.php:715
#: views/settings/widget-settings-body.php:278
#: views/settings/widget-settings-body.php:283
msgid "Help Improve Ultimate Multisite"
msgstr ""

#. translators: %s is a link to the privacy policy
#: inc/class-settings.php:718
#, php-format
msgid "Allow Ultimate Multisite to collect anonymous usage data and error reports to help us improve the plugin. We collect: PHP version, WordPress version, plugin version, network type (subdomain/subdirectory), aggregate counts (sites, memberships), active gateways, and error logs. We never collect personal data, customer information, or domain names. <a href=\"%s\" target=\"_blank\">Learn more</a>."
msgstr ""
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for tracker/telemetry related files and code
find . -type f -name "*track*" | head -20
rg -l "tracker|telemetry|usage.*data|error.*report" --type php inc/ | head -20

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 310


🏁 Script executed:

#!/bin/bash
# Search for data collection, payload, and specific tracked fields
rg -n -A 5 "payload|collect.*data|send.*data" inc/class-settings.php inc/class-tracker.php 2>/dev/null | head -100

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 4692


🏁 Script executed:

#!/bin/bash
# Look for the actual tracker class or usage tracking implementation
rg -n "class.*[Tt]racker|function.*track|wp_remote_post.*track|wp_remote_get.*track" inc/ -A 3

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1295


🏁 Script executed:

#!/bin/bash
# Search for privacy policy references and data collection disclosures in code
rg -n "privacy|personal.*data|domain.*name|collect" inc/class-settings.php -B 2 -A 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 784


🏁 Script executed:

#!/bin/bash
# Get the full get_tracking_data() function implementation
sed -n '183,350p' inc/class-tracker.php | head -200

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 4438


🏁 Script executed:

#!/bin/bash
# Check what specific fields are being collected
rg -n "php.*version|wp_version|version|network.*type|aggregate|gateways|sites|memberships" inc/class-tracker.php -A 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2968


🏁 Script executed:

#!/bin/bash
# Check the error reporting/logging collection
rg -n "get_error|error.*data|log.*data" inc/class-tracker.php -A 5

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2860


🏁 Script executed:

#!/bin/bash
# Look at send_to_api to see complete payload structure
sed -n '922,960p' inc/class-tracker.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 506


Update telemetry disclosure to match actual data collection scope.

The implementation collects significantly more data than stated in the disclosure. Beyond the disclosed fields (PHP/WordPress/plugin version, network type, aggregate site/membership counts, active gateways, error logs), the code also collects: MySQL version, server software type, timezone, locale, SSL status, active add-ons, customer/product/payment/domain counts, memory limits, and error file paths/stack traces.

The disclosure must be updated to accurately reflect all collected fields, or data collection must be restricted to match the disclosure. Additionally, error reporting should clarify what constitutes "error logs" (currently includes file paths and sanitized stack traces).

🤖 Prompt for AI Agents
In `@lang/ultimate-multisite.pot` around lines 8917 - 8927, The telemetry
disclosure string (msgid "Allow Ultimate Multisite to collect anonymous usage
data..." used in inc/class-settings.php and
views/settings/widget-settings-body.php) is incomplete; either update that
message to enumerate all actually collected fields (MySQL version, server
software, timezone, locale, SSL status, active add-ons,
customer/product/payment/domain counts, memory limits, error file paths and
sanitized stack traces, etc.) and clarify what "error logs" contains, or change
the collection logic in the code paths that assemble telemetry to remove any
fields not currently disclosed; adjust the text in the class-settings.php/views
strings to precisely match the chosen scope and ensure the error-reporting
wording explicitly states inclusion of file paths and sanitized stack traces.

- Add should_collect_payment() method to Checkout class to determine
  payment requirements earlier in the flow
- Only validate billing address when payment is actually being collected
- Apply WordPress code style fixes to hooks documentation generator

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Fix all issues with AI agents
In `@inc/checkout/class-checkout.php`:
- Around line 1958-1991: The temporary Cart created in should_collect_payment()
only passed 'products' and 'country', which can miscompute payment needs because
Cart->should_collect_payment() depends on pricing-related inputs; update the
Cart construction to pass the same pricing/session fields used elsewhere in
checkout/cart (pull via $this->request_or_session) — include discounts/coupons,
duration/interval, trial parameters, PWYW amount, recurring/price overrides and
any other pricing-related keys your checkout normally sends — so that new
Cart(...) mirrors the real checkout/cart data before calling
should_collect_payment() on it.

In `@inc/documentation/generate-berlindb-hooks.php`:
- Around line 351-545: The script currently calls
file_put_contents($output_file, $content) but always prints a success message;
modify the block around file_put_contents so you capture its return value (e.g.
$bytes = file_put_contents($output_file, $content)), check for === false, and on
failure write a clear error to STDERR (including $output_file) and exit with a
non-zero status (exit(1)); only print the "Written to ..." message to STDERR
when the write succeeded. Use the existing symbols $output_file, $content,
fwrite and STDERR to implement the check and error path.
- Around line 52-79: The find_line_number function should guard against
unreadable files and cache files per filename: change the single static $lines
to a static associative cache (e.g. $cache) keyed by $file, return 0 immediately
if !is_readable($file) before calling file(), and load file contents into
$cache[$file] = file($file) when first accessed; keep the loop and the rest of
the logic intact so existing callers (e.g. the $hook_lines pre-computation)
continue to work.
- Around line 141-194: The greedy regex in extract_schema_columns that finds the
$columns block ('/\$columns\s*=\s*\[(.+)\];/s') can over-capture; change it to a
non-greedy match so it stops at the first closing bracket by using
'/\$columns\s*=\s*\[(.+?)\];/s' (keep the s flag), leaving the rest of the
function unchanged so $m[1] still contains the intended block for parsing.

In `@lang/ultimate-multisite.pot`:
- Around line 4193-4199: Update the source string in
class-product-edit-admin-page.php that currently reads "This products will be
offered inside upgrade/downgrade forms as order bumps." to the correct plural
"These products will be offered inside upgrade/downgrade forms as order bumps."
(refer to the msgid "This products will be offered inside upgrade/downgrade
forms as order bumps." in the POT diff), then regenerate the POT so the
corrected string replaces the old msgid in lang/ultimate-multisite.pot.
- Around line 7043-7046: Update the source string in the checkout code that
generates the discount-code error: replace "The code %s do not exist or is no
longer valid." with the grammatically correct "The code %s does not exist or is
no longer valid." (the string originates from the message in
inc/checkout/class-cart.php referenced by the msgid in
lang/ultimate-multisite.pot); after changing the source string, regenerate the
POT so the updated msgid is propagated to translations.
- Around line 9019-9024: Remove the trailing space from the "Force Synchronous
Site Publication " label in the source (inc/class-settings.php where that msgid
is defined, e.g., in the Settings class or the method that builds the label) so
it becomes "Force Synchronous Site Publication" exactly, then regenerate the
POT/translation files so the corrected msgid replaces the one with the trailing
space; ensure the change is made at the original msgid location referenced in
inc/class-settings.php to avoid breaking exact msgid matching.

Comment on lines +141 to +194
function extract_schema_columns(string $file): array {

$source = file_get_contents($file);

// Extract the $columns array block.
if (! preg_match('/\$columns\s*=\s*\[(.+)\];/s', $source, $m)) {
return [];
}

$columns_block = $m[1];
$all_columns = [];
$transition_columns = [];
$seen_names = [];

// Split into individual column arrays.
// Each column is delimited by [ ... ],
preg_match_all('/\[\s*(.*?)\s*\]/s', $columns_block, $entries);

foreach ($entries[1] as $entry) {
$col = [];

// Extract name
if (preg_match("/'name'\s*=>\s*'([^']+)'/", $entry, $nm)) {
$col['name'] = $nm[1];
} else {
continue;
}

// Extract type
if (preg_match("/'type'\s*=>\s*'([^']+)'/", $entry, $tm)) {
$col['type'] = $tm[1];
} else {
$col['type'] = 'mixed';
}

// Deduplicate columns (some schemas define the same column twice).
if (isset($seen_names[ $col['name'] ])) {
continue;
}

$seen_names[ $col['name'] ] = true;
$all_columns[] = $col;

// Check for transition => true
if (preg_match("/'transition'\s*=>\s*true/", $entry)) {
$transition_columns[] = $col;
}
}

return [
'all' => $all_columns,
'transition' => $transition_columns,
];
}
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

🧩 Analysis chain

🏁 Script executed:

wc -l inc/documentation/generate-berlindb-hooks.php
sed -n '1,200p' inc/documentation/generate-berlindb-hooks.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 5940


Use a non-greedy regex for the $columns block.
The current greedy match can over-capture if other arrays follow.

Patch
-if (! preg_match('/\$columns\s*=\s*\[(.+)\];/s', $source, $m)) {
+if (! preg_match('/\$columns\s*=\s*\[(.+?)\];/s', $source, $m)) {
    return [];
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function extract_schema_columns(string $file): array {
$source = file_get_contents($file);
// Extract the $columns array block.
if (! preg_match('/\$columns\s*=\s*\[(.+)\];/s', $source, $m)) {
return [];
}
$columns_block = $m[1];
$all_columns = [];
$transition_columns = [];
$seen_names = [];
// Split into individual column arrays.
// Each column is delimited by [ ... ],
preg_match_all('/\[\s*(.*?)\s*\]/s', $columns_block, $entries);
foreach ($entries[1] as $entry) {
$col = [];
// Extract name
if (preg_match("/'name'\s*=>\s*'([^']+)'/", $entry, $nm)) {
$col['name'] = $nm[1];
} else {
continue;
}
// Extract type
if (preg_match("/'type'\s*=>\s*'([^']+)'/", $entry, $tm)) {
$col['type'] = $tm[1];
} else {
$col['type'] = 'mixed';
}
// Deduplicate columns (some schemas define the same column twice).
if (isset($seen_names[ $col['name'] ])) {
continue;
}
$seen_names[ $col['name'] ] = true;
$all_columns[] = $col;
// Check for transition => true
if (preg_match("/'transition'\s*=>\s*true/", $entry)) {
$transition_columns[] = $col;
}
}
return [
'all' => $all_columns,
'transition' => $transition_columns,
];
}
function extract_schema_columns(string $file): array {
$source = file_get_contents($file);
// Extract the $columns array block.
if (! preg_match('/\$columns\s*=\s*\[(.+?)\];/s', $source, $m)) {
return [];
}
$columns_block = $m[1];
// …rest of function…
}
🤖 Prompt for AI Agents
In `@inc/documentation/generate-berlindb-hooks.php` around lines 141 - 194, The
greedy regex in extract_schema_columns that finds the $columns block
('/\$columns\s*=\s*\[(.+)\];/s') can over-capture; change it to a non-greedy
match so it stops at the first closing bracket by using
'/\$columns\s*=\s*\[(.+?)\];/s' (keep the s flag), leaving the rest of the
function unchanged so $m[1] still contains the intended block for parsing.

Comment on lines +351 to +545
$output_file = __DIR__ . '/berlindb-dynamic-hooks.php';
$prefix = 'wu';
$src = $query_php_relative;

ob_start();

echo "<?php\n";

// Build the @see line for transition hooks.
$transition_see = $hook_lines['transition'] ? "see {$src}:{$hook_lines['transition']}" : "see {$src}";

echo <<<'HEADER'
/**
* BerlinDB Dynamic Hooks Reference
*
* AUTO-GENERATED — do not edit manually.
* Regenerate with: php inc/documentation/generate-berlindb-hooks.php
*
* This file exists solely so that @10up/wp-hooks-documentor can discover
* the dynamic hooks fired by BerlinDB's Query class for every registered
* Ultimate Multisite model. It is never loaded at runtime.
*
* @package WP_Ultimo
* @subpackage Documentation
* @since 2.3.0
* @generated
*/

defined('ABSPATH') || exit;

// phpcs:disable -- This file is never executed; it only carries docblocks.

// Variable declarations to keep static analysers and editors happy.
$old_value = null;
$new_value = null;
$item_id = 0;
$query = null;
$clauses = [];
$search_columns = [];
$search = '';
$items = [];
$item = [];
$sql = '';

HEADER;

foreach ($models as $model) {
$item = $model['item_name'];
$items = $model['item_name_plural'];
$label = humanize($item);
$labels = humanize($items);
$section = ucwords($label);

echo "\n// ─── {$section} " . str_repeat('─', max(1, 72 - strlen($section))) . "\n";

// ── Transition hooks ────────────────────────────────────────────────
foreach ($model['transition_cols'] as $col) {
$col_name = $col['name'];
$phpdoc = mysql_type_to_phpdoc($col['type']);
$hook_name = "{$prefix}_transition_{$item}_{$col_name}";
$col_human = str_replace('_', ' ', $col_name);
$see_line = $transition_see;

echo <<<HOOK

/**
* Fires when the {$col_human} of a {$label} transitions from one value to another.
*
* This hook is fired by BerlinDB when a database UPDATE changes the `{$col_name}`
* column for a {$label} row.
*
* @since 2.0.0
* @{$see_line}
*
* @param {$phpdoc} \$old_value The previous {$col_human} value.
* @param {$phpdoc} \$new_value The new {$col_human} value.
* @param int \$item_id The {$label} ID.
*/
do_action('{$hook_name}', \$old_value, \$new_value, \$item_id);

HOOK;
}

// ── Query hooks ─────────────────────────────────────────────────────

$see_pre_get = $hook_lines['pre_get'] ? "see {$src}:{$hook_lines['pre_get']}" : "see {$src}";
$see_parse_query = $hook_lines['parse_query'] ? "see {$src}:{$hook_lines['parse_query']}" : "see {$src}";
$see_query_clauses = $hook_lines['query_clauses'] ? "see {$src}:{$hook_lines['query_clauses']}" : "see {$src}";
$see_search_cols = $hook_lines['search_columns'] ? "see {$src}:{$hook_lines['search_columns']}" : "see {$src}";
$see_the_items = $hook_lines['the_items'] ? "see {$src}:{$hook_lines['the_items']}" : "see {$src}";
$see_filter_item = $hook_lines['filter_item'] ? "see {$src}:{$hook_lines['filter_item']}" : "see {$src}";
$see_found_query = $hook_lines['found_query'] ? "see {$src}:{$hook_lines['found_query']}" : "see {$src}";

echo <<<HOOK

/**
* Fires before {$labels} are fetched from the database.
*
* @since 2.0.0
* @{$see_pre_get}
*
* @param \\BerlinDB\\Database\\Query \$query The query instance (passed by reference).
*/
do_action_ref_array('{$prefix}_pre_get_{$items}', [&\$query]);

/**
* Fires after {$labels} query vars have been parsed.
*
* @since 2.0.0
* @{$see_parse_query}
*
* @param \\BerlinDB\\Database\\Query \$query The query instance (passed by reference).
*/
do_action_ref_array('{$prefix}_parse_{$items}_query', [&\$query]);

/**
* Filters the SQL clauses for a {$labels} query.
*
* @since 2.0.0
* @{$see_query_clauses}
*
* @param array \$clauses {
* Associative array of SQL clause strings.
*
* @type string \$fields The SELECT fields.
* @type string \$join The JOIN clause.
* @type string \$where The WHERE clause.
* @type string \$orderby The ORDER BY clause.
* @type string \$limits The LIMIT clause.
* @type string \$groupby The GROUP BY clause.
* }
* @param \\BerlinDB\\Database\\Query \$query The query instance (passed by reference).
* @return array
*/
\$clauses = apply_filters_ref_array('{$prefix}_{$items}_query_clauses', [\$clauses, &\$query]);

/**
* Filters the columns to search when performing a {$labels} search.
*
* @since 2.0.0
* @{$see_search_cols}
*
* @param string[] \$search_columns Array of column names to search.
* @param string \$search The search term.
* @param \\BerlinDB\\Database\\Query \$query The query instance.
* @return string[]
*/
\$search_columns = apply_filters('{$prefix}_{$items}_search_columns', \$search_columns, \$search, \$query);

/**
* Filters the found {$labels} after a query.
*
* @since 2.0.0
* @{$see_the_items}
*
* @param object[] \$items The array of found {$label} objects.
* @param \\BerlinDB\\Database\\Query \$query The query instance (passed by reference).
* @return object[]
*/
\$items = apply_filters_ref_array('{$prefix}_the_{$items}', [\$items, &\$query]);

/**
* Filters a single {$label} item before it is inserted or updated in the database.
*
* @since 2.0.0
* @{$see_filter_item}
*
* @param array \$item The item data as an associative array.
* @param \\BerlinDB\\Database\\Query \$query The query instance (passed by reference).
* @return array
*/
\$item = apply_filters_ref_array('{$prefix}_filter_{$item}_item', [\$item, &\$query]);

/**
* Filters the FOUND_ROWS() query for {$labels}.
*
* @since 2.0.0
* @{$see_found_query}
*
* @param string \$sql The SQL query to count found rows.
* @param \\BerlinDB\\Database\\Query \$query The query instance (passed by reference).
* @return string
*/
\$sql = apply_filters_ref_array('{$prefix}_found_{$items}_query', [\$sql, &\$query]);

HOOK;
}

echo "\n// phpcs:enable\n";

$content = ob_get_clean();

file_put_contents($output_file, $content);

fwrite(STDERR, "Written to {$output_file}\n");
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

🧩 Analysis chain

🏁 Script executed:

rg "file_put_contents" -n inc/documentation/generate-berlindb-hooks.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 127


Handle file_put_contents return value
file_put_contents() can return false, but the script always reports success. Check its return value, emit an error on failure, and exit with a non-zero status.

Suggested fix
 $content = ob_get_clean();
 
-file_put_contents($output_file, $content);
+$_bytes = file_put_contents($output_file, $content);
+if ($_bytes === false) {
+    fwrite(STDERR, "Failed to write {$output_file}\n");
+    exit(1);
+}
 
 fwrite(STDERR, "Written to {$output_file}\n");
🤖 Prompt for AI Agents
In `@inc/documentation/generate-berlindb-hooks.php` around lines 351 - 545, The
script currently calls file_put_contents($output_file, $content) but always
prints a success message; modify the block around file_put_contents so you
capture its return value (e.g. $bytes = file_put_contents($output_file,
$content)), check for === false, and on failure write a clear error to STDERR
(including $output_file) and exit with a non-zero status (exit(1)); only print
the "Written to ..." message to STDERR when the write succeeded. Use the
existing symbols $output_file, $content, fwrite and STDERR to implement the
check and error path.

superdav42 and others added 2 commits February 3, 2026 11:10
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
PWYW fixes:
- Add custom_amounts and pwyw_recurring to Cart in process_order()
- Add missing pricing inputs to should_collect_payment() temporary cart
- Remove v-bind:name suppression for PWYW duration fields in product edit
- Update Product::is_recurring() to check pwyw_recurring_mode
- Add wu_product_is_recurring filter for extensibility

Settings API refactor:
- Create save_setting() helper for DRY code
- Add sanitize_setting_value() using Field API for proper sanitization
- Add get_field_definition() to look up field definitions from Settings
- Remove body_params from API logging to avoid storing sensitive data

Grammar and text fixes:
- "This products" → "These products"
- "do not exist" → "does not exist"
- Remove trailing space from "Force Synchronous Site Publication"

Security improvements:
- Add rel="noopener noreferrer" and esc_url() to privacy policy link

Cleanup:
- Delete stale inc/berlindb-dynamic-hooks.php duplicate
- Fix changelog: separate 2.4.10 (released) from 2.4.11 (unreleased)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
inc/checkout/class-cart.php (1)

1698-1785: ⚠️ Potential issue | 🟡 Minor

Ensure minimum price is enforced even when no custom amount is provided.
If the suggested amount is below the minimum, the cart currently allows under‑minimum pricing. Clamp to the minimum (or error) to keep the rule consistent.

🛠️ Proposed fix
-			} else {
-				// Use suggested amount as default
-				$amount = $product->get_pwyw_suggested_amount();
-			}
+			} else {
+				// Use suggested amount as default, but never below minimum
+				$amount = max(
+					$product->get_pwyw_suggested_amount(),
+					$product->get_pwyw_minimum_amount()
+				);
+			}
🧹 Nitpick comments (2)
inc/admin-pages/class-product-edit-admin-page.php (1)

333-356: Consider adding validation that suggested price ≥ minimum price.

The PWYW amount fields look good, but there's no client-side validation to ensure pwyw_suggested_amount is greater than or equal to pwyw_minimum_amount. An admin could accidentally set a suggested price below the minimum, which would confuse customers at checkout.

You could add a Vue-based warning or validation in the wrapper attributes, or handle this server-side in handle_save().

inc/apis/class-settings-endpoint.php (1)

483-499: Avoid unused loop variable to satisfy PHPMD.
Use array_keys() (or a named variable) to remove the unused $_.

🛠️ Proposed fix
-		foreach ($settings as $key => $_) {
+		foreach (array_keys($settings) as $key) {
 			if ($this->is_sensitive_setting($key)) {
 				unset($settings[ $key ]);
 			}
 		}

@github-actions
Copy link

github-actions bot commented Feb 3, 2026

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

@github-actions
Copy link

github-actions bot commented Feb 3, 2026

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@assets/js/checkout.js`:
- Around line 460-515: In create_order(), the try/catch wrapping a commented-out
history.pushState is unreachable; either re-enable the pushState call so the
catch can meaningfully handle failures or remove the entire try/catch block (and
the commented pushState) to satisfy the linter; update the wu_on_create_order
flow accordingly and ensure no leftover commented history.pushState remains in
the create_order method.

In `@inc/checkout/class-checkout.php`:
- Around line 1961-1999: The temporary Cart built in should_collect_payment()
omits state, city, membership_id, payment_id, auto_renew and cart_type which can
change tax, proration and recovery logic; update the Cart instantiation in
should_collect_payment() to pass these values from request_or_session (e.g.
'state' => $this->request_or_session('billing_state'), 'city' =>
$this->request_or_session('billing_city'), 'membership_id' =>
$this->request_or_session('membership_id'), 'payment_id' =>
$this->request_or_session('payment_id'), 'auto_renew' =>
$this->request_or_session('auto_renew', true), 'cart_type' =>
$this->request_or_session('cart_type', 'new')) so Cart->get_total() and
Cart->should_collect_payment() reflect real checkout state; alternatively expose
a filter in should_collect_payment() to allow callers to inject/modify the cart
payload before creating the Cart.

Comment on lines +460 to +515
create_order() {

/*
* Bail if there is no order summary to update.
*/
if (!jQuery('#wu-order-summary-content').length) {
if (! jQuery('#wu-order-summary-content').length) {

return;
return;

} // end if;
} // end if;

this.block();
this.block();

this.order = false;
this.order = false;

const that = this;
const that = this;

const _request = this.debounce(this.request);
const _request = this.debounce(this.request);

const data = { ...this.$data };
const data = { ...this.$data };

delete data.stored_templates;
delete data.state_list;
delete data.city_list;
delete data.labels;
delete data.stored_templates;
delete data.state_list;
delete data.city_list;
delete data.labels;

_request('wu_create_order', this.filter_for_request(data, 'wu_create_order'), function (results) {
_request('wu_create_order', this.filter_for_request(data, 'wu_create_order'), function (results) {

that.order = results.data.order;
that.order = results.data.order;

that.state_list = results.data.states;
that.state_list = results.data.states;

that.city_list = results.data.cities;
that.city_list = results.data.cities;

that.labels = results.data.labels;
that.labels = results.data.labels;

that.cart_type = results.data.order.type;
that.cart_type = results.data.order.type;

that.errors = results.data.order.errors;
that.errors = results.data.order.errors;

that.hooks.doAction('wu_on_create_order', that, results.data);
that.hooks.doAction('wu_on_create_order', that, results.data);

if (results.data.order.url) {
if (results.data.order.url) {

try {
try {

// history.pushState({}, null, wu_checkout.baseurl + results.data.order.url);
// history.pushState({}, null, wu_checkout.baseurl + results.data.order.url);

} catch (err) {
} catch (err) {

// eslint-disable-next-line no-console
console.warn('Browser does not support pushState.', err);
// eslint-disable-next-line no-console
console.warn('Browser does not support pushState.', err);

} // end try;
} // end try;

} // ed if;
} // ed if;
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

Remove unreachable try/catch block.

The try block is empty (pushState is commented), so the catch is unreachable per lint. Either re-enable pushState or remove the block to clear the error.

🧹 Minimal cleanup
-						if (results.data.order.url) {
-
-							try {
-
-								// history.pushState({}, null, wu_checkout.baseurl + results.data.order.url);
-
-							} catch (err) {
-
-								// eslint-disable-next-line no-console
-								console.warn('Browser does not support pushState.', err);
-
-							} // end try;
-
-						} // ed if;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
create_order() {
/*
* Bail if there is no order summary to update.
*/
if (!jQuery('#wu-order-summary-content').length) {
if (! jQuery('#wu-order-summary-content').length) {
return;
return;
} // end if;
} // end if;
this.block();
this.block();
this.order = false;
this.order = false;
const that = this;
const that = this;
const _request = this.debounce(this.request);
const _request = this.debounce(this.request);
const data = { ...this.$data };
const data = { ...this.$data };
delete data.stored_templates;
delete data.state_list;
delete data.city_list;
delete data.labels;
delete data.stored_templates;
delete data.state_list;
delete data.city_list;
delete data.labels;
_request('wu_create_order', this.filter_for_request(data, 'wu_create_order'), function (results) {
_request('wu_create_order', this.filter_for_request(data, 'wu_create_order'), function (results) {
that.order = results.data.order;
that.order = results.data.order;
that.state_list = results.data.states;
that.state_list = results.data.states;
that.city_list = results.data.cities;
that.city_list = results.data.cities;
that.labels = results.data.labels;
that.labels = results.data.labels;
that.cart_type = results.data.order.type;
that.cart_type = results.data.order.type;
that.errors = results.data.order.errors;
that.errors = results.data.order.errors;
that.hooks.doAction('wu_on_create_order', that, results.data);
that.hooks.doAction('wu_on_create_order', that, results.data);
if (results.data.order.url) {
if (results.data.order.url) {
try {
try {
// history.pushState({}, null, wu_checkout.baseurl + results.data.order.url);
// history.pushState({}, null, wu_checkout.baseurl + results.data.order.url);
} catch (err) {
} catch (err) {
// eslint-disable-next-line no-console
console.warn('Browser does not support pushState.', err);
// eslint-disable-next-line no-console
console.warn('Browser does not support pushState.', err);
} // end try;
} // end try;
} // ed if;
} // ed if;
create_order() {
/*
* Bail if there is no order summary to update.
*/
if (! jQuery('#wu-order-summary-content').length) {
return;
} // end if;
this.block();
this.order = false;
const that = this;
const _request = this.debounce(this.request);
const data = { ...this.$data };
delete data.stored_templates;
delete data.state_list;
delete data.city_list;
delete data.labels;
_request('wu_create_order', this.filter_for_request(data, 'wu_create_order'), function (results) {
that.order = results.data.order;
that.state_list = results.data.states;
that.city_list = results.data.cities;
that.labels = results.data.labels;
that.cart_type = results.data.order.type;
that.errors = results.data.order.errors;
that.hooks.doAction('wu_on_create_order', that, results.data);
🧰 Tools
🪛 Biome (2.3.13)

[error] 508-513: This code is unreachable

(lint/correctness/noUnreachable)

🤖 Prompt for AI Agents
In `@assets/js/checkout.js` around lines 460 - 515, In create_order(), the
try/catch wrapping a commented-out history.pushState is unreachable; either
re-enable the pushState call so the catch can meaningfully handle failures or
remove the entire try/catch block (and the commented pushState) to satisfy the
linter; update the wu_on_create_order flow accordingly and ensure no leftover
commented history.pushState remains in the create_order method.

Comment on lines +1961 to +1999
/**
* Determines whether payment should be collected for the current checkout.
*
* Uses $this->order if available, otherwise builds a temporary Cart
* from the request/session data to check.
*
* @since 2.0.20
* @return bool
*/
public function should_collect_payment(): bool {

if ($this->order) {
return $this->order->should_collect_payment();
}

$products = $this->request_or_session('products', []);

if (empty($products)) {
return true;
}

try {
$cart = new Cart(
[
'products' => (array) $products,
'country' => $this->request_or_session('billing_country'),
'discount_code' => $this->request_or_session('discount_code'),
'duration' => $this->request_or_session('duration'),
'duration_unit' => $this->request_or_session('duration_unit'),
'custom_amounts' => $this->request_or_session('custom_amounts', []),
'pwyw_recurring' => $this->request_or_session('pwyw_recurring', []),
]
);

return $cart->should_collect_payment();
} catch (\Throwable $e) {
return true;
}
}
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, locate the Cart class
fd -t f "class-cart.php|Cart.php" inc/

# Check Cart's constructor signature
rg -n "public function __construct" inc/checkout/class-cart.php -A 20

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 669


🏁 Script executed:

#!/bin/bash
# Read the full Cart constructor to see all parameters
sed -n '263,400p' inc/checkout/class-cart.php | cat -n

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 4457


🏁 Script executed:

#!/bin/bash
# Search for tax logic using state/city
rg -n -C5 "state|city" inc/checkout/class-cart.php | head -100

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2932


🏁 Script executed:

#!/bin/bash
# Find the should_collect_payment method in Cart
rg -n "should_collect_payment" inc/checkout/class-cart.php -A 30

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2173


🏁 Script executed:

#!/bin/bash
# Check if is_free() and get_recurring_total() depend on taxes
rg -n "function is_free|function get_recurring_total" inc/checkout/class-cart.php -A 15

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 958


🏁 Script executed:

#!/bin/bash
# Check if get_total() includes taxes
rg -n "function get_total\(" inc/checkout/class-cart.php -A 20 | head -50

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 678


🏁 Script executed:

#!/bin/bash
# Check if line_item get_total includes taxes
rg -n "class.*LineItem" inc/checkout/ -A 3

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

#!/bin/bash
# Search for LineItem class
fd -t f "line" inc/checkout/ -type f
rg -n "get_total\(\)" inc/checkout/class-cart.php | head -20

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 813


🏁 Script executed:

#!/bin/bash
# Find where line_items are created and check their structure
rg -n "line_item.*get_total|->get_total" inc/checkout/class-cart.php -B 3 | head -50

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1469


🏁 Script executed:

#!/bin/bash
# Search for where line_items array is built to understand the object type
rg -n "\$this->line_items\s*=|->line_items\[\]" inc/checkout/class-cart.php -B 2 -A 2 | head -80

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

#!/bin/bash
# Look for line_items initialization and use
rg -n "line_items" inc/checkout/class-cart.php | head -30

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1561


🏁 Script executed:

#!/bin/bash
# Search for where taxes are added to line items
rg -n "tax|->get_total" inc/checkout/class-cart.php | grep -i "tax\|recalculate"

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2588


🏁 Script executed:

#!/bin/bash
# Check if membership_id, payment_id, auto_renew, cart_type affect the build_cart or should_collect_payment logic
rg -n "membership_id|payment_id|auto_renew|cart_type" inc/checkout/class-cart.php | head -40

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1970


🏁 Script executed:

#!/bin/bash
# Check what wu_get_applicable_tax_rates returns with empty state/city
rg -n "wu_get_applicable_tax_rates" --type-list 2>/dev/null || rg -rn "function wu_get_applicable_tax_rates" .

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 172


🏁 Script executed:

#!/bin/bash
# Get the full implementation of wu_get_applicable_tax_rates
rg -n "function wu_get_applicable_tax_rates" inc/functions/tax.php -A 30

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 886


🏁 Script executed:

#!/bin/bash
# Verify what happens when state/city are empty strings in wu_get_applicable_tax_rates
sed -n '130,180p' inc/functions/tax.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1170


State/city omission will miscalculate payment collection.

The temporary Cart constructed in should_collect_payment() omits state, city, membership_id, payment_id, auto_renew, and cart_type. These parameters directly affect cart calculations:

  • Tax rates are determined by wu_get_applicable_tax_rates($country, $state, $city) (Cart line 2490). Omitting state/city causes them to default to empty strings, bypassing state/city-specific tax rules.
  • Cart type determines setup fee and proration logic (Cart lines 1275, 2306, 2347). Defaulting to 'new' skips upgrade/downgrade calculations.
  • membership_id/payment_id trigger recovery and upgrade workflows. Omitting these builds a fresh cart instead.
  • auto_renew affects cart setup. Defaulting to true ignores the actual customer preference.

Since should_collect_payment() depends on get_total() which includes calculated taxes, the decision to collect payment will be incorrect when taxes or prorations vary by location or cart type.

Pass the actual values from the request/session to align with real checkout state, or apply a filter to allow customization.

🤖 Prompt for AI Agents
In `@inc/checkout/class-checkout.php` around lines 1961 - 1999, The temporary Cart
built in should_collect_payment() omits state, city, membership_id, payment_id,
auto_renew and cart_type which can change tax, proration and recovery logic;
update the Cart instantiation in should_collect_payment() to pass these values
from request_or_session (e.g. 'state' =>
$this->request_or_session('billing_state'), 'city' =>
$this->request_or_session('billing_city'), 'membership_id' =>
$this->request_or_session('membership_id'), 'payment_id' =>
$this->request_or_session('payment_id'), 'auto_renew' =>
$this->request_or_session('auto_renew', true), 'cart_type' =>
$this->request_or_session('cart_type', 'new')) so Cart->get_total() and
Cart->should_collect_payment() reflect real checkout state; alternatively expose
a filter in should_collect_payment() to allow callers to inject/modify the cart
payload before creating the Cart.

Replace fragile UI-based wizard setup with WP-CLI/PHP fixture approach:
- Add PHP fixtures for DB tables, products, checkout form, trial products,
  and verification scripts
- Create 000-setup, 010-manual-checkout-flow, 020-free-trial-flow specs
- Fix wpCli command (npx wp-env instead of npm run env) and add wpCliFile
- Remove broken checkout commands and old specs that used vague selectors
- Remove domain-mapping-roles and plugin specs (incompatible with wp-env)
- Fix wizard.spec.js: handle variable button text on installer steps,
  add missing recommended-plugins step, reset custom login page on setup
- Remove WooCommerce addon from core .wp-env.json test plugins
- Add env:fix-perms script for Docker permission issues

All 15 tests across 7 specs pass reliably on consecutive runs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@github-actions
Copy link

github-actions bot commented Feb 3, 2026

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🤖 Fix all issues with AI agents
In `@tests/e2e/cypress/fixtures/setup-checkout-form.php`:
- Line 6: Replace the loose text search using
WP_Ultimo\Models\Checkout_Form::query([ 'search' => 'main-form', ... ]) with the
exact-slug helper wu_get_checkout_form_by_slug('main-form') to fetch the form by
slug, and when creating a form check the return of wp_insert_post for WP_Error
(and falsy values) before proceeding—if wp_insert_post returns a WP_Error or
false, handle/log/return the error similar to the existing form creation check
so failures are detected and handled.

In `@tests/e2e/cypress/fixtures/setup-tables.php`:
- Line 13: The call to update_network_option currently has a prohibited space
before the opening parenthesis; remove the space so the function name is
immediately followed by the opening parenthesis (i.e., change
update_network_option( null, WP_Ultimo::NETWORK_OPTION_SETUP_FINISHED, time() )
to use no space between update_network_option and the '('), and ensure arguments
remain the same (null, WP_Ultimo::NETWORK_OPTION_SETUP_FINISHED, time()) so
static analysis passes.

In `@tests/e2e/cypress/fixtures/setup-trial-product.php`:
- Around line 7-10: The calls to the product setter methods (product->set_name,
product->set_slug, product->set_amount, product->set_duration) contain
prohibited spaces inside the parentheses; remove the extra space after the
opening parenthesis and before the closing parenthesis for each call so they
read like set_name('Trial Plan'), set_slug('trial-plan'), set_amount(19.99),
set_duration(1) to satisfy the code style/analysis rules.

In `@tests/e2e/cypress/fixtures/verify-manual-checkout-results.php`:
- Around line 22-29: Replace the call to json_encode(...) with
wp_json_encode(...) in the echo statement that outputs the array of
$um_payment_status, $um_payment_gateway, $um_payment_total,
$um_membership_status, $um_site_count, and $um_site_type; ensure the array
structure is unchanged and only the encoder function name (json_encode ->
wp_json_encode) is updated so the fixture uses WordPress' JSON encoder.

In `@tests/e2e/cypress/fixtures/verify-trial-results.php`:
- Around line 20-25: Replace the direct use of json_encode() with WordPress'
wp_json_encode() in the code that outputs the trial verification array (the echo
json_encode([...]) call); update the echo statement to call wp_json_encode() so
it uses WP's JSON encoding and error handling while keeping the same array keys
('um_payment_status', 'um_membership_status', 'um_membership_trial_end',
'um_site_type').

In `@tests/e2e/cypress/integration/000-setup.spec.js`:
- Around line 54-64: Add a readback assertion for the 'force_publish_sites_sync'
setting in the test so both settings changed by the cy.wpCli call are verified:
after calling cy.wpCli with wu_save_setting('enable_email_verification', ...)
and wu_save_setting('force_publish_sites_sync', true) add another cy.wpCli
invocation that runs wu_get_setting('force_publish_sites_sync') and assert the
result stdout contains "1" or "true" (whichever the app returns) to confirm the
change; reference the existing cy.wpCli helper and the
wu_get_setting/wu_save_setting symbols to locate where to add the check in the
"Should disable email verification and enable sync site publish" test.
- Around line 24-31: The test currently only logs the wpCliFile output and
doesn't assert success; update the "Should create a test product/plan" test to
assert the command succeeded by checking result.code (or result.stdout) contains
a valid product ID and that productId is non-empty; locate the cy.wpCliFile(...)
call in the it("Should create a test product/plan", ...) block and after
extracting productId (const productId = result.stdout.trim()) add an assertion
(e.g., expect(result.code).to.equal(0) or expect(productId).to.match(/\d+/) /
expect(productId).to.not.be.empty) so failures fail the spec instead of only
being logged.

In `@tests/e2e/cypress/integration/020-free-trial-flow.spec.js`:
- Around line 13-31: The test currently calls
cy.wpCliFile("tests/e2e/cypress/fixtures/setup-trial-product.php", {
failOnNonZeroExit: false }) and ignores the result, which can hide failures;
update the then handler for cy.wpCliFile to assert the command succeeded and
returned a non-empty numeric productId (check result.code === 0 or result.stderr
is empty and result.stdout.trim() is a positive integer) and fail the test early
if not—use the existing productId variable name in the assertion and log to
preserve behavior.

In `@tests/e2e/cypress/support/commands/index.js`:
- Around line 16-22: The wpCliFile helper currently interpolates filePath into
the shell command unsafely; sanitize and properly quote it before calling
cy.exec: in wpCliFile, validate filePath (reject empty, absolute paths starting
with '/' or '\\', any segment of '..', or any characters like
backticks/newlines/quotes), normalize it (e.g., use path.posix.normalize and
ensure the normalized path does not climb above the plugin directory), then
construct containerPath from the sanitized relative path and pass a safely
quoted containerPath to cy.exec (escape any single quotes if you use
single-quote wrapping) so the shell invocation in the cy.exec call cannot be
broken or used for path traversal or command injection.
🧹 Nitpick comments (9)
tests/e2e/cypress/support/commands/index.js (2)

5-10: Return the cy.exec chainable from wpCli.
Line 5: without a return, callers can’t reliably chain or assert on the exec result.

Proposed fix
Cypress.Commands.add("wpCli", (command, options = {}) => {
-  cy.exec(`npx wp-env run tests-cli wp ${command}`, {
+  return cy.exec(`npx wp-env run tests-cli wp ${command}`, {
     ...options,
     timeout: options.timeout || 60000,
   });
});

25-37: Return the cy.request chainable from loginByApi.
Line 25: returning enables chaining and explicit waiting/inspection of the response.

Proposed fix
Cypress.Commands.add("loginByApi", (username, password) => {
-  cy.request({
+  return cy.request({
     method: "POST",
     url: "/wp-login.php",
     form: true,
     body: {
       log: username,
       pwd: password,
       "wp-submit": "Log In",
       redirect_to: "/wp-admin/",
       testcookie: 1,
     },
   });
});
package.json (1)

77-78: Pin the Alpine image tag used in env:fix-perms.

Line 77 uses alpine (latest), which is non-deterministic and can change behavior over time. Pin to a specific stable release branch such as alpine:3.22 or better yet a patch tag like alpine:3.22.3 (or image digest) to keep env:start (Line 78) reproducible.

tests/e2e/cypress/fixtures/setup-product.php (1)

13-15: Consider adding error handling for save() failure.

The save() method can return a WP_Error on failure (per the docstring in the relevant code snippets). If save fails, get_id() may return null/0, potentially causing silent test failures.

Proposed fix
-$product->save();
+$result = $product->save();
+
+if ( is_wp_error( $result ) ) {
+	echo 'error:' . $result->get_error_message();
+	return;
+}

 echo $product->get_id();
tests/e2e/cypress/fixtures/setup-trial-product.php (1)

16-18: Consider adding error handling for save() failure.

Similar to setup-product.php, if save() fails, get_id() may return null, leading to an invalid product ID being appended to the pricing table.

Proposed fix
-$product->save();
-
-$product_id = $product->get_id();
+$result = $product->save();
+
+if ( is_wp_error( $result ) ) {
+	echo 'error:' . $result->get_error_message();
+	return;
+}
+
+$product_id = $product->get_id();
tests/e2e/cypress/fixtures/setup-checkout-form.php (1)

31-40: Consider checking wp_insert_post return value for errors.

wp_insert_post can return a WP_Error on failure. Without checking, a failed page insertion would save an error object as the default_registration_page setting.

Proposed fix
 $page_id = wp_insert_post(
 	[
 		'post_name'    => 'register',
 		'post_title'   => 'Register',
 		'post_content' => '[wu_checkout slug="main-form"]',
 		'post_status'  => 'publish',
 		'post_type'    => 'page',
 		'post_author'  => 1,
 	]
 );

+if ( is_wp_error( $page_id ) ) {
+	echo 'error:' . $page_id->get_error_message();
+	return;
+}
+
 wu_save_setting( 'default_registration_page', $page_id );
tests/e2e/cypress/integration/wizard.spec.js (1)

77-80: Consider adding an explicit URL assertion before clicking.

The cy.url({ timeout: 120000 }) on line 77 validates navigation, but for consistency with other steps, consider adding cy.assertPageUrl() to verify the full page context before interacting with the button.

Proposed enhancement
     /**
      * Steps: Recommended Plugins
      * May download plugins from wordpress.org via AJAX; allow extra time.
      */
     cy.url({ timeout: 120000 }).should("include", "step=recommended-plugins");
+    cy.assertPageUrl({
+      pathname: "/wp-admin/network/admin.php",
+      page: page_name,
+      step: "recommended-plugins",
+    });
     cy.get('button[data-testid="button-primary"]')
       .should("not.be.disabled")
       .click();
tests/e2e/cypress/integration/010-manual-checkout-flow.spec.js (2)

1-11: Optional: share test-data helpers to reduce duplication.

This same timestamp-based customer/site data pattern appears in the free-trial spec; a small helper would centralize future edits.


13-83: Make this spec self-sufficient instead of relying on earlier setup specs.

This flow implicitly depends on the checkout form and manual gateway being preconfigured (likely by 000-setup). If this spec runs alone or in parallel, it can fail unexpectedly. Consider adding a local before() that ensures the checkout form exists and the manual gateway is enabled (or at least verifies those preconditions).

* Create a checkout form and registration page for e2e testing.
* Idempotent: skips creation if the form already exists.
*/
$existing = WP_Ultimo\Models\Checkout_Form::query( [ 'search' => 'main-form', 'number' => 1 ] );
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's explore the repository structure
find . -type f -name "*.php" | grep -i checkout_form | head -20

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 128


🏁 Script executed:

# Look for the Checkout_Form model
find . -type f -path "*Models*" -name "*.php" | grep -i checkout

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 128


🏁 Script executed:

# Check the structure of inc/database directory
ls -la inc/database/ 2>/dev/null || echo "Directory not found, searching..."
find . -type d -name "*checkout*" | head -10

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1142


🏁 Script executed:

# Find the setup-checkout-form.php file mentioned in the review
find . -type f -name "setup-checkout-form.php"

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 133


🏁 Script executed:

# Let's examine the setup-checkout-form.php file
cat -n ./tests/e2e/cypress/fixtures/setup-checkout-form.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1438


🏁 Script executed:

# Now let's look at the checkout-forms database directory
ls -la ./inc/database/checkout-forms/

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 523


🏁 Script executed:

# Search for the Checkout_Form model class
find . -type f -name "*.php" -path "*Models*" | xargs grep -l "class Checkout_Form"

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 128


🏁 Script executed:

# Find the Checkout_Form model class
find . -type f -name "*.php" | xargs grep -l "class Checkout_Form" | grep -v Test

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 582


🏁 Script executed:

# Examine the Checkout_Form query class
cat -n ./inc/database/checkout-forms/class-checkout-form-query.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2551


🏁 Script executed:

# Let's also check the schema to understand the structure
cat -n ./inc/database/checkout-forms/class-checkout-forms-schema.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2856


🏁 Script executed:

# Now let's look at the Checkout_Form model
cat -n ./inc/models/class-checkout-form.php | head -100

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2310


🏁 Script executed:

# Let's find the Query base class to understand what parameters are supported
find . -type f -path "*Database/Engine*" -name "*.php" | head -10

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

# Search for the Query class definition
find . -type f -name "*.php" -path "*Engine*" | xargs grep -l "class Query"

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 65


🏁 Script executed:

# Search for the Query engine class
find . -type f -name "*.php" | xargs grep -l "class Query" | grep -i engine

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 118


🏁 Script executed:

# Let's check the engine directory structure
ls -la ./inc/database/engine/

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 646


🏁 Script executed:

# Look for query parameter handling - search for how 'search' parameter is handled
rg "search" ./inc/database/engine/ -A 3 -B 3

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

# Let's examine the Query base class
cat -n ./inc/database/engine/class-query.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 3525


🏁 Script executed:

# Search for how 'search' parameter is handled in the entire database engine
rg "search" ./inc/database/engine/*.php -A 2 -B 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

# Let's search for query parameter handling more broadly
rg "'search'" . --type php -A 2 -B 2 | head -50

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2542


🏁 Script executed:

# Let's search for how other models query by slug
rg "slug" ./inc/models/ --type php -B 2 -A 2 | head -50

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2774


🏁 Script executed:

# Search for other usage of query() method with different parameters
rg "::query\(" . --type php -A 1 | grep -E "(slug|search)" | head -20

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 230


🏁 Script executed:

# Let's check if wp_insert_post error handling is addressed elsewhere in the codebase
rg "wp_insert_post" . --type php -A 3 | head -60

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 4579


🏁 Script executed:

# Let's check if we can query by slug directly - look for other query examples in tests
rg "::query\(" ./tests --type php -A 2 | head -40

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2937


🏁 Script executed:

# Let's search for BerlinDB documentation or see how direct field queries work
rg "->query" ./inc --type php -A 1 | grep -E "slug|name|id" -A 1 | head -30

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 485


🏁 Script executed:

# Check the specific test file for Checkout_Form to see how queries are tested
cat -n ./tests/WP_Ultimo/Models/Checkout_Form_Test.php | head -100

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 3660


🏁 Script executed:

# Let's check how BerlinDB or the Query class handles direct field parameters
# Search for examples of direct field queries in the codebase
rg "query\(\s*\[\s*'" ./inc --type php -A 2 | head -60

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

# Let's look for wp_error checking patterns in the codebase for wp_insert_post
rg "is_wp_error.*wp_insert_post\|wp_insert_post.*is_wp_error" . --type php -B 2 -A 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

# Let's verify if direct field queries are used elsewhere
rg "query\(\s*\[\s*'(name|slug|id)'" ./inc --type php -A 1

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

# Let's check the parent class BerlinDB more carefully - look for how query parameters are handled
rg "search.*searchable\|searchable.*search" ./inc/database/engine/ -A 2 -B 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

# Look at the schema to understand which columns are searchable
rg "searchable" ./inc/database/checkout-forms/ -A 2 -B 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 964


🏁 Script executed:

# Let's check if BerlinDB documentation or comments mention parameter handling
rg "search\|slug" ./inc/database/checkout-forms/class-checkout-form-query.php -B 3 -A 3

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

# Search for how query methods handle field-specific parameters in other models
rg "query.*slug\|slug.*query" ./inc/functions/ --type php -B 2 -A 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

# Let's look at how wp_insert_post results are used in the file
cat -n ./tests/e2e/cypress/fixtures/setup-checkout-form.php | tail -20

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 636


🏁 Script executed:

# Search for other E2E fixtures that use wp_insert_post to see if they check for errors
rg "wp_insert_post" ./tests/e2e/cypress/fixtures/ -A 4 -B 1 | head -50

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 547


🏁 Script executed:

# Let's check if there's any documentation or comments in BerlinDB integration
find . -type f -name "*.php" | xargs grep -l "BerlinDB" | head -5

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 270


🏁 Script executed:

# Let's check if direct field matching with slug works by looking at BerlinDB usage
rg "column_defaults\|get_column" ./inc/database/engine/ -A 5 -B 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

# Let's understand how BerlinDB supports field-specific queries
# Check if there are examples of __in or direct field parameters
rg "'[a-z_]+__in'" ./inc --type php -A 1 | head -30

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1493


🏁 Script executed:

# Let's also check if the concern about search matching multiple fields is valid
# by testing a real scenario - see if there are multiple checkoutforms with similar names
rg "Checkout_Form" ./inc/functions/ --type php -A 5 -B 2 | head -40

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2210


🏁 Script executed:

# Let's look at how BerlinDB query actually works by checking documentation in code comments
cat -n ./inc/database/engine/class-query.php | grep -A 20 "public function\|@param\|@return"

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1744


🏁 Script executed:

# I found a reference to wu_get_checkout_form_by_slug! Let's see the implementation
cat -n ./inc/functions/checkout-form.php | grep -A 10 "wu_get_checkout_form_by_slug"

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 779


🏁 Script executed:

# Let's see the complete checkout-form.php file
cat -n ./inc/functions/checkout-form.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 6703


🏁 Script executed:

# Check if is_wp_error is used anywhere in E2E fixtures
rg "is_wp_error" ./tests/e2e/cypress/fixtures/ -A 2 -B 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 477


Use dedicated slug lookup function and add error handling for wp_insert_post.

The 'search' => 'main-form' performs a text search which may match unintended forms. Use the existing wu_get_checkout_form_by_slug('main-form') function for exact slug matching. Additionally, wp_insert_post() can return a WP_Error on failure but the code doesn't check for this; add error handling similar to the form creation check above.

Proposed fix
-$existing = WP_Ultimo\Models\Checkout_Form::query( [ 'search' => 'main-form', 'number' => 1 ] );
+$existing = wu_get_checkout_form_by_slug( 'main-form' );

-if ( $existing ) {
-	$form    = $existing[0];
+if ( $existing ) {
+	$form    = $existing;
	$page_id = wu_get_setting( 'default_registration_page', 0 );
	echo 'form:' . $form->get_id() . ',page:' . $page_id;
	return;
}

 $page_id = wp_insert_post( [...] );
+
+if ( is_wp_error( $page_id ) ) {
+	echo 'error:' . $page_id->get_error_message();
+	return;
+}
🤖 Prompt for AI Agents
In `@tests/e2e/cypress/fixtures/setup-checkout-form.php` at line 6, Replace the
loose text search using WP_Ultimo\Models\Checkout_Form::query([ 'search' =>
'main-form', ... ]) with the exact-slug helper
wu_get_checkout_form_by_slug('main-form') to fetch the form by slug, and when
creating a form check the return of wp_insert_post for WP_Error (and falsy
values) before proceeding—if wp_insert_post returns a WP_Error or false,
handle/log/return the error similar to the existing form creation check so
failures are detected and handled.

Comment on lines +24 to +31
it("Should create a test product/plan", () => {
cy.wpCliFile("tests/e2e/cypress/fixtures/setup-product.php", {
failOnNonZeroExit: false,
}).then((result) => {
const productId = result.stdout.trim();
cy.log(`Created test product with ID: ${productId}`);
});
});
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 product creation success.

The fixture result is only logged; add a quick check to catch failures early.

✅ Suggested guard
  cy.wpCliFile("tests/e2e/cypress/fixtures/setup-product.php", {
    failOnNonZeroExit: false,
  }).then((result) => {
    const productId = result.stdout.trim();
+   expect(productId, "test product id").to.not.equal("");
    cy.log(`Created test product with ID: ${productId}`);
  });
🤖 Prompt for AI Agents
In `@tests/e2e/cypress/integration/000-setup.spec.js` around lines 24 - 31, The
test currently only logs the wpCliFile output and doesn't assert success; update
the "Should create a test product/plan" test to assert the command succeeded by
checking result.code (or result.stdout) contains a valid product ID and that
productId is non-empty; locate the cy.wpCliFile(...) call in the it("Should
create a test product/plan", ...) block and after extracting productId (const
productId = result.stdout.trim()) add an assertion (e.g.,
expect(result.code).to.equal(0) or expect(productId).to.match(/\d+/) /
expect(productId).to.not.be.empty) so failures fail the spec instead of only
being logged.

Comment on lines +54 to +64
it("Should disable email verification and enable sync site publish", () => {
cy.wpCli(
"eval \"wu_save_setting('enable_email_verification', 'never'); wu_save_setting('force_publish_sites_sync', true);\""
);

cy.wpCli(
"eval \"echo wu_get_setting('enable_email_verification', 'always');\""
).then((result) => {
expect(result.stdout).to.contain("never");
});
});
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

Also verify force_publish_sites_sync is actually set.

You set two settings but only assert one; add a quick readback for force_publish_sites_sync to avoid silent regressions.

✅ Suggested check
  cy.wpCli(
    "eval \"wu_save_setting('enable_email_verification', 'never'); wu_save_setting('force_publish_sites_sync', true);\""
  );

  cy.wpCli(
    "eval \"echo wu_get_setting('enable_email_verification', 'always');\""
  ).then((result) => {
    expect(result.stdout).to.contain("never");
  });
+
+ cy.wpCli(
+   "eval \"echo wu_get_setting('force_publish_sites_sync', false) ? 'true' : 'false';\""
+ ).then((result) => {
+   expect(result.stdout).to.contain("true");
+ });
🤖 Prompt for AI Agents
In `@tests/e2e/cypress/integration/000-setup.spec.js` around lines 54 - 64, Add a
readback assertion for the 'force_publish_sites_sync' setting in the test so
both settings changed by the cy.wpCli call are verified: after calling cy.wpCli
with wu_save_setting('enable_email_verification', ...) and
wu_save_setting('force_publish_sites_sync', true) add another cy.wpCli
invocation that runs wu_get_setting('force_publish_sites_sync') and assert the
result stdout contains "1" or "true" (whichever the app returns) to confirm the
change; reference the existing cy.wpCli helper and the
wu_get_setting/wu_save_setting symbols to locate where to add the check in the
"Should disable email verification and enable sync site publish" test.

Comment on lines +13 to +31
before(() => {
cy.loginByForm(
Cypress.env("admin").username,
Cypress.env("admin").password
);

// Enable trial without payment method
cy.wpCli(
"eval \"wu_save_setting('allow_trial_without_payment_method', true);\""
);

// Create the trial product
cy.wpCliFile("tests/e2e/cypress/fixtures/setup-trial-product.php", {
failOnNonZeroExit: false,
}).then((result) => {
const productId = result.stdout.trim();
cy.log(`Created trial product with ID: ${productId}`);
});
});
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

Fail fast if trial product creation fails.

With failOnNonZeroExit: false and no stdout check, a fixture failure can be silently ignored, leading to flaky downstream errors. Add a simple assertion on the returned ID (or stderr) to surface failures early.

✅ Suggested guard
  cy.wpCliFile("tests/e2e/cypress/fixtures/setup-trial-product.php", {
    failOnNonZeroExit: false,
  }).then((result) => {
    const productId = result.stdout.trim();
+   expect(productId, "trial product id").to.not.equal("");
    cy.log(`Created trial product with ID: ${productId}`);
  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
before(() => {
cy.loginByForm(
Cypress.env("admin").username,
Cypress.env("admin").password
);
// Enable trial without payment method
cy.wpCli(
"eval \"wu_save_setting('allow_trial_without_payment_method', true);\""
);
// Create the trial product
cy.wpCliFile("tests/e2e/cypress/fixtures/setup-trial-product.php", {
failOnNonZeroExit: false,
}).then((result) => {
const productId = result.stdout.trim();
cy.log(`Created trial product with ID: ${productId}`);
});
});
before(() => {
cy.loginByForm(
Cypress.env("admin").username,
Cypress.env("admin").password
);
// Enable trial without payment method
cy.wpCli(
"eval \"wu_save_setting('allow_trial_without_payment_method', true);\""
);
// Create the trial product
cy.wpCliFile("tests/e2e/cypress/fixtures/setup-trial-product.php", {
failOnNonZeroExit: false,
}).then((result) => {
const productId = result.stdout.trim();
expect(productId, "trial product id").to.not.equal("");
cy.log(`Created trial product with ID: ${productId}`);
});
});
🤖 Prompt for AI Agents
In `@tests/e2e/cypress/integration/020-free-trial-flow.spec.js` around lines 13 -
31, The test currently calls
cy.wpCliFile("tests/e2e/cypress/fixtures/setup-trial-product.php", {
failOnNonZeroExit: false }) and ignores the result, which can hide failures;
update the then handler for cy.wpCliFile to assert the command succeeded and
returned a non-empty numeric productId (check result.code === 0 or result.stderr
is empty and result.stdout.trim() is a positive integer) and fail the test early
if not—use the existing productId variable name in the assertion and log to
preserve behavior.

Comment on lines +16 to +22
Cypress.Commands.add("wpCliFile", (filePath, options = {}) => {
const containerPath = `/var/www/html/wp-content/plugins/ultimate-multisite/${filePath}`;

cy.exec(`npx wp-env run tests-cli wp eval-file ${containerPath}`, {
...options,
timeout: options.timeout || 60000,
});
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

Validate and quote filePath before shell execution.
Line 16-20: filePath is interpolated into a shell command and could include .. or quotes that escape the plugin root or break the command.

Proposed fix
Cypress.Commands.add("wpCliFile", (filePath, options = {}) => {
-  const containerPath = `/var/www/html/wp-content/plugins/ultimate-multisite/${filePath}`;
+  if (filePath.includes("..")) {
+    throw new Error("filePath must stay within the plugin root");
+  }
+  const containerPath =
+    `/var/www/html/wp-content/plugins/ultimate-multisite/${filePath}`.replace(/"/g, '\\"');

-  cy.exec(`npx wp-env run tests-cli wp eval-file ${containerPath}`, {
+  return cy.exec(`npx wp-env run tests-cli wp eval-file "${containerPath}"`, {
     ...options,
     timeout: options.timeout || 60000,
   });
});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Cypress.Commands.add("wpCliFile", (filePath, options = {}) => {
const containerPath = `/var/www/html/wp-content/plugins/ultimate-multisite/${filePath}`;
cy.exec(`npx wp-env run tests-cli wp eval-file ${containerPath}`, {
...options,
timeout: options.timeout || 60000,
});
Cypress.Commands.add("wpCliFile", (filePath, options = {}) => {
if (filePath.includes("..")) {
throw new Error("filePath must stay within the plugin root");
}
const containerPath =
`/var/www/html/wp-content/plugins/ultimate-multisite/${filePath}`.replace(/"/g, '\\"');
return cy.exec(`npx wp-env run tests-cli wp eval-file "${containerPath}"`, {
...options,
timeout: options.timeout || 60000,
});
});
🤖 Prompt for AI Agents
In `@tests/e2e/cypress/support/commands/index.js` around lines 16 - 22, The
wpCliFile helper currently interpolates filePath into the shell command
unsafely; sanitize and properly quote it before calling cy.exec: in wpCliFile,
validate filePath (reject empty, absolute paths starting with '/' or '\\', any
segment of '..', or any characters like backticks/newlines/quotes), normalize it
(e.g., use path.posix.normalize and ensure the normalized path does not climb
above the plugin directory), then construct containerPath from the sanitized
relative path and pass a safely quoted containerPath to cy.exec (escape any
single quotes if you use single-quote wrapping) so the shell invocation in the
cy.exec call cannot be broken or used for path traversal or command injection.

Apply WordPress coding standards: escape echo output with esc_html(),
use wp_json_encode() instead of json_encode(), fix spacing in function
calls and arrays.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@github-actions
Copy link

github-actions bot commented Feb 3, 2026

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

…rror handling

- Reference actual spec files (000-setup, 010-manual-checkout-flow, 020-free-trial-flow)
  instead of non-existent ones (checkout-registration, setup-wizard-complete, etc.)
- Remove invalid Cypress CLI flags (--video, --screenshot-on-failure, --record,
  --reporter-options) that caused 'unknown option' errors
- Remove continue-on-error from test steps so failures properly fail the job
- Remove redundant 'Fail job if tests failed' step

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@github-actions
Copy link

github-actions bot commented Feb 4, 2026

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

@github-actions
Copy link

github-actions bot commented Feb 4, 2026

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@inc/class-ajax.php`:
- Around line 112-115: The current condition overwrites a user-provided
$args['query']['number']; change the logic so the user-provided value is
preserved by copying $args['query']['number'] into $args['number'] when it's
present: check ! empty($args['query']['number']) and set $args['number'] =
$args['query']['number']; alternatively, if the intent was to set a default only
when query.number is missing, invert the condition to if (
empty($args['query']['number']) ) and set $args['query']['number'] =
$args['number'] — update the code around the $args['query']['number'] /
$args['number'] handling accordingly.
🧹 Nitpick comments (1)
inc/documentation/berlindb-dynamic-hooks.php (1)

143-157: Minor grammar issues in generated docblocks — fix in the generator script.

Several auto-generated descriptions have awkward phrasing that would improve documentation readability:

  • Line 144: "the has trialed of a customer" → "the has_trialed flag of a customer"
  • Line 159: "the vip of a customer" → "the VIP status of a customer"
  • Line 349: "The previous value value" → "The previous value" (duplicate word)
  • Lines 386, 538, 568, 842: "the active of a..." → "the active status of a..."
  • Line 523: "the domain of a domain" → "the domain name of a domain record"
  • Lines 690, 705: "a event" → "an event"

Since this file should not be edited manually, consider updating inc/documentation/generate-berlindb-hooks.php to improve the humanization logic for these edge cases.

Comment on lines +112 to +115
// Number can be in the query array or it's own. Code uses both.
if (! empty($args['query']['number'])) {
$args['query']['number'] = $args['number'];
}
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 | 🟠 Major

Logic appears inverted: user-provided query.number is overwritten instead of preserved.

The condition ! empty($args['query']['number']) triggers when the user has provided a value, then overwrites it with $args['number']. This discards user input rather than synchronizing it.

If the intent is for query.number to override args.number:

-// Number can be in the query array or it's own. Code uses both.
-if (! empty($args['query']['number'])) {
-    $args['query']['number'] = $args['number'];
-}
+// Allow query.number to override the top-level number if provided.
+if (! empty($args['query']['number'])) {
+    $args['number'] = $args['query']['number'];
+}

Alternatively, if the intent is to set a default when not provided:

-if (! empty($args['query']['number'])) {
+if (empty($args['query']['number'])) {
     $args['query']['number'] = $args['number'];
 }

,

🤖 Prompt for AI Agents
In `@inc/class-ajax.php` around lines 112 - 115, The current condition overwrites
a user-provided $args['query']['number']; change the logic so the user-provided
value is preserved by copying $args['query']['number'] into $args['number'] when
it's present: check ! empty($args['query']['number']) and set $args['number'] =
$args['query']['number']; alternatively, if the intent was to set a default only
when query.number is missing, invert the condition to if (
empty($args['query']['number']) ) and set $args['query']['number'] =
$args['number'] — update the code around the $args['query']['number'] /
$args['number'] handling accordingly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WP Ultimo API Settings

2 participants