Commit a492156e41e for woocommerce

commit a492156e41ee46762b8ed0613adec14c62f2bedb
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Tue Nov 3 12:06:11 2026 +0300

    Estimate shop base tax for shoppers without an address (#69840)

    * [WOOPLUG-7870] Estimate tax at the shop base for shoppers without an address

    * Add changefile(s) from automation for the following project(s): woocommerce

    * [WOOPLUG-7870] Allow a dynamic description for tax settings in the API test

    * [WOOPLUG-7870] Update system status settings count for the store base fields

    * [WOOPLUG-7870] Show the base-address tax estimate note whenever tax is based on the shop base

    * [WOOPLUG-7870] Use the post-filter tax-rate path for the shop-base estimate

    * [WOOPLUG-7870] Update the new docblock version tags to 11.4.0

    * [WOOPLUG-7870] Fix rate matching and postcode handling in the base-address tax estimate

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/69840-wooplug-7870-tax-shop-base-estimate b/plugins/woocommerce/changelog/69840-wooplug-7870-tax-shop-base-estimate
new file mode 100644
index 00000000000..50da58eb6ca
--- /dev/null
+++ b/plugins/woocommerce/changelog/69840-wooplug-7870-tax-shop-base-estimate
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Estimate tax from the store's postcode and city for shoppers without an address when the default location is the shop base.
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-general.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-general.php
index a903d0b2d61..f3da73292d8 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-general.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-general.php
@@ -252,13 +252,20 @@ class WC_Settings_General extends WC_Settings_Page {
 				array(
 					'title'    => __( 'Default customer location', 'woocommerce' ),
 					'id'       => 'woocommerce_default_customer_address',
+					'desc'     => wc_tax_enabled()
+						? sprintf(
+							/* translators: %s: link to the Tax settings. */
+							__( 'Until a customer enters their address, tax is estimated based on this setting. See what is estimated under %s.', 'woocommerce' ),
+							'<a href="' . esc_url( admin_url( 'admin.php?page=wc-settings&tab=tax' ) ) . '">' . esc_html__( 'Settings → Tax', 'woocommerce' ) . '</a>'
+						)
+						: '',
 					'desc_tip' => __( 'This option determines a customers default location. The MaxMind GeoLite Database will be periodically downloaded to your wp-content directory if using geolocation.', 'woocommerce' ),
 					'default'  => DefaultCustomerAddress::BASE,
 					'type'     => 'select',
 					'class'    => 'wc-enhanced-select',
 					'options'  => array(
 						DefaultCustomerAddress::NO_DEFAULT => __( 'No location by default', 'woocommerce' ),
-						DefaultCustomerAddress::BASE => __( 'Shop country/region', 'woocommerce' ),
+						DefaultCustomerAddress::BASE       => __( 'Shop country/region', 'woocommerce' ),
 						DefaultCustomerAddress::GEOLOCATION => __( 'Geolocate', 'woocommerce' ),
 						DefaultCustomerAddress::GEOLOCATION_AJAX => __( 'Geolocate (with page caching support)', 'woocommerce' ),
 					),
diff --git a/plugins/woocommerce/includes/admin/settings/views/settings-tax.php b/plugins/woocommerce/includes/admin/settings/views/settings-tax.php
index 2f14a66e242..5b9e68cf526 100644
--- a/plugins/woocommerce/includes/admin/settings/views/settings-tax.php
+++ b/plugins/woocommerce/includes/admin/settings/views/settings-tax.php
@@ -10,6 +10,13 @@ defined( 'ABSPATH' ) || exit;
 use Automattic\WooCommerce\Enums\TaxBasedOn;
 use Automattic\WooCommerce\Enums\TaxDisplayMode;

+// Read-only estimate shown under "Calculate tax based on": what an un-addressed shopper is quoted,
+// how it follows the Default customer location setting, and the per-class rates at the store address.
+// Display only; it does not change how tax is calculated.
+$shop_base_tax_notice          = ( new Automattic\WooCommerce\Internal\Tax\ShopBaseTaxEstimateNotice() )->get_notice();
+$base_location_estimate_text   = $shop_base_tax_notice['estimate'];
+$base_location_no_rate_warning = $shop_base_tax_notice['warning'];
+
 $settings = array(

 	array(
@@ -34,6 +41,7 @@ $settings = array(
 	array(
 		'title'    => __( 'Calculate tax based on', 'woocommerce' ),
 		'id'       => 'woocommerce_tax_based_on',
+		'desc'     => $base_location_estimate_text,
 		'desc_tip' => __( 'This option determines which address is used to calculate tax.', 'woocommerce' ),
 		'default'  => TaxBasedOn::SHIPPING,
 		'type'     => 'select',
@@ -45,7 +53,13 @@ $settings = array(
 		),
 	),

-	'shipping-tax-class' => array(
+	'base-location-tax-estimate-warning' => array(
+		'type'        => 'notice',
+		'notice_type' => 'warning',
+		'text'        => $base_location_no_rate_warning,
+	),
+
+	'shipping-tax-class'                 => array(
 		'title'    => __( 'Shipping tax class', 'woocommerce' ),
 		'desc'     => __( 'Optionally control which tax class shipping gets, or leave it so shipping tax is based on the cart items themselves.', 'woocommerce' ),
 		'id'       => 'woocommerce_shipping_tax_class',
@@ -136,4 +150,8 @@ if ( ! wc_shipping_enabled() ) {
 	unset( $settings['shipping-tax-class'] );
 }

+if ( '' === $base_location_no_rate_warning ) {
+	unset( $settings['base-location-tax-estimate-warning'] );
+}
+
 return apply_filters( 'woocommerce_tax_settings', $settings );
diff --git a/plugins/woocommerce/includes/admin/views/html-admin-page-status-report.php b/plugins/woocommerce/includes/admin/views/html-admin-page-status-report.php
index b7f58ceb720..4d12b146eba 100644
--- a/plugins/woocommerce/includes/admin/views/html-admin-page-status-report.php
+++ b/plugins/woocommerce/includes/admin/views/html-admin-page-status-report.php
@@ -53,6 +53,8 @@ $store_base_country_display = isset( $countries[ $store_base_country ] ) ? $stor
 $states                     = WC()->countries->get_states( $store_base_country );
 $store_base_state           = $settings['store_base_state'] ?? '';
 $store_base_state_display   = is_array( $states ) && isset( $states[ $store_base_state ] ) ? $states[ $store_base_state ] . ' (' . $store_base_state . ')' : $store_base_state;
+$store_base_postcode        = $settings['store_base_postcode'] ?? '';
+$store_base_city            = $settings['store_base_city'] ?? '';

 $tax_based_on_labels  = array(
 	TaxBasedOn::SHIPPING => __( 'Customer shipping address', 'woocommerce' ),
@@ -938,6 +940,16 @@ if ( 0 < $mu_plugins_count ) :
 			<td class="help"><?php echo wc_help_tip( esc_html__( 'The state or region configured as the store base location.', 'woocommerce' ) ); ?></td>
 			<td><?php echo $store_base_state_display ? esc_html( $store_base_state_display ) : '<mark class="no">&ndash;</mark>'; ?></td>
 		</tr>
+		<tr>
+			<td data-export-label="Store base postcode"><?php esc_html_e( 'Store base postcode', 'woocommerce' ); ?>:</td>
+			<td class="help"><?php echo wc_help_tip( esc_html__( 'The postcode configured as the store base location, used to estimate tax before a customer enters an address.', 'woocommerce' ) ); ?></td>
+			<td><?php echo $store_base_postcode ? esc_html( $store_base_postcode ) : '<mark class="no">&ndash;</mark>'; ?></td>
+		</tr>
+		<tr>
+			<td data-export-label="Store base city"><?php esc_html_e( 'Store base city', 'woocommerce' ); ?>:</td>
+			<td class="help"><?php echo wc_help_tip( esc_html__( 'The city configured as the store base location, used to estimate tax before a customer enters an address.', 'woocommerce' ) ); ?></td>
+			<td><?php echo $store_base_city ? esc_html( $store_base_city ) : '<mark class="no">&ndash;</mark>'; ?></td>
+		</tr>
 		<tr>
 			<td data-export-label="Default customer location"><?php esc_html_e( 'Default customer location', 'woocommerce' ); ?>:</td>
 			<td class="help"><?php echo wc_help_tip( esc_html__( 'The location WooCommerce assumes for a customer before an address is provided.', 'woocommerce' ) ); ?></td>
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index dd193283aa8..87fca571b79 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -427,6 +427,7 @@ final class WooCommerce {
 		 * These classes have a register method for attaching hooks.
 		 */
 		$container->get( Automattic\WooCommerce\Internal\Utilities\PluginInstaller::class )->register();
+		$container->get( Automattic\WooCommerce\Internal\Tax\BaseLocationTaxEstimate::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\TransientFiles\TransientFilesEngine::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Orders\OrderAttributionController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Orders\OrderAttributionBlocksController::class )->register();
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller.php
index d4b3b68d307..8e1d3aa73aa 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller.php
@@ -757,6 +757,18 @@ class WC_REST_System_Status_V2_Controller extends WC_REST_Controller {
 							'context'     => array( 'view' ),
 							'readonly'    => true,
 						),
+						'store_base_postcode'            => array(
+							'description' => __( 'Store base postcode.', 'woocommerce' ),
+							'type'        => 'string',
+							'context'     => array( 'view' ),
+							'readonly'    => true,
+						),
+						'store_base_city'                => array(
+							'description' => __( 'Store base city.', 'woocommerce' ),
+							'type'        => 'string',
+							'context'     => array( 'view' ),
+							'readonly'    => true,
+						),
 						'default_customer_location'      => array(
 							'description' => __( 'Default customer location.', 'woocommerce' ),
 							'type'        => 'string',
@@ -1654,6 +1666,8 @@ class WC_REST_System_Status_V2_Controller extends WC_REST_Controller {
 			'tax_rate_count'                 => absint( $tax_rate_count ),
 			'store_base_country'             => WC()->countries->get_base_country(),
 			'store_base_state'               => WC()->countries->get_base_state(),
+			'store_base_postcode'            => WC()->countries->get_base_postcode(),
+			'store_base_city'                => WC()->countries->get_base_city(),
 			'default_customer_location'      => get_option( 'woocommerce_default_customer_address', DefaultCustomerAddress::BASE ),
 			'taxonomies'                     => $term_response,
 			'product_visibility_terms'       => $product_visibility_terms,
diff --git a/plugins/woocommerce/src/Internal/Tax/BaseLocationTaxEstimate.php b/plugins/woocommerce/src/Internal/Tax/BaseLocationTaxEstimate.php
new file mode 100644
index 00000000000..a6e2bc4fae8
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Tax/BaseLocationTaxEstimate.php
@@ -0,0 +1,86 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Tax;
+
+use Automattic\WooCommerce\Enums\DefaultCustomerAddress;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Completes an un-addressed shopper's taxable address with the store's postcode and city
+ * when the default customer location is the shop base, so a postcode- or city-scoped base
+ * tax rate can be estimated before the shopper enters their own address.
+ *
+ * This only affects the tax lookup: it does not prefill checkout fields, change the stored
+ * customer address, or affect shipping-rate selection, since it filters the taxable address
+ * alone. Once the shopper enters a postcode or city, the estimate steps aside.
+ *
+ * @since 11.4.0
+ */
+class BaseLocationTaxEstimate implements RegisterHooksInterface {
+
+	/**
+	 * Register hooks.
+	 */
+	public function register(): void {
+		add_filter( 'woocommerce_customer_taxable_address', array( $this, 'estimate_base_location_address' ), 10, 2 );
+	}
+
+	/**
+	 * Fill the store's postcode and city into an un-addressed shopper's taxable address.
+	 *
+	 * @param array $taxable_address The taxable address: [ country, state, postcode, city ].
+	 * @param mixed $customer        The customer object (unused).
+	 * @return array The taxable address, with the store postcode/city filled in when applicable.
+	 */
+	public function estimate_base_location_address( $taxable_address, $customer ) {
+		unset( $customer );
+
+		if ( ! is_array( $taxable_address ) || count( $taxable_address ) < 4 ) {
+			return $taxable_address;
+		}
+
+		if ( ! wc_tax_enabled() ) {
+			return $taxable_address;
+		}
+
+		if ( DefaultCustomerAddress::BASE !== get_option( 'woocommerce_default_customer_address', DefaultCustomerAddress::BASE ) ) {
+			return $taxable_address;
+		}
+
+		list( $country, $state, $postcode, $city ) = array_values( $taxable_address );
+
+		// The shopper has started entering their own location, so stop estimating.
+		if ( '' !== (string) $postcode || '' !== (string) $city ) {
+			return $taxable_address;
+		}
+
+		$countries = WC()->countries;
+		if ( ! $countries ) {
+			return $taxable_address;
+		}
+
+		// Only treat the shopper as "at the shop" when the assumed country and state are the store's.
+		// A same-state shopper mid-entry is estimated at the store postcode/city by design; it is a
+		// pre-address estimate that corrects once they enter their own postcode or city.
+		if ( (string) $country !== (string) $countries->get_base_country() || (string) $state !== (string) $countries->get_base_state() ) {
+			return $taxable_address;
+		}
+
+		// Reindex so the writes land on the postcode/city slots even if a callback returned string keys,
+		// while keeping any trailing fields (e.g. a tax provider's street at index 4). Normalize the
+		// postcode to the format an entered address uses, so other consumers of the filter see it consistently.
+		$taxable_address = array_values( $taxable_address );
+		$base_postcode   = (string) $countries->get_base_postcode();
+
+		// Formatting a postcode that normalizes to nothing leaves a bare separator in some countries
+		// ('-' for BR, PL, JP, PT), which consumers of this filter would read as the shopper's postcode.
+		$has_postcode       = '' !== wc_normalize_postcode( $base_postcode );
+		$taxable_address[2] = $has_postcode ? wc_format_postcode( $base_postcode, (string) $country ) : '';
+		$taxable_address[3] = (string) $countries->get_base_city();
+
+		return $taxable_address;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Tax/ShopBaseTaxEstimateNotice.php b/plugins/woocommerce/src/Internal/Tax/ShopBaseTaxEstimateNotice.php
new file mode 100644
index 00000000000..a9a6b15d17a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Tax/ShopBaseTaxEstimateNotice.php
@@ -0,0 +1,304 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Tax;
+
+use Automattic\WooCommerce\Enums\DefaultCustomerAddress;
+use Automattic\WooCommerce\Enums\TaxBasedOn;
+use WC_Cache_Helper;
+use WC_Tax;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Builds the read-only tax estimate shown on the Tax settings screen: what an un-addressed shopper
+ * is quoted given the Default customer location setting, the per-tax-class rates at the store
+ * address, and warnings when no rate matches. This is display only; it never changes how tax is
+ * calculated (see BaseLocationTaxEstimate for the lookup behavior).
+ *
+ * @since 11.4.0
+ */
+class ShopBaseTaxEstimateNotice {
+
+	/**
+	 * Build the estimate help text and warning for the Tax settings screen.
+	 *
+	 * @return array{estimate: string, warning: string} HTML strings, empty when there is nothing to show.
+	 */
+	public function get_notice(): array {
+		$empty = array(
+			'estimate' => '',
+			'warning'  => '',
+		);
+
+		if ( ! $this->is_shown() ) {
+			return $empty;
+		}
+
+		$location = get_option( 'woocommerce_default_customer_address', DefaultCustomerAddress::BASE );
+
+		// Base tax mode taxes every order at the store address regardless of the default location, so its message wins.
+		if ( DefaultCustomerAddress::BASE === $location || TaxBasedOn::BASE === get_option( 'woocommerce_tax_based_on', TaxBasedOn::SHIPPING ) ) {
+			return $this->shop_base_notice();
+		}
+
+		if ( DefaultCustomerAddress::NO_DEFAULT === $location ) {
+			return array(
+				'estimate' => $this->wrap(
+					sprintf(
+						/* translators: %s: link to the General settings. */
+						__( 'No tax is shown until the customer enters their address. Change this with Default customer location in %s.', 'woocommerce' ),
+						$this->general_link()
+					)
+				),
+				'warning'  => '',
+			);
+		}
+
+		if ( in_array( $location, array( DefaultCustomerAddress::GEOLOCATION, DefaultCustomerAddress::GEOLOCATION_AJAX ), true ) ) {
+			return array(
+				'estimate' => $this->wrap(
+					sprintf(
+						/* translators: %s: link to the General settings. */
+						__( 'Before a customer enters their address, tax is estimated from their detected country and state. Change this with Default customer location in %s.', 'woocommerce' ),
+						$this->general_link()
+					)
+				),
+				'warning'  => '',
+			);
+		}
+
+		return $empty;
+	}
+
+	/**
+	 * Whether the estimate should appear at all.
+	 *
+	 * @return bool
+	 */
+	private function is_shown(): bool {
+		if ( ! wc_tax_enabled() ) {
+			return false;
+		}
+
+		$automated_taxes_enabled = in_array( get_option( 'wc_connect_taxes_enabled' ), array( 'yes', true ), true );
+
+		/**
+		 * Filters whether the shop-base tax estimate help text and warning show on the Tax settings screen.
+		 *
+		 * Automated tax extensions that calculate tax themselves can return false to hide the manual-rate estimate.
+		 *
+		 * @since 11.4.0
+		 * @param bool $show Whether to show the estimate. Defaults to false when automated taxes are enabled.
+		 */
+		return (bool) apply_filters( 'woocommerce_show_shop_base_tax_estimate', ! $automated_taxes_enabled );
+	}
+
+	/**
+	 * Build the notice for the "Shop base address" default customer location.
+	 *
+	 * @return array{estimate: string, warning: string}
+	 */
+	private function shop_base_notice(): array {
+		// WC()->countries is not available in every context this view loads in (e.g. during install).
+		$countries = WC()->countries;
+		if ( ! $countries || ! class_exists( 'WC_Tax' ) ) {
+			return array(
+				'estimate' => '',
+				'warning'  => '',
+			);
+		}
+
+		// These pass through filters, so coerce before handing them to strictly typed calls below.
+		$country      = (string) $countries->get_base_country();
+		$state        = (string) $countries->get_base_state();
+		$postcode     = (string) $countries->get_base_postcode();
+		$city         = (string) $countries->get_base_city();
+		$has_postcode = '' !== wc_normalize_postcode( $postcode );
+		$has_address  = $has_postcode || '' !== trim( $city );
+		$tax_based_on = TaxBasedOn::BASE === get_option( 'woocommerce_tax_based_on', TaxBasedOn::SHIPPING );
+		$base_args    = array(
+			'country'  => $country,
+			'state'    => $state,
+			'postcode' => $postcode,
+			'city'     => $city,
+		);
+		// get_rates_from_location() applies the woocommerce_matched_rates filter, so the estimate reflects the rate the shopper is actually quoted.
+		$standard_rates = WC_Tax::get_rates_from_location( '', array( $country, $state, $postcode, $city ) );
+		$standard_rates = is_array( $standard_rates ) ? $standard_rates : array();
+
+		// Lead line: what is taxed, and where.
+		if ( $has_address ) {
+			$shown        = array_filter( array( trim( $city ), $has_postcode ? $postcode : '' ) );
+			$address_html = '<strong>' . esc_html( implode( ', ', $shown ) ) . '</strong>';
+			$lead         = $tax_based_on
+				? sprintf( /* translators: %s: store base address (bold). */ __( 'All orders are taxed at your store base address: %s.', 'woocommerce' ), $address_html )
+				: sprintf( /* translators: %s: store base address (bold). */ __( 'Until a customer enters their address, tax is estimated at your store base address: %s.', 'woocommerce' ), $address_html );
+		} else {
+			$lead = $tax_based_on
+				? __( 'All orders are taxed at your store\'s country and state only.', 'woocommerce' )
+				: __( 'Until a customer enters their address, tax is estimated at your store\'s country and state only.', 'woocommerce' );
+		}
+
+		$lines = array( $lead, $this->rate_summary( $base_args ) );
+
+		// Footer: how to change it, and a link to the rates. Base tax mode is not driven by the default location.
+		if ( $tax_based_on ) {
+			$lines[] = $this->standard_rates_link();
+		} else {
+			$lines[] = sprintf(
+				/* translators: 1: link to the General settings, 2: link to the standard tax rates. */
+				__( 'Change this with Default customer location in %1$s. %2$s', 'woocommerce' ),
+				$this->general_link(),
+				$this->standard_rates_link()
+			);
+		}
+
+		return array(
+			'estimate' => $this->wrap( implode( '<br/>', array_filter( $lines ) ) ),
+			'warning'  => $this->warning( $standard_rates, $country, $state, $has_address, $tax_based_on ),
+		);
+	}
+
+	/**
+	 * Summarize the estimated rate for each tax class at the store address.
+	 *
+	 * @param array $base_args Store address parts keyed country/state/postcode/city.
+	 * @return string
+	 */
+	private function rate_summary( array $base_args ): string {
+		$classes = array( '' => __( 'Standard rate', 'woocommerce' ) );
+		// A tax class can be registered with a slug that is not derived from its name, and products store that slug.
+		foreach ( WC_Tax::get_tax_rate_classes() as $tax_class ) {
+			$classes[ $tax_class->slug ] = $tax_class->name;
+		}
+
+		$parts = array();
+		foreach ( $classes as $slug => $label ) {
+			$rates = WC_Tax::get_rates_from_location( $slug, array( $base_args['country'], $base_args['state'], $base_args['postcode'], $base_args['city'] ) );
+
+			if ( ! is_array( $rates ) || empty( $rates ) ) {
+				$parts[] = esc_html( $label ) . ': &ndash;';
+				continue;
+			}
+
+			// Effective rate on a 100 base, so compound rates are reflected correctly.
+			$pct     = array_sum( WC_Tax::calc_tax( 100, $rates, false ) );
+			$parts[] = esc_html( $label ) . ': ' . esc_html( rtrim( rtrim( sprintf( '%.4f', $pct ), '0' ), '.' ) . '%' );
+		}
+
+		return sprintf(
+			/* translators: %s: list of estimated tax rates per tax class. */
+			__( 'Estimated rates &mdash; %s', 'woocommerce' ),
+			implode( ' &middot; ', $parts )
+		);
+	}
+
+	/**
+	 * Build the no-rate warning, scoped to the store's standard-rate situation.
+	 *
+	 * @param array  $standard_rates Rates matched at the store address for the standard class.
+	 * @param string $country        Store base country.
+	 * @param string $state          Store base state.
+	 * @param bool   $has_address    Whether the store has a postcode or city.
+	 * @param bool   $tax_based_on   Whether tax is calculated on the shop base address.
+	 * @return string
+	 */
+	private function warning( array $standard_rates, string $country, string $state, bool $has_address, bool $tax_based_on ): string {
+		if ( ! empty( $standard_rates ) ) {
+			return '';
+		}
+
+		if ( ! $has_address && $this->has_scoped_standard_rates( $country, $state ) ) {
+			return sprintf(
+				/* translators: 1: link to the General settings, 2: link to the standard tax rates. */
+				__( 'Your store has postcode- or city-scoped standard rates, but no store postcode or city is set, so they cannot be estimated. Add them in %1$s, or see %2$s.', 'woocommerce' ),
+				$this->general_link(),
+				$this->standard_rates_link()
+			);
+		}
+
+		if ( $tax_based_on ) {
+			return sprintf(
+				/* translators: %s: link to the standard tax rates. */
+				__( 'No standard tax rate matches your store address, so standard-rate products are quoted no tax. Add a matching rate in %s.', 'woocommerce' ),
+				$this->standard_rates_link()
+			);
+		}
+
+		return sprintf(
+			/* translators: %s: link to the standard tax rates. */
+			__( 'No standard tax rate matches your store address, so standard-rate products are quoted no tax for a customer who has not entered an address. Add a matching rate in %s.', 'woocommerce' ),
+			$this->standard_rates_link()
+		);
+	}
+
+	/**
+	 * Whether any postcode- or city-scoped standard rate applies to the store's location.
+	 *
+	 * @param string $country Store base country.
+	 * @param string $state   Store base state.
+	 * @return bool
+	 */
+	private function has_scoped_standard_rates( string $country, string $state ): bool {
+		if ( '' === $country ) {
+			return false;
+		}
+
+		global $wpdb;
+
+		// Resolve the location the way WC_Tax::get_matched_tax_rates() does: uppercased, with '' as a wildcard.
+		$country = strtoupper( $country );
+		$state   = strtoupper( $state );
+
+		$cache_key = WC_Cache_Helper::get_cache_prefix( 'taxes' ) . 'shop_base_scoped_' . md5( $country . '|' . $state );
+		$cached    = wp_cache_get( $cache_key, 'taxes' );
+		if ( false !== $cached ) {
+			return (bool) $cached;
+		}
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- No core API returns a rate's location scope; cached above.
+		$count = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_tax_rate_locations locations
+				INNER JOIN {$wpdb->prefix}woocommerce_tax_rates rates ON locations.tax_rate_id = rates.tax_rate_id
+				WHERE rates.tax_rate_country IN ( %s, '' ) AND rates.tax_rate_state IN ( %s, '' ) AND rates.tax_rate_class = '' AND locations.location_type IN ( 'postcode', 'city' )",
+				$country,
+				$state
+			)
+		);
+
+		$result = absint( $count ) > 0;
+		wp_cache_set( $cache_key, $result ? 1 : 0, 'taxes' );
+
+		return $result;
+	}
+
+	/**
+	 * Wrap the estimate text in a targetable span.
+	 *
+	 * @param string $html Estimate HTML.
+	 * @return string
+	 */
+	private function wrap( string $html ): string {
+		return '' === $html ? '' : '<span class="wc-tax-base-location-estimate">' . $html . '</span>';
+	}
+
+	/**
+	 * Link to the General settings screen.
+	 *
+	 * @return string
+	 */
+	private function general_link(): string {
+		return '<a href="' . esc_url( admin_url( 'admin.php?page=wc-settings&tab=general' ) ) . '">' . esc_html__( 'Settings → General', 'woocommerce' ) . '</a>';
+	}
+
+	/**
+	 * Link to the standard tax rates screen.
+	 *
+	 * @return string
+	 */
+	private function standard_rates_link(): string {
+		return '<a href="' . esc_url( admin_url( 'admin.php?page=wc-settings&tab=tax&section=standard' ) ) . '">' . esc_html__( 'View standard rates', 'woocommerce' ) . '</a>';
+	}
+}
diff --git a/plugins/woocommerce/tests/e2e/tests/api-tests/settings/settings-crud.test.ts b/plugins/woocommerce/tests/e2e/tests/api-tests/settings/settings-crud.test.ts
index f690eb90208..942ce9a6237 100644
--- a/plugins/woocommerce/tests/e2e/tests/api-tests/settings/settings-crud.test.ts
+++ b/plugins/woocommerce/tests/e2e/tests/api-tests/settings/settings-crud.test.ts
@@ -459,7 +459,7 @@ test.describe( 'Settings API tests: CRUD', () => {
 					expect.objectContaining( {
 						id: 'woocommerce_default_customer_address',
 						label: 'Default customer location',
-						description: '',
+						description: expect.any( String ),
 						type: 'select',
 						default: 'base',
 						tip: 'This option determines a customers default location. The MaxMind GeoLite Database will be periodically downloaded to your wp-content directory if using geolocation.',
@@ -1051,7 +1051,7 @@ test.describe( 'Settings API tests: CRUD', () => {
 					expect.objectContaining( {
 						id: 'woocommerce_tax_based_on',
 						label: 'Calculate tax based on',
-						description: '',
+						description: expect.any( String ),
 						type: 'select',
 						default: 'shipping',
 						options: {
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/system-status.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/system-status.php
index 7e2ee3e2d43..a18b7d7237d 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/system-status.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/system-status.php
@@ -216,7 +216,7 @@ class WC_Tests_REST_System_Status_V2 extends WC_REST_Unit_Test_Case {

 		$settings = (array) $this->fetch_or_get_system_status_data_for_user( self::$administrator_user )['settings'];

-		$this->assertEquals( 31, count( $settings ) );
+		$this->assertEquals( 33, count( $settings ) );
 		$this->assertEquals( WC()->legacy_rest_api_is_available(), $settings['api_enabled'] );
 		$this->assertEquals( get_woocommerce_currency(), $settings['currency'] );
 		$this->assertEquals( $term_response, $settings['taxonomies'] );
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/system-status.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/system-status.php
index 932b9a8aa82..35fe32bdb8f 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/system-status.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/system-status.php
@@ -275,7 +275,7 @@ class WC_Tests_REST_System_Status extends WC_REST_Unit_Test_Case {

 		$settings = (array) $this->fetch_or_get_system_status_data_for_user( self::$administrator_user )['settings'];

-		$this->assertEquals( 31, count( $settings ) );
+		$this->assertEquals( 33, count( $settings ) );
 		$this->assertEquals( WC()->legacy_rest_api_is_available(), $settings['api_enabled'] );
 		$this->assertEquals( get_woocommerce_currency(), $settings['currency'] );
 		$this->assertEquals( $term_response, $settings['taxonomies'] );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Tax/BaseLocationTaxEstimateTest.php b/plugins/woocommerce/tests/php/src/Internal/Tax/BaseLocationTaxEstimateTest.php
new file mode 100644
index 00000000000..6addc5204c4
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Tax/BaseLocationTaxEstimateTest.php
@@ -0,0 +1,218 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Tax;
+
+use Automattic\WooCommerce\Internal\Tax\BaseLocationTaxEstimate;
+use WC_Customer;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the BaseLocationTaxEstimate class.
+ */
+class BaseLocationTaxEstimateTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var BaseLocationTaxEstimate
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new BaseLocationTaxEstimate();
+
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_default_customer_address', 'base' );
+		update_option( 'woocommerce_default_country', 'US:CA' );
+		update_option( 'woocommerce_store_postcode', '94110' );
+		update_option( 'woocommerce_store_city', 'San Francisco' );
+	}
+
+	/**
+	 * @testdox Fills the store postcode and city for an un-addressed shopper at the shop base.
+	 */
+	public function test_fills_base_postcode_and_city_for_unaddressed_shopper(): void {
+		$result = $this->sut->estimate_base_location_address( array( 'US', 'CA', '', '' ), null );
+
+		$this->assertSame( array( 'US', 'CA', '94110', 'San Francisco' ), $result );
+	}
+
+	/**
+	 * @testdox Leaves the address alone once the shopper has entered a postcode.
+	 */
+	public function test_skips_when_shopper_has_postcode(): void {
+		$address = array( 'US', 'CA', '90001', '' );
+
+		$this->assertSame( $address, $this->sut->estimate_base_location_address( $address, null ) );
+	}
+
+	/**
+	 * @testdox Leaves the address alone once the shopper has entered a city.
+	 */
+	public function test_skips_when_shopper_has_city(): void {
+		$address = array( 'US', 'CA', '', 'Los Angeles' );
+
+		$this->assertSame( $address, $this->sut->estimate_base_location_address( $address, null ) );
+	}
+
+	/**
+	 * @testdox Does not estimate when the default customer location is not the shop base.
+	 */
+	public function test_skips_when_default_location_not_base(): void {
+		update_option( 'woocommerce_default_customer_address', 'geolocation' );
+		$address = array( 'US', 'CA', '', '' );
+
+		$this->assertSame( $address, $this->sut->estimate_base_location_address( $address, null ) );
+	}
+
+	/**
+	 * @testdox Does not estimate when the assumed country or state is not the store's.
+	 */
+	public function test_skips_when_country_state_differ_from_base(): void {
+		$address = array( 'GB', '', '', '' );
+
+		$this->assertSame( $address, $this->sut->estimate_base_location_address( $address, null ) );
+	}
+
+	/**
+	 * @testdox Does not estimate when taxes are disabled.
+	 */
+	public function test_skips_when_taxes_disabled(): void {
+		update_option( 'woocommerce_calc_taxes', 'no' );
+		$address = array( 'US', 'CA', '', '' );
+
+		$this->assertSame( $address, $this->sut->estimate_base_location_address( $address, null ) );
+	}
+
+	/**
+	 * @testdox Returns a non-array taxable address unchanged.
+	 */
+	public function test_returns_non_array_input_unchanged(): void {
+		$this->assertSame( 'not-an-address', $this->sut->estimate_base_location_address( 'not-an-address', null ) );
+	}
+
+	/**
+	 * @testdox Returns an incomplete taxable address unchanged.
+	 */
+	public function test_returns_short_array_unchanged(): void {
+		$address = array( 'US', 'CA' );
+
+		$this->assertSame( $address, $this->sut->estimate_base_location_address( $address, null ) );
+	}
+
+	/**
+	 * @testdox Fills only the city when the store has a city but no postcode.
+	 */
+	public function test_fills_only_city_when_store_has_no_postcode(): void {
+		update_option( 'woocommerce_store_postcode', '' );
+
+		$result = $this->sut->estimate_base_location_address( array( 'US', 'CA', '', '' ), null );
+
+		$this->assertSame( array( 'US', 'CA', '', 'San Francisco' ), $result );
+	}
+
+	/**
+	 * @testdox Treats a store postcode that normalizes to nothing as no postcode at all.
+	 * @testWith ["-"]
+	 *           ["  "]
+	 *           ["--"]
+	 *
+	 * @param string $stored Store postcode whose characters all normalize away.
+	 */
+	public function test_postcode_that_normalizes_to_nothing_is_not_formatted( string $stored ): void {
+		update_option( 'woocommerce_default_country', 'BR:SP' );
+		update_option( 'woocommerce_store_postcode', $stored );
+
+		$result = $this->sut->estimate_base_location_address( array( 'BR', 'SP', '', '' ), null );
+
+		$this->assertSame( array( 'BR', 'SP', '', 'San Francisco' ), $result );
+	}
+
+	/**
+	 * @testdox Leaves the postcode empty, not a bare separator, when a store has no postcode.
+	 * @testWith ["BR", "SP"]
+	 *           ["PL", ""]
+	 *           ["JP", ""]
+	 *           ["PT", ""]
+	 *
+	 * @param string $country Store base country whose postcode format uses a separator.
+	 * @param string $state   Store base state, where the country has them.
+	 */
+	public function test_no_postcode_never_becomes_a_separator( string $country, string $state ): void {
+		update_option( 'woocommerce_default_country', '' === $state ? $country : $country . ':' . $state );
+		update_option( 'woocommerce_store_postcode', '' );
+
+		$result = $this->sut->estimate_base_location_address( array( $country, $state, '', '' ), null );
+
+		$this->assertSame( array( $country, $state, '', 'San Francisco' ), $result );
+	}
+
+	/**
+	 * @testdox Returns a clean numeric 4-tuple even when the taxable address is keyed associatively.
+	 */
+	public function test_normalizes_non_numeric_taxable_address_when_filling(): void {
+		$result = $this->sut->estimate_base_location_address(
+			array(
+				'country'  => 'US',
+				'state'    => 'CA',
+				'postcode' => '',
+				'city'     => '',
+			),
+			null
+		);
+
+		$this->assertSame( array( 'US', 'CA', '94110', 'San Francisco' ), $result );
+	}
+
+	/**
+	 * @testdox Keeps a trailing field (such as a tax provider's street) when filling postcode and city.
+	 */
+	public function test_preserves_trailing_fields_when_filling(): void {
+		$result = $this->sut->estimate_base_location_address( array( 'US', 'CA', '', '', '123 Main St' ), null );
+
+		$this->assertSame( array( 'US', 'CA', '94110', 'San Francisco', '123 Main St' ), $result );
+	}
+
+	/**
+	 * @testdox The estimate reaches shipping-based taxable addresses without changing the stored address.
+	 */
+	public function test_estimate_applies_to_shipping_based_tax(): void {
+		update_option( 'woocommerce_tax_based_on', 'shipping' );
+		$this->sut->register();
+
+		$customer = new WC_Customer();
+		$customer->set_shipping_country( 'US' );
+		$customer->set_shipping_state( 'CA' );
+
+		$taxable = $customer->get_taxable_address();
+
+		$this->assertSame( '94110', $taxable[2], 'The tax lookup should use the estimated store postcode.' );
+		$this->assertSame( 'San Francisco', $taxable[3], 'The tax lookup should use the estimated store city.' );
+		$this->assertSame( '', $customer->get_shipping_postcode(), 'The stored shipping postcode must stay empty.' );
+		$this->assertSame( '', $customer->get_shipping_city(), 'The stored shipping city must stay empty.' );
+	}
+
+	/**
+	 * @testdox The estimate reaches the taxable address without changing the stored customer address.
+	 */
+	public function test_estimate_leaves_the_stored_customer_address_untouched(): void {
+		update_option( 'woocommerce_tax_based_on', 'billing' );
+		$this->sut->register();
+
+		$customer = new WC_Customer();
+		$customer->set_billing_country( 'US' );
+		$customer->set_billing_state( 'CA' );
+
+		$taxable = $customer->get_taxable_address();
+
+		$this->assertSame( '94110', $taxable[2], 'The tax lookup should use the estimated store postcode.' );
+		$this->assertSame( 'San Francisco', $taxable[3], 'The tax lookup should use the estimated store city.' );
+		$this->assertSame( '', $customer->get_billing_postcode(), 'The stored billing postcode must stay empty.' );
+		$this->assertSame( '', $customer->get_billing_city(), 'The stored billing city must stay empty.' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Tax/ShopBaseTaxEstimateNoticeTest.php b/plugins/woocommerce/tests/php/src/Internal/Tax/ShopBaseTaxEstimateNoticeTest.php
new file mode 100644
index 00000000000..e407ce3522f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Tax/ShopBaseTaxEstimateNoticeTest.php
@@ -0,0 +1,352 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Tax;
+
+use Automattic\WooCommerce\Internal\Tax\ShopBaseTaxEstimateNotice;
+use WC_Tax;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ShopBaseTaxEstimateNotice class.
+ */
+class ShopBaseTaxEstimateNoticeTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var ShopBaseTaxEstimateNotice
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new ShopBaseTaxEstimateNotice();
+
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_default_customer_address', 'base' );
+		update_option( 'woocommerce_tax_based_on', 'shipping' );
+		update_option( 'woocommerce_default_country', 'US:CA' );
+		update_option( 'woocommerce_store_postcode', '94110' );
+		update_option( 'woocommerce_store_city', 'San Francisco' );
+		delete_option( 'wc_connect_taxes_enabled' );
+	}
+
+	/**
+	 * Insert a standard-class tax rate, optionally scoped to postcodes or cities.
+	 *
+	 * @param array    $overrides Rate field overrides.
+	 * @param string[] $postcodes Postcodes to scope the rate to.
+	 * @param string[] $cities    Cities to scope the rate to.
+	 * @return int
+	 */
+	private function add_standard_rate( array $overrides = array(), array $postcodes = array(), array $cities = array() ): int {
+		$rate_id = WC_Tax::_insert_tax_rate(
+			array_merge(
+				array(
+					'tax_rate_country'  => 'US',
+					'tax_rate_state'    => 'CA',
+					'tax_rate'          => '20.0000',
+					'tax_rate_name'     => 'CA',
+					'tax_rate_priority' => 1,
+					'tax_rate_compound' => 0,
+					'tax_rate_shipping' => 1,
+					'tax_rate_order'    => 0,
+					'tax_rate_class'    => '',
+				),
+				$overrides
+			)
+		);
+
+		if ( ! empty( $postcodes ) ) {
+			WC_Tax::_update_tax_rate_postcodes( $rate_id, implode( ';', $postcodes ) );
+		}
+		if ( ! empty( $cities ) ) {
+			WC_Tax::_update_tax_rate_cities( $rate_id, implode( ';', $cities ) );
+		}
+
+		return $rate_id;
+	}
+
+	/**
+	 * @testdox Shows nothing when taxes are disabled.
+	 */
+	public function test_hidden_when_taxes_disabled(): void {
+		update_option( 'woocommerce_calc_taxes', 'no' );
+
+		$this->assertSame(
+			array(
+				'estimate' => '',
+				'warning'  => '',
+			),
+			$this->sut->get_notice()
+		);
+	}
+
+	/**
+	 * @testdox Hides by default when an automated tax provider is enabled.
+	 */
+	public function test_hidden_when_automated_taxes_enabled(): void {
+		update_option( 'wc_connect_taxes_enabled', 'yes' );
+
+		$this->assertSame(
+			array(
+				'estimate' => '',
+				'warning'  => '',
+			),
+			$this->sut->get_notice()
+		);
+	}
+
+	/**
+	 * @testdox The automated-tax default can be overridden with the filter.
+	 */
+	public function test_filter_can_force_the_estimate_on(): void {
+		update_option( 'wc_connect_taxes_enabled', 'yes' );
+		add_filter( 'woocommerce_show_shop_base_tax_estimate', '__return_true' );
+
+		$notice = $this->sut->get_notice();
+		remove_filter( 'woocommerce_show_shop_base_tax_estimate', '__return_true' );
+
+		$this->assertNotSame( '', $notice['estimate'] );
+	}
+
+	/**
+	 * @testdox Explains that no tax is shown when there is no default customer location.
+	 */
+	public function test_no_location_message(): void {
+		update_option( 'woocommerce_default_customer_address', '' );
+
+		$notice = $this->sut->get_notice();
+
+		$this->assertStringContainsString( 'No tax is shown until the customer enters their address', $notice['estimate'] );
+		$this->assertSame( '', $notice['warning'] );
+	}
+
+	/**
+	 * @testdox Explains that the estimate comes from the detected location when geolocating.
+	 */
+	public function test_geolocation_message(): void {
+		update_option( 'woocommerce_default_customer_address', 'geolocation' );
+
+		$notice = $this->sut->get_notice();
+
+		$this->assertStringContainsString( 'estimated from their detected country and state', $notice['estimate'] );
+		$this->assertSame( '', $notice['warning'] );
+	}
+
+	/**
+	 * @testdox In shop-base tax mode the base-address message wins even with no default customer location.
+	 */
+	public function test_shop_base_tax_mode_overrides_no_location(): void {
+		update_option( 'woocommerce_tax_based_on', 'base' );
+		update_option( 'woocommerce_default_customer_address', '' );
+		$this->add_standard_rate();
+
+		$notice = $this->sut->get_notice();
+
+		$this->assertStringContainsString( 'All orders are taxed at your store base address', $notice['estimate'] );
+		$this->assertStringNotContainsString( 'No tax is shown until the customer enters their address', $notice['estimate'] );
+	}
+
+	/**
+	 * @testdox In shop-base tax mode the base-address message wins even when geolocating.
+	 */
+	public function test_shop_base_tax_mode_overrides_geolocation(): void {
+		update_option( 'woocommerce_tax_based_on', 'base' );
+		update_option( 'woocommerce_default_customer_address', 'geolocation' );
+		$this->add_standard_rate();
+
+		$notice = $this->sut->get_notice();
+
+		$this->assertStringContainsString( 'All orders are taxed at your store base address', $notice['estimate'] );
+		$this->assertStringNotContainsString( 'estimated from their detected country and state', $notice['estimate'] );
+	}
+
+	/**
+	 * @testdox Shows the store address, the per-class rates, and a way to change it for customer-based tax.
+	 */
+	public function test_shop_base_customer_mode_shows_rates(): void {
+		$this->add_standard_rate();
+
+		$notice = $this->sut->get_notice();
+
+		$this->assertStringContainsString( 'tax is estimated at your store base address', $notice['estimate'] );
+		$this->assertStringContainsString( 'San Francisco, 94110', $notice['estimate'] );
+		$this->assertStringContainsString( 'Standard rate: 20%', $notice['estimate'] );
+		$this->assertStringContainsString( 'Change this with Default customer location', $notice['estimate'] );
+		$this->assertSame( '', $notice['warning'] );
+	}
+
+	/**
+	 * @testdox Says all orders are taxed at the base and omits the change hint in shop-base tax mode.
+	 */
+	public function test_shop_base_tax_mode_message(): void {
+		update_option( 'woocommerce_tax_based_on', 'base' );
+		$this->add_standard_rate();
+
+		$notice = $this->sut->get_notice();
+
+		$this->assertStringContainsString( 'All orders are taxed at your store base address', $notice['estimate'] );
+		$this->assertStringNotContainsString( 'Change this with Default customer location', $notice['estimate'] );
+	}
+
+	/**
+	 * @testdox Shows a country-and-state lead when the store has no postcode or city.
+	 */
+	public function test_empty_store_address_shows_country_and_state_lead(): void {
+		update_option( 'woocommerce_store_postcode', '' );
+		update_option( 'woocommerce_store_city', '' );
+		$this->add_standard_rate();
+
+		$notice = $this->sut->get_notice();
+
+		$this->assertStringContainsString( 'country and state only', $notice['estimate'] );
+		$this->assertSame( '', $notice['warning'] );
+	}
+
+	/**
+	 * @testdox The no-rate warning drops the un-addressed qualifier in shop-base tax mode.
+	 */
+	public function test_shop_base_tax_mode_no_rate_warning(): void {
+		update_option( 'woocommerce_tax_based_on', 'base' );
+
+		$notice = $this->sut->get_notice();
+
+		$this->assertStringContainsString( 'No standard tax rate matches your store address', $notice['warning'] );
+		$this->assertStringNotContainsString( 'has not entered an address', $notice['warning'] );
+	}
+
+	/**
+	 * @testdox The rate summary reflects the effective rate when a compound rate applies.
+	 */
+	public function test_rate_summary_reflects_compound_rates(): void {
+		$this->add_standard_rate(
+			array(
+				'tax_rate'          => '10.0000',
+				'tax_rate_priority' => 1,
+				'tax_rate_compound' => 0,
+			)
+		);
+		$this->add_standard_rate(
+			array(
+				'tax_rate'          => '10.0000',
+				'tax_rate_priority' => 2,
+				'tax_rate_compound' => 1,
+			)
+		);
+
+		$notice = $this->sut->get_notice();
+
+		$this->assertStringContainsString( 'Standard rate: 21%', $notice['estimate'] );
+	}
+
+	/**
+	 * @testdox Warns when no standard rate matches the store address.
+	 */
+	public function test_warns_when_no_rate_matches(): void {
+		$notice = $this->sut->get_notice();
+
+		$this->assertStringContainsString( 'No standard tax rate matches your store address', $notice['warning'] );
+	}
+
+	/**
+	 * @testdox Warns specifically when scoped rates exist but the store has no postcode or city.
+	 */
+	public function test_warns_when_scoped_rates_but_no_store_address(): void {
+		$this->add_standard_rate( array(), array( '94110' ) );
+		update_option( 'woocommerce_store_postcode', '' );
+		update_option( 'woocommerce_store_city', '' );
+
+		$notice = $this->sut->get_notice();
+
+		$this->assertStringContainsString( 'postcode- or city-scoped standard rates', $notice['warning'] );
+	}
+
+	/**
+	 * @testdox Treats a store postcode that normalizes to nothing as no postcode at all.
+	 * @testWith ["-"]
+	 *           ["  "]
+	 *
+	 * @param string $stored Store postcode whose characters all normalize away.
+	 */
+	public function test_separator_only_postcode_is_not_an_address( string $stored ): void {
+		$this->add_standard_rate( array(), array( '94110' ) );
+		update_option( 'woocommerce_store_postcode', $stored );
+		update_option( 'woocommerce_store_city', '' );
+
+		$notice = $this->sut->get_notice();
+
+		$this->assertStringNotContainsString( $stored . '</strong>', $notice['estimate'] );
+		$this->assertStringContainsString( 'postcode- or city-scoped standard rates', $notice['warning'] );
+	}
+
+	/**
+	 * @testdox Survives a matched-rates callback that returns a non-array.
+	 */
+	public function test_handles_non_array_matched_rates(): void {
+		$break_rates = function () {
+			return null;
+		};
+		add_filter( 'woocommerce_matched_rates', $break_rates );
+
+		try {
+			$notice = $this->sut->get_notice();
+
+			$this->assertStringContainsString( 'your store base address', $notice['estimate'] );
+			$this->assertStringContainsString( 'Standard rate: &ndash;', $notice['estimate'] );
+		} finally {
+			remove_filter( 'woocommerce_matched_rates', $break_rates );
+		}
+	}
+
+	/**
+	 * @testdox Reads each tax class's stored slug, which need not be derived from its name.
+	 */
+	public function test_rate_summary_uses_the_stored_tax_class_slug(): void {
+		$this->assertIsArray(
+			WC_Tax::create_tax_class( 'Digital goods', 'digital' ),
+			'The tax class fixture should have been created.'
+		);
+
+		$this->add_standard_rate(
+			array(
+				'tax_rate'       => '5.0000',
+				'tax_rate_class' => 'digital',
+				'tax_rate_name'  => 'Digital',
+			)
+		);
+
+		$notice = $this->sut->get_notice();
+
+		$this->assertStringContainsString( 'Digital goods: 5%', $notice['estimate'] );
+	}
+
+	/**
+	 * @testdox Warns about scoped rates when the scoped rate uses a wildcard country or state.
+	 * @testWith ["", "CA"]
+	 *           ["US", ""]
+	 *           ["", ""]
+	 *
+	 * @param string $country Rate country, blank for the wildcard that matches every country.
+	 * @param string $state   Rate state, blank for the wildcard that matches every state.
+	 */
+	public function test_warns_when_wildcard_scoped_rates_but_no_store_address( string $country, string $state ): void {
+		$this->add_standard_rate(
+			array(
+				'tax_rate_country' => $country,
+				'tax_rate_state'   => $state,
+			),
+			array( '94110' )
+		);
+		update_option( 'woocommerce_store_postcode', '' );
+		update_option( 'woocommerce_store_city', '' );
+
+		$notice = $this->sut->get_notice();
+
+		$this->assertStringContainsString( 'postcode- or city-scoped standard rates', $notice['warning'] );
+	}
+}