Commit 017b43fa5a1 for woocommerce
commit 017b43fa5a1144e21654158503235ff092bead26
Author: Christopher Hertel <mail@christopher-hertel.de>
Date: Fri Nov 6 00:56:11 2026 +0100
Add shipping zone and tax rate query abilities (#70597)
* Add shipping-zones-query and tax-rates-query abilities
Core had no shipping or tax abilities. Woo AI's REST-bridge zone reads
return only id, name and order, with locations and methods as links.
- shipping-zones-query lists the zones in matching order, plus the
catch-all zone 0, each with its locations and its methods. Methods
are plain nested data with their scalar settings, without secrets;
whether they become their own extensible resource is still open.
Zones are a new extensible resource, shipping_zone.
- tax-rates-query lists the rates of every tax class, with postcodes
and cities, filtered by country and class. Tax rates are table rows
with no object an extension could read or change, so they take no
extension fields.
Secrets are kept out of method settings by detection, in a SecretFields
class the settings abilities can reuse: password-type fields, and any
field whose ID or option name has a credential word (api_key, secret,
token, ...). A secret in a text field under a harmless name still gets
through.
* Make shipping methods extensible and page tax rates in SQL
Shipping methods become their own `shipping_method` resource, since
extensions like table rate shipping keep their data on the method, not
the zone. Each method now also returns its order within the zone.
Tax rates were loaded for every class and sliced in PHP, which pulls a
whole imported postcode table into memory on each call. The query now
filters and pages in SQL and loads the postcodes and cities of a page in
one query.
* Take include and return total from tax-rates-query
List abilities take the IDs to return in `include` and report the number
of matches across all pages in `total`, as product-terms-query does.
tax-rates-query had neither, so a caller could not fetch one rate by ID
or tell how many rates matched without paging through all of them.
* Read shipping method settings through the method's getters
The settings and their field types came from the instance_settings and
instance_form_fields properties. A method that overrides
get_instance_form_fields(), or gains fields through the
woocommerce_shipping_instance_form_fields_{id} filter, never puts them
in that property, so a password field there was only caught by its name.
A method that does not load its settings in the constructor returned
none at all.
Load the settings with init_instance_settings() and list the fields of
get_instance_form_fields(), as the shipping zone methods REST endpoint
does. Stored values without a declared field are left out.
* Load shipping zones once in shipping-zones-query
get_zones() read every zone and its methods, and execute() then built
each zone again and loaded its methods a second time. Read the zones
with get_shipping_zones(), which loads them in one batch without their
methods.
* Order tax rates by the store's tax class order
tax-rates-query sorted rates by class slug, while the tax settings list
the standard rates first and then the classes by name. A class whose
slug does not follow its name, such as one created with its own slug,
came out of place. Sort by the class order the settings use, with rates
of a deleted class last.
* Test shop manager access and the tax class filter
The PR says shop managers can use both abilities, but only an editor's
refusal was tested, and the class filter only with an unknown class.
* Fix the shipping and tax ability tests for the current field API
The registry reset reflected on ExtensibleAbilities::$reported, which no
longer exists, so every test errored in setUp. It now resets the same
registries as ExtensibleAbilitiesTest, including $syncs.
The test field declared its output type as array( 'string', 'null' ),
which register_field() refuses, so no extension field was added. It now
declares a single string type.
* Trust the input schema in the shipping and tax query abilities
Ability now converts the input to the schema types and validates it
before execute() runs, so the casts, intval maps and page clamps only
repeated what the schema guarantees.
* Recognize joined credential names and license keys as secrets
Names such as apikey, APIKey, accesskey or secretkey normalize to a
single word, so the whole-word match missed them and a shipping method
setting under such a name was returned. List the joined spellings of
the compound credential words too, and treat license keys as
credentials.
* Recognize the British spelling of licence keys as secrets
* Test the compound and shipping flags of tax rates
* Give each tax rate opposite compound and shipping flags in the test
With both flags equal on each rate, a formatter that read one flag for
both would still pass.
* Return settings only for Core shipping methods, without extension fields
Extensions keep carrier credentials in method settings under names such
as account_pass or meter_number, which no name list can tell from safe
ones, and a name list also dropped harmless fields like
require_signature. Settings now come back only for flat rate, free
shipping and local pickup, limited to the fields those classes declare,
so fields that extensions add through the form fields filter stay out.
SecretFields is no longer needed.
The shipping_zone and shipping_method resources are left out until the
abilities opt into extension fields together: shipping objects have no
get_type(), so a field with object_types would fail every zone query,
and the zone query applies no query filters.
* Read Core shipping method settings from a fresh instance
A woocommerce_shipping_zone_shipping_methods callback can add fields to
the method objects of a zone, so their instance_form_fields can hold
extension fields.
diff --git a/plugins/woocommerce/changelog/shipping-tax-abilities b/plugins/woocommerce/changelog/shipping-tax-abilities
new file mode 100644
index 00000000000..557eed31424
--- /dev/null
+++ b/plugins/woocommerce/changelog/shipping-tax-abilities
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add abilities that list shipping zones with their regions and methods, and tax rates.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index b4bdebab1db..c2aa8b54b5d 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -23,6 +23,8 @@ use Automattic\WooCommerce\Internal\Abilities\Domain\ProductDelete;
use Automattic\WooCommerce\Internal\Abilities\Domain\ProductUpdate;
use Automattic\WooCommerce\Internal\Abilities\Domain\ProductsQuery;
use Automattic\WooCommerce\Internal\Abilities\Domain\ProductTermsQuery;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ShippingZonesQuery;
+use Automattic\WooCommerce\Internal\Abilities\Domain\TaxRatesQuery;
defined( 'ABSPATH' ) || exit;
@@ -56,6 +58,8 @@ class AbilitiesLoader {
ProductCreate::class,
ProductDelete::class,
ProductUpdate::class,
+ ShippingZonesQuery::class,
+ TaxRatesQuery::class,
);
/**
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ShippingZonesQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ShippingZonesQuery.php
new file mode 100644
index 00000000000..413123d7358
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ShippingZonesQuery.php
@@ -0,0 +1,244 @@
+<?php
+/**
+ * Shipping zones query ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the ability that lists shipping zones with their locations and methods.
+ */
+class ShippingZonesQuery extends AbstractDomainAbility implements AbilityDefinition {
+
+ /**
+ * Methods whose settings are returned. Extensions keep credentials in method settings under any name.
+ */
+ private const CORE_METHOD_CLASSES = array( \WC_Shipping_Flat_Rate::class, \WC_Shipping_Free_Shipping::class, \WC_Shipping_Local_Pickup::class );
+
+ /**
+ * Get the ability name.
+ *
+ * @return string
+ *
+ * @since 11.4.0
+ */
+ public static function get_name(): string {
+ return 'woocommerce/shipping-zones-query';
+ }
+
+ /**
+ * Get the ability registration arguments.
+ *
+ * @return array
+ *
+ * @since 11.4.0
+ */
+ public static function get_registration_args(): array {
+ return array(
+ 'label' => __( 'Query shipping zones', 'woocommerce' ),
+ 'description' => __( 'List shipping zones in matching order, with the regions each covers and its shipping methods. Zone 0 covers every location no other zone matches.', 'woocommerce' ),
+ 'category' => 'woocommerce',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'include' => array(
+ 'type' => 'array',
+ 'description' => __( 'Return only these zone IDs.', 'woocommerce' ),
+ 'items' => array(
+ 'type' => 'integer',
+ 'minimum' => 0,
+ ),
+ ),
+ ),
+ 'additionalProperties' => false,
+ 'default' => array(),
+ ),
+ 'output_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'zones' => array(
+ 'type' => 'array',
+ 'items' => self::get_zone_output_schema(),
+ ),
+ ),
+ 'additionalProperties' => false,
+ ),
+ 'execute_callback' => array( __CLASS__, 'execute' ),
+ 'permission_callback' => array( __CLASS__, 'can_query_shipping' ),
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => true,
+ 'idempotent' => true,
+ 'destructive' => false,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * List shipping zones.
+ *
+ * @param array $input Ability input.
+ * @return array
+ *
+ * @since 11.4.0
+ */
+ public static function execute( array $input ): array {
+ $zones = array_values( \WC_Shipping_Zones::get_shipping_zones() );
+ $zones[] = new \WC_Shipping_Zone( 0 );
+
+ if ( isset( $input['include'] ) ) {
+ $include = $input['include'];
+ $zones = array_filter(
+ $zones,
+ static function ( \WC_Shipping_Zone $zone ) use ( $include ): bool {
+ return in_array( $zone->get_id(), $include, true );
+ }
+ );
+ }
+
+ return array( 'zones' => array_values( array_map( array( __CLASS__, 'format_zone_for_response' ), $zones ) ) );
+ }
+
+ /**
+ * Check shipping settings read access.
+ *
+ * @return bool
+ *
+ * @since 11.4.0
+ */
+ public static function can_query_shipping(): bool {
+ return wc_rest_check_manager_permissions( 'settings', 'read' );
+ }
+
+ /**
+ * Format a zone for ability output.
+ *
+ * @param \WC_Shipping_Zone $zone Zone.
+ * @return array
+ */
+ private static function format_zone_for_response( \WC_Shipping_Zone $zone ): array {
+ return array(
+ 'id' => $zone->get_id(),
+ 'name' => $zone->get_zone_name(),
+ 'order' => $zone->get_zone_order(),
+ 'locations' => array_map(
+ static function ( $location ): array {
+ return array(
+ 'code' => (string) $location->code,
+ 'type' => (string) $location->type,
+ );
+ },
+ array_values( $zone->get_zone_locations() )
+ ),
+ 'methods' => array_map( array( __CLASS__, 'format_method' ), array_values( $zone->get_shipping_methods() ) ),
+ );
+ }
+
+ /**
+ * Format a shipping method of a zone. Only Core methods get settings, limited to the
+ * fields Core declares, since extensions can add fields to them through filters.
+ *
+ * @param \WC_Shipping_Method $method Method.
+ * @return array
+ */
+ private static function format_method( \WC_Shipping_Method $method ): array {
+ $data = array(
+ 'instance_id' => (int) $method->get_instance_id(),
+ 'method_id' => (string) $method->id,
+ 'title' => (string) $method->get_title(),
+ 'enabled' => 'yes' === $method->enabled,
+ 'order' => (int) $method->method_order,
+ );
+
+ $class = get_class( $method );
+ if ( in_array( $class, self::CORE_METHOD_CLASSES, true ) ) {
+ // A fresh instance, since a woocommerce_shipping_zone_shipping_methods callback can change the zone's one.
+ $core = new $class( $method->get_instance_id() );
+ $core->init_instance_settings();
+ $settings = array();
+ foreach ( array_keys( $core->instance_form_fields ) as $key ) {
+ $value = $core->instance_settings[ $key ] ?? null;
+ if ( 'title' !== $key && is_scalar( $value ) ) {
+ $settings[ $key ] = (string) $value;
+ }
+ }
+ $data['settings'] = (object) $settings;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get the schema of a zone in a response.
+ *
+ * @return array
+ */
+ private static function get_zone_output_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'integer' ),
+ 'name' => array( 'type' => 'string' ),
+ 'order' => array( 'type' => 'integer' ),
+ 'locations' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'code' => array(
+ 'type' => 'string',
+ 'description' => __( 'Country code, COUNTRY:STATE, postcode pattern or continent code, by type.', 'woocommerce' ),
+ ),
+ 'type' => array(
+ 'type' => 'string',
+ 'enum' => array( 'country', 'state', 'postcode', 'continent' ),
+ ),
+ ),
+ 'additionalProperties' => false,
+ ),
+ ),
+ 'methods' => array(
+ 'type' => 'array',
+ 'items' => self::get_method_output_schema(),
+ ),
+ ),
+ 'additionalProperties' => false,
+ );
+ }
+
+ /**
+ * Get the schema of a shipping method in a response.
+ *
+ * @return array
+ */
+ private static function get_method_output_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'instance_id' => array( 'type' => 'integer' ),
+ 'method_id' => array( 'type' => 'string' ),
+ 'title' => array( 'type' => 'string' ),
+ 'enabled' => array( 'type' => 'boolean' ),
+ 'order' => array( 'type' => 'integer' ),
+ 'settings' => array(
+ 'type' => 'object',
+ 'description' => __( 'Settings such as cost or min_amount, as stored. Only for the flat rate, free shipping and local pickup methods.', 'woocommerce' ),
+ 'additionalProperties' => array( 'type' => 'string' ),
+ ),
+ ),
+ 'additionalProperties' => false,
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/TaxRatesQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/TaxRatesQuery.php
new file mode 100644
index 00000000000..195ee1c23e4
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/TaxRatesQuery.php
@@ -0,0 +1,281 @@
+<?php
+/**
+ * Tax rates query ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the ability that lists tax rates. Tax rates are table rows with
+ * no object an extension could read or change, so they take no extension fields.
+ */
+class TaxRatesQuery extends AbstractDomainAbility implements AbilityDefinition {
+
+ /**
+ * Get the ability name.
+ *
+ * @return string
+ *
+ * @since 11.4.0
+ */
+ public static function get_name(): string {
+ return 'woocommerce/tax-rates-query';
+ }
+
+ /**
+ * Get the ability registration arguments.
+ *
+ * @return array
+ *
+ * @since 11.4.0
+ */
+ public static function get_registration_args(): array {
+ $output_schema = self::get_collection_output_schema( 'rates', self::get_rate_output_schema() );
+ $output_schema['properties']['total'] = array(
+ 'type' => 'integer',
+ 'description' => __( 'Total number of matching rates across all pages.', 'woocommerce' ),
+ );
+
+ return array(
+ 'label' => __( 'Query tax rates', 'woocommerce' ),
+ 'description' => __( 'List the tax rates of every tax class, in the order of the tax settings.', 'woocommerce' ),
+ 'category' => 'woocommerce',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'include' => array(
+ 'type' => 'array',
+ 'description' => __( 'Return only these tax rate IDs.', 'woocommerce' ),
+ 'minItems' => 1,
+ 'items' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ ),
+ 'country' => array(
+ 'type' => 'string',
+ 'description' => __( 'Two-letter country code. Rates for every country are included too.', 'woocommerce' ),
+ 'pattern' => '^[A-Za-z]{2}$',
+ ),
+ 'class' => array(
+ 'type' => 'string',
+ 'description' => __( 'Tax class slug; standard for the standard rates.', 'woocommerce' ),
+ ),
+ 'page' => array(
+ 'type' => 'integer',
+ 'default' => 1,
+ 'minimum' => 1,
+ ),
+ 'per_page' => array(
+ 'type' => 'integer',
+ 'default' => 50,
+ 'minimum' => 1,
+ 'maximum' => 100,
+ ),
+ ),
+ 'additionalProperties' => false,
+ 'default' => array(),
+ ),
+ 'output_schema' => $output_schema,
+ 'execute_callback' => array( __CLASS__, 'execute' ),
+ 'permission_callback' => array( __CLASS__, 'can_query_taxes' ),
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => true,
+ 'idempotent' => true,
+ 'destructive' => false,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * List tax rates.
+ *
+ * @param array $input Ability input.
+ * @return array
+ *
+ * @since 11.4.0
+ */
+ public static function execute( array $input ): array {
+ global $wpdb;
+
+ $page = $input['page'] ?? 1;
+ $per_page = $input['per_page'] ?? 50;
+
+ $conditions = array( '1=1' );
+ if ( isset( $input['include'] ) ) {
+ $include = $input['include'];
+ $placeholders = implode( ', ', array_fill( 0, count( $include ), '%d' ) );
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- The placeholders are generated for each ID.
+ $conditions[] = $wpdb->prepare( "tax_rate_id IN ({$placeholders})", $include );
+ }
+ if ( isset( $input['class'] ) ) {
+ $tax_class = sanitize_title( $input['class'] );
+ if ( 'standard' !== $tax_class && ! in_array( $tax_class, \WC_Tax::get_tax_class_slugs(), true ) ) {
+ return array(
+ 'rates' => array(),
+ 'total' => 0,
+ 'total_pages' => 0,
+ 'page' => $page,
+ 'per_page' => $per_page,
+ );
+ }
+ $conditions[] = $wpdb->prepare( 'tax_rate_class = %s', 'standard' === $tax_class ? '' : $tax_class );
+ }
+ if ( isset( $input['country'] ) ) {
+ $conditions[] = $wpdb->prepare( "tax_rate_country IN ( '', %s )", strtoupper( $input['country'] ) );
+ }
+ $where = implode( ' AND ', $conditions );
+
+ // Standard first, then the classes in the order the tax settings list them; rates of a deleted class last.
+ $class_slugs = \WC_Tax::get_tax_class_slugs();
+ $class_order = "WHEN '' THEN 0";
+ foreach ( $class_slugs as $index => $slug ) {
+ $class_order .= $wpdb->prepare( ' WHEN %s THEN %d', $slug, $index + 1 );
+ }
+ $class_order = "CASE tax_rate_class {$class_order} ELSE " . ( count( $class_slugs ) + 1 ) . ' END';
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Each condition and the class order are prepared above.
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT * FROM {$wpdb->prefix}woocommerce_tax_rates WHERE {$where} ORDER BY {$class_order}, tax_rate_order, tax_rate_id LIMIT %d, %d",
+ ( $page - 1 ) * $per_page,
+ $per_page
+ )
+ );
+ $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_tax_rates WHERE {$where}" );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ $locations = self::get_locations( array_map( 'intval', wp_list_pluck( $rows, 'tax_rate_id' ) ) );
+
+ return array(
+ 'rates' => array_map(
+ static fn ( \stdClass $rate ): array => self::format_rate_for_response( $rate, $locations[ (int) $rate->tax_rate_id ] ?? array() ),
+ $rows
+ ),
+ 'total' => $total,
+ 'total_pages' => (int) ceil( $total / $per_page ),
+ 'page' => $page,
+ 'per_page' => $per_page,
+ );
+ }
+
+ /**
+ * Check tax settings read access.
+ *
+ * @return bool
+ *
+ * @since 11.4.0
+ */
+ public static function can_query_taxes(): bool {
+ return wc_rest_check_manager_permissions( 'settings', 'read' );
+ }
+
+ /**
+ * Get the postcodes and cities of the given tax rates in one query.
+ *
+ * @param int[] $rate_ids Tax rate IDs.
+ * @return array<int, array{postcodes?: string[], cities?: string[]}> Locations keyed by tax rate ID.
+ */
+ private static function get_locations( array $rate_ids ): array {
+ global $wpdb;
+
+ if ( empty( $rate_ids ) ) {
+ return array();
+ }
+
+ $placeholders = implode( ', ', array_fill( 0, count( $rate_ids ), '%d' ) );
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- The placeholders are generated for each ID.
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT tax_rate_id, location_code, location_type FROM {$wpdb->prefix}woocommerce_tax_rate_locations WHERE tax_rate_id IN ({$placeholders}) ORDER BY location_id",
+ $rate_ids
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+
+ $locations = array();
+ foreach ( $rows as $row ) {
+ $key = 'postcode' === $row->location_type ? 'postcodes' : ( 'city' === $row->location_type ? 'cities' : null );
+ if ( null !== $key ) {
+ $locations[ (int) $row->tax_rate_id ][ $key ][] = (string) $row->location_code;
+ }
+ }
+
+ return $locations;
+ }
+
+ /**
+ * Format a tax rate row.
+ *
+ * @param \stdClass $rate Row of the tax rates table.
+ * @param array $locations Postcodes and cities of the rate.
+ * @return array
+ */
+ private static function format_rate_for_response( \stdClass $rate, array $locations ): array {
+ return array(
+ 'id' => (int) $rate->tax_rate_id,
+ 'country' => (string) $rate->tax_rate_country,
+ 'state' => (string) $rate->tax_rate_state,
+ 'postcodes' => $locations['postcodes'] ?? array(),
+ 'cities' => $locations['cities'] ?? array(),
+ 'rate' => (string) $rate->tax_rate,
+ 'name' => (string) $rate->tax_rate_name,
+ 'priority' => (int) $rate->tax_rate_priority,
+ 'compound' => (bool) $rate->tax_rate_compound,
+ 'shipping' => (bool) $rate->tax_rate_shipping,
+ 'class' => '' === (string) $rate->tax_rate_class ? 'standard' : (string) $rate->tax_rate_class,
+ );
+ }
+
+ /**
+ * Get the schema of a tax rate in a response.
+ *
+ * @return array
+ */
+ private static function get_rate_output_schema(): array {
+ $list = array(
+ 'type' => 'array',
+ 'items' => array( 'type' => 'string' ),
+ );
+
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'integer' ),
+ 'country' => array(
+ 'type' => 'string',
+ 'description' => __( 'Empty for every country.', 'woocommerce' ),
+ ),
+ 'state' => array( 'type' => 'string' ),
+ 'postcodes' => $list,
+ 'cities' => $list,
+ 'rate' => array(
+ 'type' => 'string',
+ 'description' => __( 'Percentage as a decimal string.', 'woocommerce' ),
+ ),
+ 'name' => array( 'type' => 'string' ),
+ 'priority' => array( 'type' => 'integer' ),
+ 'compound' => array( 'type' => 'boolean' ),
+ 'shipping' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'Whether the rate also applies to shipping.', 'woocommerce' ),
+ ),
+ 'class' => array( 'type' => 'string' ),
+ ),
+ 'additionalProperties' => false,
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ShippingAndTaxAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ShippingAndTaxAbilitiesTest.php
new file mode 100644
index 00000000000..5ec8f36082c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ShippingAndTaxAbilitiesTest.php
@@ -0,0 +1,425 @@
+<?php
+/**
+ * ShippingAndTaxAbilitiesTest class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
+
+/**
+ * Tests for the shipping-zones-query and tax-rates-query abilities.
+ */
+class ShippingAndTaxAbilitiesTest extends \WC_Unit_Test_Case {
+
+ /**
+ * Administrator used as the current user.
+ *
+ * @var int
+ */
+ private static $administrator_id;
+
+ /**
+ * Init action count restored in tearDown.
+ *
+ * @var int|null
+ */
+ private $original_init_count;
+
+ /**
+ * Create the administrator once for the class.
+ *
+ * @param \WP_UnitTest_Factory $factory WordPress unit test factory.
+ */
+ public static function wpSetUpBeforeClass( $factory ): void {
+ self::$administrator_id = $factory->user->create( array( 'role' => 'administrator' ) );
+ }
+
+ /**
+ * Start each test from an empty abilities registry, so the abilities register again.
+ */
+ public function setUp(): void {
+ global $wp_actions, $wpdb;
+
+ if ( ! function_exists( 'wp_register_ability' ) ) {
+ require_once WC_ABSPATH . 'vendor/wordpress/abilities-api/includes/bootstrap.php';
+ }
+
+ // The Abilities API registry refuses to initialize before `init` has fired.
+ $this->original_init_count = $wp_actions['init'] ?? null;
+ $wp_actions['init'] = max( 1, (int) ( $wp_actions['init'] ?? 0 ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+ parent::setUp();
+ $wpdb->query( "DELETE FROM {$wpdb->prefix}woocommerce_tax_rate_locations" );
+ $wpdb->query( "DELETE FROM {$wpdb->prefix}woocommerce_tax_rates" );
+
+ wc_get_container()->get( AbilitiesLoader::class )->init();
+ self::reset_registries();
+ wp_set_current_user( self::$administrator_id );
+ }
+
+ /**
+ * Leave the registries and the init count as the next test expects them.
+ */
+ public function tearDown(): void {
+ global $wp_actions;
+
+ try {
+ self::reset_registries();
+ } finally {
+ parent::tearDown();
+ }
+
+ if ( null !== $this->original_init_count ) {
+ $wp_actions['init'] = $this->original_init_count; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ } else {
+ unset( $wp_actions['init'] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ }
+ }
+
+ /**
+ * Run an ability as the current user, with its permission check.
+ *
+ * @param string $name Ability name.
+ * @param array $input Ability input.
+ * @return mixed|\WP_Error
+ */
+ private function run_ability( string $name, array $input = array() ) {
+ $ability = wp_get_ability( $name );
+ $this->assertNotNull( $ability, "Ability {$name} should be registered." );
+
+ return $ability->execute( $input );
+ }
+
+ /**
+ * Drop every registered ability, so the next read registers them again.
+ */
+ private static function reset_registries(): void {
+ global $wp_actions;
+
+ foreach ( array( 'WP_Abilities_Registry', 'WP_Ability_Categories_Registry' ) as $registry ) {
+ if ( class_exists( $registry ) ) {
+ $instance = new \ReflectionProperty( $registry, 'instance' );
+ $instance->setAccessible( true );
+ $instance->setValue( null, null );
+ }
+ }
+ unset( $wp_actions['wp_abilities_api_init'], $wp_actions['wp_abilities_api_categories_init'] );
+ }
+
+ /**
+ * @testdox Should return each zone with its locations and methods, plus the catch-all zone.
+ */
+ public function test_zones_include_locations_and_methods(): void {
+ $zone = new \WC_Shipping_Zone();
+ $zone->set_zone_name( 'Portugal' );
+ $zone->add_location( 'PT', 'country' );
+ $zone->save();
+ $instance_id = $zone->add_shipping_method( 'flat_rate' );
+ update_option( "woocommerce_flat_rate_{$instance_id}_settings", array( 'cost' => '4.50' ) );
+
+ $result = $this->run_ability( 'woocommerce/shipping-zones-query' );
+
+ $this->assertNotWPError( $result );
+ $by_id = array_column( $result['zones'], null, 'id' );
+ $this->assertArrayHasKey( 0, $by_id, 'The catch-all zone should be listed.' );
+ $item = $by_id[ $zone->get_id() ];
+ $this->assertSame(
+ array(
+ 'code' => 'PT',
+ 'type' => 'country',
+ ),
+ $item['locations'][0]
+ );
+ $this->assertSame( 'flat_rate', $item['methods'][0]['method_id'] );
+ $this->assertSame( '4.50', ( (array) $item['methods'][0]['settings'] )['cost'] );
+ }
+
+ /**
+ * @testdox Should return only the settings Core declares for its methods, not those added through the form fields filter.
+ */
+ public function test_core_method_settings_leave_out_added_fields(): void {
+ add_filter(
+ 'woocommerce_shipping_instance_form_fields_flat_rate',
+ static function ( array $fields ): array {
+ $fields['carrier_account'] = array(
+ 'title' => 'Carrier account',
+ 'type' => 'text',
+ );
+ return $fields;
+ }
+ );
+ $instance_id = ( new \WC_Shipping_Zone( 0 ) )->add_shipping_method( 'flat_rate' );
+ update_option(
+ "woocommerce_flat_rate_{$instance_id}_settings",
+ array(
+ 'cost' => '4.50',
+ 'carrier_account' => 'canary-account',
+ 'undeclared' => 'canary-stale',
+ )
+ );
+
+ $result = $this->run_ability( 'woocommerce/shipping-zones-query', array( 'include' => array( 0 ) ) );
+ $settings = (array) $result['zones'][0]['methods'][0]['settings'];
+
+ $this->assertSame( '4.50', $settings['cost'] );
+ $this->assertArrayNotHasKey( 'carrier_account', $settings );
+ $this->assertArrayNotHasKey( 'undeclared', $settings );
+ $this->assertStringNotContainsString( 'canary', (string) wp_json_encode( $result ) );
+ }
+
+ /**
+ * @testdox Should leave out a field that a zone methods filter adds to a Core method object.
+ */
+ public function test_core_method_settings_leave_out_fields_added_to_the_zone_method(): void {
+ add_filter(
+ 'woocommerce_shipping_zone_shipping_methods',
+ static function ( $methods ) {
+ foreach ( $methods as $method ) {
+ $method->instance_form_fields['carrier_account'] = array(
+ 'title' => 'Carrier account',
+ 'type' => 'text',
+ );
+ }
+ return $methods;
+ }
+ );
+ $instance_id = ( new \WC_Shipping_Zone( 0 ) )->add_shipping_method( 'flat_rate' );
+ update_option(
+ "woocommerce_flat_rate_{$instance_id}_settings",
+ array(
+ 'cost' => '4.50',
+ 'carrier_account' => 'canary-account',
+ )
+ );
+
+ $result = $this->run_ability( 'woocommerce/shipping-zones-query', array( 'include' => array( 0 ) ) );
+ $settings = (array) $result['zones'][0]['methods'][0]['settings'];
+
+ $this->assertSame( '4.50', $settings['cost'] );
+ $this->assertArrayNotHasKey( 'carrier_account', $settings );
+ }
+
+ /**
+ * @testdox Should return no settings for a method from an extension, even one that extends a Core method.
+ */
+ public function test_extension_methods_have_no_settings(): void {
+ $carrier = new class() extends \WC_Shipping_Flat_Rate {
+ /**
+ * Give the subclass its own method ID.
+ *
+ * @param int $instance_id Instance ID.
+ */
+ public function __construct( $instance_id = 0 ) {
+ parent::__construct( $instance_id );
+ $this->id = 'test_carrier';
+ }
+ };
+ $register = static function ( array $methods ) use ( $carrier ): array {
+ $methods['test_carrier'] = $carrier;
+ return $methods;
+ };
+ add_filter( 'woocommerce_shipping_methods', $register );
+ $instance_id = ( new \WC_Shipping_Zone( 0 ) )->add_shipping_method( 'test_carrier' );
+ update_option( "woocommerce_test_carrier_{$instance_id}_settings", array( 'cost' => 'canary-cost' ) );
+
+ $result = $this->run_ability( 'woocommerce/shipping-zones-query', array( 'include' => array( 0 ) ) );
+ remove_filter( 'woocommerce_shipping_methods', $register );
+
+ $this->assertSame( 'test_carrier', $result['zones'][0]['methods'][0]['method_id'] );
+ $this->assertArrayNotHasKey( 'settings', $result['zones'][0]['methods'][0] );
+ $this->assertStringNotContainsString( 'canary', (string) wp_json_encode( $result ) );
+ }
+
+ /**
+ * @testdox Should list tax rates of every class with their flags, and filter them by country and by class.
+ */
+ public function test_tax_rates_filter_by_country(): void {
+ \WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'DE',
+ 'tax_rate' => '19.0000',
+ 'tax_rate_name' => 'MwSt',
+ 'tax_rate_class' => '',
+ 'tax_rate_compound' => 0,
+ 'tax_rate_shipping' => 1,
+ )
+ );
+ $reduced = \WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'DE',
+ 'tax_rate' => '7.0000',
+ 'tax_rate_name' => 'MwSt reduced',
+ 'tax_rate_class' => 'reduced-rate',
+ 'tax_rate_compound' => 1,
+ 'tax_rate_shipping' => 0,
+ )
+ );
+ \WC_Tax::_update_tax_rate_postcodes( $reduced, '10115; 10117' );
+ \WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'FR',
+ 'tax_rate' => '20.0000',
+ 'tax_rate_name' => 'TVA',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ $result = $this->run_ability( 'woocommerce/tax-rates-query', array( 'country' => 'de' ) );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( array( 'MwSt', 'MwSt reduced' ), array_column( $result['rates'], 'name' ) );
+ $this->assertFalse( $result['rates'][0]['compound'] );
+ $this->assertTrue( $result['rates'][0]['shipping'] );
+ $this->assertTrue( $result['rates'][1]['compound'] );
+ $this->assertFalse( $result['rates'][1]['shipping'] );
+ $this->assertSame( 'reduced-rate', $result['rates'][1]['class'] );
+ $this->assertSame( array( '10115', '10117' ), $result['rates'][1]['postcodes'] );
+
+ $reduced_only = $this->run_ability( 'woocommerce/tax-rates-query', array( 'class' => 'reduced-rate' ) );
+
+ $this->assertSame( array( $reduced ), array_column( $reduced_only['rates'], 'id' ) );
+ $this->assertSame( 1, $reduced_only['total'] );
+ }
+
+ /**
+ * @testdox Should refuse shipping and tax reads to a user who cannot manage WooCommerce.
+ */
+ public function test_requires_settings_permission(): void {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+
+ $this->assertWPError( $this->run_ability( 'woocommerce/shipping-zones-query' ) );
+ $this->assertWPError( $this->run_ability( 'woocommerce/tax-rates-query' ) );
+ }
+
+ /**
+ * @testdox Should let a shop manager read shipping zones and tax rates.
+ */
+ public function test_shop_manager_can_read(): void {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'shop_manager' ) ) );
+
+ $this->assertNotWPError( $this->run_ability( 'woocommerce/shipping-zones-query' ) );
+ $this->assertNotWPError( $this->run_ability( 'woocommerce/tax-rates-query' ) );
+ }
+
+ /**
+ * @testdox Should page tax rates in the order of the tax settings, and return none for an unknown class.
+ */
+ public function test_tax_rates_are_paged(): void {
+ $ids = array();
+ foreach ( array( 2, 0, 1 ) as $order ) {
+ $ids[ $order ] = \WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'DE',
+ 'tax_rate' => '19.0000',
+ 'tax_rate_order' => $order,
+ )
+ );
+ }
+
+ $first = $this->run_ability( 'woocommerce/tax-rates-query', array( 'per_page' => 2 ) );
+ $second = $this->run_ability(
+ 'woocommerce/tax-rates-query',
+ array(
+ 'per_page' => 2,
+ 'page' => 2,
+ )
+ );
+ $unknown = $this->run_ability( 'woocommerce/tax-rates-query', array( 'class' => 'no-such-class' ) );
+
+ $this->assertSame( array( $ids[0], $ids[1] ), array_column( $first['rates'], 'id' ) );
+ $this->assertSame( array( $ids[2] ), array_column( $second['rates'], 'id' ) );
+ $this->assertSame( 3, $first['total'] );
+ $this->assertSame( 2, $first['total_pages'] );
+ $this->assertSame( array(), $unknown['rates'] );
+ $this->assertSame( 0, $unknown['total'] );
+ }
+
+ /**
+ * @testdox Should list standard rates first, then each class in the order of the tax settings, not by slug.
+ */
+ public function test_tax_rates_follow_the_tax_class_order(): void {
+ \WC_Tax::create_tax_class( 'Alpha rate', 'zz-alpha-rate' );
+ $ids = array();
+ foreach ( array( 'zz-alpha-rate', 'reduced-rate', '' ) as $tax_class ) {
+ $ids[ $tax_class ] = \WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'DE',
+ 'tax_rate' => '7.0000',
+ 'tax_rate_class' => $tax_class,
+ )
+ );
+ }
+
+ try {
+ $result = $this->run_ability( 'woocommerce/tax-rates-query' );
+ } finally {
+ // The class list is cached, and the cache outlives the test's database rollback.
+ \WC_Tax::delete_tax_class_by( 'slug', 'zz-alpha-rate' );
+ }
+
+ $this->assertSame( array( $ids[''], $ids['zz-alpha-rate'], $ids['reduced-rate'] ), array_column( $result['rates'], 'id' ) );
+ }
+
+ /**
+ * @testdox Should return only the included tax rates, in the order of the tax settings.
+ */
+ public function test_tax_rates_filter_by_include(): void {
+ $ids = array();
+ foreach ( array( 0, 1, 2 ) as $order ) {
+ $ids[ $order ] = \WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'DE',
+ 'tax_rate' => '19.0000',
+ 'tax_rate_order' => $order,
+ )
+ );
+ }
+
+ $result = $this->run_ability( 'woocommerce/tax-rates-query', array( 'include' => array( $ids[2], $ids[0] ) ) );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( array( $ids[0], $ids[2] ), array_column( $result['rates'], 'id' ) );
+ $this->assertSame( 2, $result['total'] );
+ }
+
+ /**
+ * @testdox Should load the postcodes and cities of a whole page in one query, whatever the number of rates.
+ */
+ public function test_tax_rate_query_count_does_not_grow_with_the_rates(): void {
+ global $wpdb;
+
+ $this->insert_rate_with_locations( 0 );
+ $this->run_ability( 'woocommerce/tax-rates-query' );
+ $before = $wpdb->num_queries;
+ $this->run_ability( 'woocommerce/tax-rates-query' );
+ $one_rate = $wpdb->num_queries - $before;
+
+ for ( $i = 1; $i < 5; $i++ ) {
+ $this->insert_rate_with_locations( $i );
+ }
+ $before = $wpdb->num_queries;
+ $result = $this->run_ability( 'woocommerce/tax-rates-query' );
+
+ $this->assertCount( 5, $result['rates'] );
+ $this->assertSame( $one_rate, $wpdb->num_queries - $before );
+ }
+
+ /**
+ * Insert a tax rate with one postcode and one city.
+ *
+ * @param int $i Number that makes the postcode and city unique.
+ */
+ private function insert_rate_with_locations( int $i ): void {
+ $id = \WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '5.0000',
+ )
+ );
+ \WC_Tax::_update_tax_rate_postcodes( $id, "1000{$i}" );
+ \WC_Tax::_update_tax_rate_cities( $id, "City {$i}" );
+ }
+}