From 9ebcc21468d0567707772149c6993bdbecc1e08f Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 28 Apr 2026 09:46:03 -0600 Subject: [PATCH 1/5] fix: treat empty string enabled flag as 'inherit' in limitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When limitations are serialized through external flows (e.g. the WooCommerce addon), the enabled flag for capabilities like domain_mapping can arrive as '' (empty string) instead of being absent. Previously, (bool)'' evaluated to false, causing the capability to be treated as explicitly disabled instead of inheriting from the parent plan/membership. This affected 23 of 31 customer subsites in a production report where domain_mapping.enabled was '' — Pro customers could not see the 'Add Custom Domain' UI despite their plan having it enabled. Two fixes: 1. Limit::setup() — treat '' for enabled the same as 'not-set', marking has_own_enabled=false so the default/inherited value is used instead of casting '' to false. 2. Limitations::merge_recursive() — use strict false check instead of falsy check when deciding to collapse a module to {enabled:false}. This prevents empty-string or absent enabled values from wiping inherited limits during the waterfall merge. --- inc/limitations/class-limit.php | 14 +++++++++++++- inc/objects/class-limitations.php | 17 +++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/inc/limitations/class-limit.php b/inc/limitations/class-limit.php index c7bcd67b0..58ff70236 100644 --- a/inc/limitations/class-limit.php +++ b/inc/limitations/class-limit.php @@ -140,9 +140,21 @@ public function setup($data): void { /* * Sets the own enabled flag, if necessary. + * + * Treat empty string the same as "not set" — when limitations are + * serialized through the WooCommerce addon or other external flows, + * the enabled flag can arrive as '' instead of being absent entirely. + * Casting '' to bool yields false, which incorrectly disables the + * capability instead of inheriting from the parent (plan/membership). + * + * @since 2.7.1 */ - if (wu_get_isset($data, 'enabled', 'not-set') === 'not-set') { + $current_enabled = wu_get_isset($data, 'enabled', 'not-set'); + + if ('not-set' === $current_enabled || '' === $current_enabled) { $this->has_own_enabled = false; + + unset($data['enabled']); } $data = wp_parse_args( diff --git a/inc/objects/class-limitations.php b/inc/objects/class-limitations.php index 627ba261a..f06a17517 100644 --- a/inc/objects/class-limitations.php +++ b/inc/objects/class-limitations.php @@ -277,13 +277,26 @@ protected function merge_recursive(array &$array1, array &$array2, $should_sum = $array2['enabled'] = true; } - if ( ! wu_get_isset($array1, 'enabled', true)) { + /* + * Only collapse a module to {enabled:false} when the enabled flag is + * explicitly set to a boolean false — not when it is absent or an + * empty string. An empty string arrives from external flows (e.g. the + * WooCommerce addon) where capability flags were never written; it + * means "not configured" and must NOT suppress inherited values. + * + * @since 2.7.1 + */ + $a1_enabled = wu_get_isset($array1, 'enabled', 'not-set'); + + if (false === $a1_enabled) { $array1 = [ 'enabled' => false, ]; } - if ( ! wu_get_isset($array2, 'enabled', true) && $should_sum) { + $a2_enabled = wu_get_isset($array2, 'enabled', 'not-set'); + + if (false === $a2_enabled && $should_sum) { return; } From 239388e5d3a0f72b1a6b053f5fe21cb85b98ae44 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 28 Apr 2026 09:46:39 -0600 Subject: [PATCH 2/5] fix: infer membership_id in Site::save() when external gateway skips it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a site is created through an external gateway (e.g. the WooCommerce addon), set_membership_id() may not be called before save(). This leaves wu_membership_id empty in blogmeta, which cascades: get_membership() returns false, customer_id is never written, and downstream code (Frontend Admin, renewal emails, admin panels) sees the site as unlinked. Add a defensive fallback: if get_membership() returns false but the site has a customer_id, look up the customer's active memberships. If exactly one exists, auto-link it. This is conservative — only acts when the inference is unambiguous (single active membership) to avoid misattribution. The primary fix belongs in the WooCommerce addon's gateway class which should call set_membership_id() and set_customer_id() before save(). This core-side fallback is a safety net. --- inc/models/class-site.php | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/inc/models/class-site.php b/inc/models/class-site.php index fbe4dc3b8..bf7a91ebb 100644 --- a/inc/models/class-site.php +++ b/inc/models/class-site.php @@ -1977,10 +1977,36 @@ public function save() { } /** - * Handles membership + * Handles membership. + * + * When the site is created through external gateways (e.g. the + * WooCommerce addon), the membership_id may not have been set on + * the site object before save(). If get_membership() returns false + * but we have a customer_id, attempt to infer the membership from + * the customer's active memberships as a defensive fallback. + * + * @since 2.7.1 */ $membership = $this->get_membership(); + if ( ! $membership && $this->get_customer_id() && function_exists('wu_get_memberships')) { + $memberships = wu_get_memberships( + [ + 'customer_id' => $this->get_customer_id(), + 'status__in' => ['active', 'trialing'], + 'number' => 2, + ] + ); + + if (1 === count($memberships)) { + $membership = $memberships[0]; + + $this->set_membership_id($membership->get_id()); + + update_site_meta($this->get_id(), self::META_MEMBERSHIP_ID, $membership->get_id()); + } + } + if ($membership) { $customer_id = $membership->get_customer_id(); From 9ba2f9e55e9427c9dae37a424c5acf3face5a8b1 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 28 Apr 2026 09:48:41 -0600 Subject: [PATCH 3/5] fix: auto-promote custom domain to primary when it reaches done stage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a custom domain (e.g. elimartz.com) completes DNS/SSL verification and reaches 'done' or 'done-without-ssl' stage, it should become the primary domain for its blog — demoting the default subdomain (e.g. elimartz.kursopro.com). Previously, both domains ended up with primary_domain=0, causing: - No 301 redirect from subdomain to custom domain - SEO duplicate content across both URLs - Customer confusion about which URL to use The new handler hooks into wu_domain_post_save and auto-promotes when: 1. The domain just reached done/done-without-ssl stage 2. It is a custom domain (not a subdomain) 3. It is not already primary 4. No other custom domain is already primary for the blog This works regardless of whether the stage was set by core's async_process_domain_stage or by an external plugin. --- inc/managers/class-domain-manager.php | 92 +++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/inc/managers/class-domain-manager.php b/inc/managers/class-domain-manager.php index 527e24aaf..19df497e8 100644 --- a/inc/managers/class-domain-manager.php +++ b/inc/managers/class-domain-manager.php @@ -156,6 +156,8 @@ public function init(): void { add_action('wu_transition_domain_domain', [$this, 'send_domain_to_host'], 10, 3); + add_action('wu_domain_post_save', [$this, 'maybe_auto_promote_primary_domain'], 10, 3); + add_action('wu_settings_domain_mapping', [$this, 'add_domain_mapping_settings']); add_action('wu_settings_sso', [$this, 'add_sso_settings']); @@ -1085,6 +1087,96 @@ public function async_remove_old_primary_domains($domains): void { } } + /** + * Auto-promote a custom domain to primary when it reaches done/done-without-ssl stage. + * + * When a non-subdomain mapping becomes active (stage = done or done-without-ssl) + * for a blog that has no other primary domain (or only has the default subdomain + * as primary), auto-promote the custom domain. This is the behavior customers + * expect: "I added my domain, verified DNS/SSL, my custom domain is now the + * primary one and the subdomain redirects to it." + * + * Hooked into wu_domain_post_save so it works regardless of whether the stage + * was set by core's async_process_domain_stage or by an external plugin. + * + * @since 2.7.1 + * + * @param array $data The saved data. + * @param \WP_Ultimo\Models\Domain $domain The domain instance. + * @param bool $new Whether this is a new domain. + * @return void + */ + public function maybe_auto_promote_primary_domain($data, $domain, $new): void { + + $done_stages = [ + Domain_Stage::DONE, + Domain_Stage::DONE_WITHOUT_SSL, + ]; + + if ( ! in_array($domain->get_stage(), $done_stages, true)) { + return; + } + + /* + * Already primary — nothing to do. + */ + if ($domain->is_primary_domain()) { + return; + } + + $domain_url = $domain->get_domain(); + + /* + * Only auto-promote custom (non-subdomain) domains. + * Subdomains like foo.kursopro.com should not auto-promote. + */ + if ( ! self::is_main_domain($domain_url)) { + return; + } + + $blog_id = $domain->get_blog_id(); + + /* + * Check if the blog already has a primary custom domain. + * If so, don't override — let the admin manage it manually. + */ + $existing_domains = wu_get_domains( + [ + 'blog_id' => $blog_id, + 'primary_domain' => true, + 'id__not_in' => [$domain->get_id()], + ] + ); + + foreach ($existing_domains as $existing) { + if (self::is_main_domain($existing->get_domain())) { + /* + * Another custom domain is already primary for this blog. + * Do not auto-promote to avoid overriding explicit choice. + */ + return; + } + } + + /* + * Promote this domain to primary. The Domain::save() method + * already handles demoting old primaries via wu_async_remove_old_primary_domains. + */ + $domain->set_primary_domain(true); + + $domain->save(); + + wu_log_add( + "domain-{$domain_url}", + sprintf( + // translators: %s is the domain name, %d is the blog ID. + __('Auto-promoted %1$s as primary domain for site %2$d.', 'ultimate-multisite'), + $domain_url, + $blog_id + ) + ); + } + /** * Tests the integration in the Wizard context. * From 3dfcf1898bee5148ade131d7805c8f46379780e8 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 28 Apr 2026 18:52:19 -0600 Subject: [PATCH 4/5] fix: guard against wiping wu_membership_id/wu_customer_id during site updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When external code (e.g. the WooCommerce addon's sync_subscription_status) constructs a Site object from partial data, the Base_Model constructor calls set_membership_id('') and set_customer_id('') — writing empty values into $this->meta. When save() then iterates $this->meta and writes to blogmeta, the previously correct values (set at signup) get overwritten with empties. Real case: customer Alejandro Perretti (blog 306), paid manually 2026-04-28 11:17 UTC. WCS subscription flipped to active, addon's sync handler ran, and wu_membership_id/wu_customer_id were wiped from blogmeta. The fix adds a guard: during updates (not new site creation), skip writing wu_membership_id and wu_customer_id to blogmeta when the value in $this->meta is empty. This prevents stale empty defaults from overwriting correct values while still allowing legitimate clears (which would go through the explicit update_site_meta call in the 'Handles membership' section below the loop). --- inc/models/class-site.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/inc/models/class-site.php b/inc/models/class-site.php index bf7a91ebb..56865c36e 100644 --- a/inc/models/class-site.php +++ b/inc/models/class-site.php @@ -1972,7 +1972,27 @@ public function save() { update_site_meta($saved, $key, $value); } + /* + * Guard: never overwrite existing wu_membership_id or wu_customer_id + * with empty values during an update. External code (e.g. the WooCommerce + * addon's sync_subscription_status) can construct a Site object from + * partial data where these properties default to empty — the Base_Model + * constructor calls set_membership_id('') / set_customer_id('') which + * populates $this->meta with empty values. Writing those empties to + * blogmeta wipes the correct values that were stored at signup. + * + * @since 2.7.1 + */ + $protected_meta_keys = [ + self::META_MEMBERSHIP_ID, + self::META_CUSTOMER_ID, + ]; + foreach ($this->meta as $key => $value) { + if ( ! $new && in_array($key, $protected_meta_keys, true) && empty($value)) { + continue; + } + update_site_meta($saved, $key, $value); } From b6c02659c6f27180d72bbdad4634a8b09327ddfb Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 28 Apr 2026 19:17:41 -0600 Subject: [PATCH 5/5] Apply suggestions from code review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- inc/managers/class-domain-manager.php | 16 +++++++++++++++- inc/models/class-site.php | 1 + 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/inc/managers/class-domain-manager.php b/inc/managers/class-domain-manager.php index 19df497e8..04ae45065 100644 --- a/inc/managers/class-domain-manager.php +++ b/inc/managers/class-domain-manager.php @@ -1164,7 +1164,21 @@ public function maybe_auto_promote_primary_domain($data, $domain, $new): void { */ $domain->set_primary_domain(true); - $domain->save(); + $save_result = $domain->save(); + if (is_wp_error($save_result)) { + wu_log_add( + "domain-{$domain_url}", + sprintf( + // translators: %1$s is the domain name, %2$d is the blog ID, %3$s is the error message. + __('Failed to auto-promote %1$s as primary domain for site %2$d: %3$s', 'ultimate-multisite'), + $domain_url, + $blog_id, + $save_result->get_error_message() + ), + LogLevel::ERROR + ); + return; + } wu_log_add( "domain-{$domain_url}", diff --git a/inc/models/class-site.php b/inc/models/class-site.php index 56865c36e..26eafb46c 100644 --- a/inc/models/class-site.php +++ b/inc/models/class-site.php @@ -2022,6 +2022,7 @@ public function save() { $membership = $memberships[0]; $this->set_membership_id($membership->get_id()); + $this->membership = $membership; update_site_meta($this->get_id(), self::META_MEMBERSHIP_ID, $membership->get_id()); }