diff --git a/inc/limitations/class-limit.php b/inc/limitations/class-limit.php index c7bcd67b..58ff7023 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/managers/class-domain-manager.php b/inc/managers/class-domain-manager.php index 527e24aa..04ae4506 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,110 @@ 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); + + $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}", + 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. * diff --git a/inc/models/class-site.php b/inc/models/class-site.php index fbe4dc3b..26eafb46 100644 --- a/inc/models/class-site.php +++ b/inc/models/class-site.php @@ -1972,15 +1972,62 @@ 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); } /** - * 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()); + $this->membership = $membership; + + update_site_meta($this->get_id(), self::META_MEMBERSHIP_ID, $membership->get_id()); + } + } + if ($membership) { $customer_id = $membership->get_customer_id(); diff --git a/inc/objects/class-limitations.php b/inc/objects/class-limitations.php index 627ba261..f06a1751 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; }