Commit 05314d4511c for woocommerce
commit 05314d4511c7cc45e049fc2e0fc845aae0e78164
Author: Sam Najian <dev@najian.info>
Date: Fri Nov 13 16:25:59 2026 +0100
Tax a no-shipping order without an address at billing on pay-for-order (#70357)
* Tax a no-shipping order without an address at billing on pay-for-order
An order with no address yet may take any first address when it is paid
through the Store API, but for an order that needs no shipping the
shipping address is not validated, while it still decides the tax when
tax is based on the shipping address.
Ship such an order to its billing address, as checkout already does for
carts that need no shipping.
* Add changelog entry for pay-for-order no-shipping tax address fix
* Address review: preserve needs-shipping filter behavior
Evaluate shipping needs after applying the submitted addresses so public filter callbacks keep seeing the request data. Cover that timing and document the no-shipping address fallback.
* Address CodeRabbit review: preserve complete billing address
* Address CodeRabbit review: validate copied billing address
* Address Bogdan's review: keep shipping for physical orders with no methods
needs_shipping() is false for physical items when the store has no
shipping methods, so a physical order with no address had its request
shipping address replaced by billing. Only copy billing when no product
on the order needs shipping.
* Address CodeRabbit review: honour the needs-shipping filter when methods exist
The items check only applies when shipping is disabled or the store has
no shipping method, where needs_shipping() is false for physical items
regardless of the order. With methods available, a filter that says the
order needs no shipping is honoured and the order ships to billing.
* Add disabled shipping method regression coverage
* Fix pay-for-order tax address without shipping line
* Address Raluca's review: clarify shipping behavior
* Address Raluca's review: tighten changelog wording
diff --git a/docs/apis/store-api/resources-endpoints/checkout-order.md b/docs/apis/store-api/resources-endpoints/checkout-order.md
index ab168fa42b9..f9189af6e6c 100644
--- a/docs/apis/store-api/resources-endpoints/checkout-order.md
+++ b/docs/apis/store-api/resources-endpoints/checkout-order.md
@@ -22,6 +22,8 @@ POST /wc/store/v1/checkout/{ORDER_ID}
| `payment_method` | string | Yes | The ID of the payment method being used to process the payment. |
| `payment_data` | array | No | Data to pass through to the payment method when processing payment. |
+When an order has neither a billing nor a shipping address yet and is treated as not needing shipping, its billing address is also used as its shipping address, and any `shipping_address` sent is ignored. This includes an order whose items are all virtual. When shipping is disabled or no method is registered, it also includes a physical order without an existing shipping line; a physical order with a shipping line keeps the submitted shipping address. When methods are available, the `woocommerce_order_needs_shipping` filter can change whether the order is treated as needing shipping.
+
```sh
curl --header "Nonce: 12345" --request POST https://example-store.com/wp-json/wc/store/v1/checkout/{ORDER_ID} -d '{"key":"wc_order_oFmQYREzh9Tfv","billing_email":"admin@example.com","payment_method":"cheque","billing_address":{...},"shipping_address":{...}'
```
diff --git a/plugins/woocommerce/changelog/woo6-361-pay-for-order-no-shipping-tax-address b/plugins/woocommerce/changelog/woo6-361-pay-for-order-no-shipping-tax-address
new file mode 100644
index 00000000000..a1f2446610a
--- /dev/null
+++ b/plugins/woocommerce/changelog/woo6-361-pay-for-order-no-shipping-tax-address
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Calculate tax from the billing address when an order that needs no shipping and has no address is paid through the Store API.
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
index 9fc06bfad89..21ee48c5716 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
@@ -238,13 +238,69 @@ class OrderController {
}
$this->restore_state_names( $order, $old_states );
+ // Evaluate after setting the addresses so the public filter sees the request data.
$needs_shipping = $order->needs_shipping();
+ if ( ! $needs_shipping && empty( $old_shipping_location['country'] ) && ! self::ships_items_without_methods( $order ) ) {
+ // The shipping address of an order that needs no shipping is not validated, but it still sets the tax
+ // when tax is based on the shipping address. Store the billing address as the shipping address instead.
+ $shipping = self::get_billing_address_for_shipping( $order );
+ $order->set_shipping_address( $shipping );
+ $this->restore_state_names( $order, $old_states );
+ $needs_shipping = $order->needs_shipping();
+ }
+
$this->validate_addresses( $order, $needs_shipping );
$this->validate_location_change( $order, $old_shipping_location, $old_tax_location, $needs_shipping );
return $shipping;
}
+ /**
+ * Checks whether an order still ships physical items while shipping is disabled or no method is registered.
+ *
+ * WC_Order::needs_shipping() returns false in either case, so the products are checked directly. An existing
+ * shipping line means the order still has a destination and its shipping address must be kept.
+ *
+ * @param \WC_Order $order Order to check.
+ * @return bool
+ */
+ private static function ships_items_without_methods( \WC_Order $order ): bool {
+ if ( wc_shipping_enabled() && 0 < wc_get_shipping_method_count( true ) ) {
+ return false;
+ }
+ if ( ! $order->get_shipping_methods() ) {
+ return false;
+ }
+ foreach ( $order->get_items() as $item ) {
+ $product = $item instanceof \WC_Order_Item_Product ? $item->get_product() : null;
+ if ( $product instanceof \WC_Product && $product->needs_shipping() ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Gets the unfiltered billing address fields that can be copied to shipping.
+ *
+ * @param \WC_Order $order Order to read.
+ * @return array
+ */
+ private static function get_billing_address_for_shipping( \WC_Order $order ): array {
+ return [
+ 'first_name' => $order->get_billing_first_name( 'edit' ),
+ 'last_name' => $order->get_billing_last_name( 'edit' ),
+ 'company' => $order->get_billing_company( 'edit' ),
+ 'address_1' => $order->get_billing_address_1( 'edit' ),
+ 'address_2' => $order->get_billing_address_2( 'edit' ),
+ 'city' => $order->get_billing_city( 'edit' ),
+ 'state' => $order->get_billing_state( 'edit' ),
+ 'postcode' => $order->get_billing_postcode( 'edit' ),
+ 'country' => $order->get_billing_country( 'edit' ),
+ 'phone' => $order->get_billing_phone( 'edit' ),
+ ];
+ }
+
/**
* Gets the location an order ships to.
*
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
index 7a81e3a7fb2..0d02fac1dda 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -2756,12 +2756,18 @@ class Checkout extends \WP_Test_REST_TestCase {
* Both addresses default to the one the order already carries and the payment method to one the guard
* accepts, so a test states only the fields it changes.
*
- * @param \WC_Order $order Order being paid for.
- * @param array $billing Billing fields to override.
- * @param array|null $shipping Shipping fields to override, or null to leave the shipping address out.
+ * @param \WC_Order $order Order being paid for.
+ * @param array $billing Billing fields to override.
+ * @param array|null $shipping Shipping fields to override, or null to leave the shipping address out.
+ * @param bool $merge_existing_billing Whether to merge the stored billing address into the request.
* @return \WP_REST_Response
*/
- private function dispatch_pay_for_order_request( \WC_Order $order, array $billing = array(), ?array $shipping = array() ) {
+ private function dispatch_pay_for_order_request(
+ \WC_Order $order,
+ array $billing = array(),
+ ?array $shipping = array(),
+ bool $merge_existing_billing = true
+ ) {
$address = $order->get_address( 'billing' );
$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout/' . $order->get_id() );
$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
@@ -2772,7 +2778,7 @@ class Checkout extends \WP_Test_REST_TestCase {
)
);
$body = array(
- 'billing_address' => array_merge( $address, $billing ),
+ 'billing_address' => $merge_existing_billing ? array_merge( $address, $billing ) : $billing,
'payment_method' => WC_Gateway_BACS::ID,
);
if ( null !== $shipping ) {
@@ -2806,6 +2812,31 @@ class Checkout extends \WP_Test_REST_TestCase {
update_option( 'woocommerce_tax_based_on', $group );
}
+ /**
+ * Makes shipping unavailable through the global setting or by removing every method.
+ *
+ * @param bool $disable_shipping Whether to disable shipping globally.
+ * @return \Closure|null Shipping method count filter to remove, or null when shipping is disabled.
+ */
+ private function make_shipping_unavailable( bool $disable_shipping ): ?\Closure {
+ if ( $disable_shipping ) {
+ update_option( 'woocommerce_ship_to_countries', 'disabled' );
+ return null;
+ }
+
+ $no_methods = static function () {
+ return array(
+ 'version' => \WC_Cache_Helper::get_transient_version( 'shipping' ),
+ 'legacy' => 0,
+ 'enabled' => 0,
+ 'disabled' => 0,
+ );
+ };
+ add_filter( 'pre_transient_wc_shipping_method_count', $no_methods );
+
+ return $no_methods;
+ }
+
/**
* @testdox Existing order payment should reject a shipping address that moves the order to a different destination.
*/
@@ -2870,6 +2901,268 @@ class Checkout extends \WP_Test_REST_TestCase {
$this->assertEquals( 'GB', wc_get_order( $order->get_id() )->get_shipping_country() );
}
+ /**
+ * @testdox Existing order payment should tax an order that ships nothing and holds no address at its billing address.
+ */
+ public function test_checkout_order_taxes_virtual_order_without_address_at_billing() {
+ $this->set_taxes_based_on( 'shipping' );
+ $this->enable_taxes_with_us_ca_rate();
+
+ // A shipping extension may decide from the submitted address, so the hook must see billing after it is set.
+ add_filter(
+ 'woocommerce_order_needs_shipping',
+ static function ( $needs_shipping, $filtered_order ) {
+ return 'US' !== $filtered_order->get_billing_country() || $needs_shipping;
+ },
+ 10,
+ 2
+ );
+
+ // A merchant-created order for a virtual product, awaiting the shopper's details.
+ $virtual_product = \WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) );
+ $order = \WC_Helper_Order::create_order( 0, $virtual_product );
+ $order->set_billing_address(
+ array(
+ 'city' => '',
+ 'state' => 'CA',
+ 'postcode' => '94103',
+ 'country' => '',
+ )
+ );
+ $order->save();
+
+ add_filter(
+ 'woocommerce_get_order_address',
+ static function ( $address, $address_type ) {
+ if ( 'billing' === $address_type ) {
+ $address['postcode'] = 'FILTERED';
+ }
+ return $address;
+ },
+ 10,
+ 2
+ );
+
+ $billing = array(
+ 'city' => 'San Francisco',
+ 'country' => 'US',
+ 'email' => $order->get_billing_email(),
+ );
+
+ // The shipping address of an order that ships nothing is not validated, so it must not set the tax.
+ $response = $this->dispatch_pay_for_order_request( $order, $billing, $this->get_different_destination(), false );
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+
+ $stored_order = wc_get_order( $order->get_id() );
+ $this->assertEquals( 'US', $stored_order->get_shipping_country() );
+ $this->assertEquals( 'CA', $stored_order->get_shipping_state() );
+ $this->assertEquals( '94103', $stored_order->get_shipping_postcode() );
+ $this->assertEquals( 4.0, (float) $stored_order->get_total_tax() );
+ }
+
+ /**
+ * @testdox Existing order address updates should re-check shipping needs after copying billing to shipping.
+ */
+ public function test_existing_order_address_update_rechecks_shipping_needs_after_copying_billing() {
+ $virtual_product = \WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) );
+ $order = \WC_Helper_Order::create_order( 0, $virtual_product );
+ $billing = $order->get_address( 'billing' );
+ $shipping = array_merge( $billing, $this->get_different_destination() );
+ $order->set_billing_country( '' );
+ $order->save();
+
+ $shipping_countries = array();
+ add_filter(
+ 'woocommerce_order_needs_shipping',
+ static function ( $needs_shipping, $filtered_order ) use ( &$shipping_countries ) {
+ $shipping_countries[] = $filtered_order->get_shipping_country();
+ return 'US' === $filtered_order->get_shipping_country() || $needs_shipping;
+ },
+ 10,
+ 2
+ );
+
+ ( new OrderController() )->update_existing_order_addresses( $order, $billing, $shipping );
+
+ $this->assertSame( array( 'GB', 'US' ), $shipping_countries );
+ }
+
+ /**
+ * @testdox Existing order payment should keep the shipping address of a physical order with a shipping line when shipping is unavailable.
+ * @testWith [false]
+ * [true]
+ *
+ * @param bool $disable_shipping Whether to disable shipping globally instead of removing every method.
+ */
+ public function test_checkout_order_keeps_physical_order_shipping_with_shipping_line_when_shipping_is_unavailable( bool $disable_shipping ) {
+ $no_methods = $this->make_shipping_unavailable( $disable_shipping );
+
+ // A merchant-created order for a physical product with a manual shipping line and no address yet.
+ $order = \WC_Helper_Order::create_order( 0 );
+ $order->set_billing_country( '' );
+ $order->save();
+ $this->assertFalse( $order->needs_shipping() );
+
+ $response = $this->dispatch_pay_for_order_request( $order, array( 'country' => 'US' ), $this->get_different_destination() );
+ if ( null !== $no_methods ) {
+ remove_filter( 'pre_transient_wc_shipping_method_count', $no_methods );
+ }
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+ $this->assertEquals( 'GB', wc_get_order( $order->get_id() )->get_shipping_country() );
+ }
+
+ /**
+ * @testdox Existing order payment should tax a physical order without a shipping line at billing when shipping is unavailable.
+ * @testWith [false]
+ * [true]
+ *
+ * @param bool $disable_shipping Whether to disable shipping globally instead of removing every method.
+ */
+ public function test_checkout_order_taxes_physical_order_without_shipping_line_at_billing_when_shipping_is_unavailable( bool $disable_shipping ) {
+ $this->set_taxes_based_on( 'shipping' );
+ $this->enable_taxes_with_us_ca_rate();
+ $no_methods = $this->make_shipping_unavailable( $disable_shipping );
+
+ $order = \WC_Helper_Order::create_order( 0 );
+ $order->remove_order_items( 'shipping' );
+ $order->set_shipping_total( 0 );
+ $order->set_billing_country( '' );
+ $order->save();
+ $this->assertFalse( $order->needs_shipping() );
+ $this->assertCount( 0, $order->get_shipping_methods() );
+
+ $response = $this->dispatch_pay_for_order_request(
+ $order,
+ array(
+ 'country' => 'US',
+ 'state' => 'CA',
+ 'postcode' => '94103',
+ ),
+ $this->get_different_destination()
+ );
+ if ( null !== $no_methods ) {
+ remove_filter( 'pre_transient_wc_shipping_method_count', $no_methods );
+ }
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+ $stored_order = wc_get_order( $order->get_id() );
+ $this->assertEquals( 'US', $stored_order->get_shipping_country() );
+ $this->assertEquals( 'CA', $stored_order->get_shipping_state() );
+ $this->assertEquals( '94103', $stored_order->get_shipping_postcode() );
+ $this->assertEquals( 4.0, (float) $stored_order->get_total_tax() );
+ }
+
+ /**
+ * @testdox Existing order payment should tax a virtual order with a shipping line at billing when shipping is unavailable.
+ * @testWith [false]
+ * [true]
+ *
+ * @param bool $disable_shipping Whether to disable shipping globally instead of removing every method.
+ */
+ public function test_checkout_order_taxes_virtual_order_with_shipping_line_at_billing_when_shipping_is_unavailable( bool $disable_shipping ) {
+ $this->set_taxes_based_on( 'shipping' );
+ $this->enable_taxes_with_us_ca_rate();
+ $no_methods = $this->make_shipping_unavailable( $disable_shipping );
+
+ $virtual_product = \WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) );
+ $order = \WC_Helper_Order::create_order( 0, $virtual_product );
+ $order->set_billing_country( '' );
+ $order->save();
+ $this->assertFalse( $order->needs_shipping() );
+ $this->assertCount( 1, $order->get_shipping_methods() );
+
+ $response = $this->dispatch_pay_for_order_request(
+ $order,
+ array(
+ 'country' => 'US',
+ 'state' => 'CA',
+ 'postcode' => '94103',
+ ),
+ $this->get_different_destination()
+ );
+ if ( null !== $no_methods ) {
+ remove_filter( 'pre_transient_wc_shipping_method_count', $no_methods );
+ }
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+ $stored_order = wc_get_order( $order->get_id() );
+ $this->assertEquals( 'US', $stored_order->get_shipping_country() );
+ $this->assertEquals( 'CA', $stored_order->get_shipping_state() );
+ $this->assertEquals( '94103', $stored_order->get_shipping_postcode() );
+ $this->assertEquals( 4.0, (float) $stored_order->get_total_tax() );
+ }
+
+ /**
+ * @testdox Existing order payment should keep the shipping address of a mixed order when shipping is unavailable.
+ * @testWith [false]
+ * [true]
+ *
+ * @param bool $disable_shipping Whether to disable shipping globally instead of removing every method.
+ */
+ public function test_checkout_order_keeps_mixed_order_shipping_when_shipping_is_unavailable( bool $disable_shipping ) {
+ $no_methods = $this->make_shipping_unavailable( $disable_shipping );
+
+ $virtual_product = \WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) );
+ $physical_product = \WC_Helper_Product::create_simple_product();
+ $order = \WC_Helper_Order::create_order( 0, $virtual_product );
+ $order->add_product( $physical_product );
+ $order->set_billing_country( '' );
+ $order->calculate_totals( false );
+
+ $items = array_values( $order->get_items() );
+ $this->assertCount( 2, $items );
+ $this->assertFalse( $items[0]->get_product()->needs_shipping() );
+ $this->assertTrue( $items[1]->get_product()->needs_shipping() );
+ $this->assertFalse( $order->needs_shipping() );
+ $this->assertCount( 1, $order->get_shipping_methods() );
+
+ $response = $this->dispatch_pay_for_order_request( $order, array( 'country' => 'US' ), $this->get_different_destination() );
+ if ( null !== $no_methods ) {
+ remove_filter( 'pre_transient_wc_shipping_method_count', $no_methods );
+ }
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+ $this->assertEquals( 'GB', wc_get_order( $order->get_id() )->get_shipping_country() );
+ }
+
+ /**
+ * @testdox Existing order payment should ship a filtered physical order to billing when an enabled or disabled shipping method exists.
+ * @testWith [1, 0]
+ * [0, 1]
+ *
+ * @param int $enabled_methods Number of enabled shipping methods.
+ * @param int $disabled_methods Number of disabled shipping methods.
+ */
+ public function test_checkout_order_ships_filtered_physical_order_to_billing_with_shipping_methods( int $enabled_methods, int $disabled_methods ) {
+ $method_counts = static function () use ( $enabled_methods, $disabled_methods ) {
+ return array(
+ 'version' => \WC_Cache_Helper::get_transient_version( 'shipping' ),
+ 'legacy' => 0,
+ 'enabled' => $enabled_methods,
+ 'disabled' => $disabled_methods,
+ );
+ };
+ add_filter( 'pre_transient_wc_shipping_method_count', $method_counts );
+
+ $this->assertSame( 1, wc_get_shipping_method_count( true ) );
+ $this->assertSame( $enabled_methods, wc_get_shipping_method_count( false, true ) );
+ add_filter( 'woocommerce_order_needs_shipping', '__return_false' );
+
+ // A merchant-created order for a physical product that an extension says ships nothing, with no address yet.
+ $order = \WC_Helper_Order::create_order( 0 );
+ $order->set_billing_country( '' );
+ $order->save();
+
+ $response = $this->dispatch_pay_for_order_request( $order, array( 'country' => 'US' ), $this->get_different_destination() );
+ remove_filter( 'woocommerce_order_needs_shipping', '__return_false' );
+ remove_filter( 'pre_transient_wc_shipping_method_count', $method_counts );
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+ $this->assertEquals( 'US', wc_get_order( $order->get_id() )->get_shipping_country() );
+ }
+
/**
* @testdox Existing order payment should reject a first shipping address away from the billing address the order ships to.
*/