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">–</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">–</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">–</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 ) . ': –';
+ 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 — %s', 'woocommerce' ),
+ implode( ' · ', $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§ion=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: –', $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'] );
+ }
+}