Commit cd1ef2e8c1e for woocommerce
commit cd1ef2e8c1e1970114d3cd62dc5ef48de338399e
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date: Tue Oct 27 18:30:11 2026 +0100
Store API: reject pay-for-order address changes that would re-price the order (#67404)
* Store API: reject pay-for-order address changes that would re-price the order
* Apply suggestion from @senadir
* Store API: unify the pricing address fields and compare city in both checks
* Store API: address review feedback on the pay-for-order pricing guard
- Rename $was_addressed to $was_priced_with_address; the flag gates whether the
order was priced against an address, not just whether it held one.
- Reword both rejection messages to name the original address as a way forward
instead of only pointing the shopper at support.
- Keep the order's existing shipping address when a request omits one. The
previous billing fallback silently re-addressed the order, and with the guard
in place it rejected billing-only requests outright.
- Treat a stored state that is not already its canonical code as a difference.
Such an order was priced against a value the rate lookup never matched, so
normalizing both sides would hide the re-price.
* Store API: price an order with no shipping address on its billing address in the pay-for-order guard
An order without a shipping address ships to its billing address, so compare the
shipping destination against billing in that case. On stores that ship to billing
only, a request without a shipping address keeps shipping in step with billing.
Also update the test messages to the new copy and cover a stored state name.
* Store API: keep a stored state name when the pay-for-order request sends its code
An order created outside the Store API can hold a state name, which matches no
shipping zone or tax rate. The Store API always sends the code, so the guard
rejected these orders even when the shopper changed nothing. Keep the stored
name when both name the same state, so the order can be paid without being
re-priced, and compare the state the way the zone and tax lookups do.
* Refactor order address checks and fix coupon test addresses
* Update checkout order address comments
* Update changelog for checkout order address checks
* Force shipping to billing on billing-only stores in pay-for-order
* Keep the order location in the pay-for-order encoding test
* Keep admin-set shipping on billing-only stores and read raw state values in pay-for-order
Copy the shipping address applied by the controller to the customer instead
of the filtered order address, only write shipping when there is one, read
states with the edit context, and guard Store API orders that lost their
address.
* Update plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
Co-authored-by: Raluca Stan <ralucastn@gmail.com>
* Pay with the order's own address in the changed-email coupon test
The test came in with the coupon release change and sends a fixed CA
address while the order holds the fixture's NY one. Now that the route
rejects a location change, it gets a 400 before it reaches the coupon
check. Use the pay-for-order helper so only the email changes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Add a test for billing-only stores keeping an order's own shipping address
An order made in the admin can ship somewhere other than its billing
address even when the store ships to billing only. Cover the branch that
keeps that address instead of copying billing over it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Add tests for shipping copied from billing on orders without one
Cover the branch that copies billing to shipping when neither the order
nor the request holds a shipping address, and the state fallback that
keeps a billing state name on the copied shipping address. Both fail
when their branch is removed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Compare pay-for-order locations without trimming
Tax rates match the stored state and city as they are, so a value with
a stray space never matched a rate when the order was priced. Trimming
in the guard made it equal to the clean value the request sends, and
the recalculation then applied the rate. Compare the raw value, as the
tax lookup does, and cover it with a test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Raluca Stan <ralucastn@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/woo6-80-checkout-order-address-pricing-guard b/plugins/woocommerce/changelog/woo6-80-checkout-order-address-pricing-guard
new file mode 100644
index 00000000000..2ab79ed280c
--- /dev/null
+++ b/plugins/woocommerce/changelog/woo6-80-checkout-order-address-pricing-guard
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Store API: reject an address change on the pay-for-order checkout endpoint when it would alter the order's shipping cost or tax.
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php b/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php
index c798e747130..06f36d02722 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php
@@ -206,12 +206,7 @@ class CheckoutOrder extends AbstractCartRoute {
// Billing address is a required field.
$billing = $request['billing_address'];
- // If shipping address (optional field) was not provided, set it to the given billing address (required field).
- $shipping = $request['shipping_address'] ?? $billing;
-
- $this->order->set_billing_address( $billing );
- $this->order->set_shipping_address( $shipping );
- $this->order_controller->validate_existing_order_before_update( $this->order );
+ $shipping = $this->order_controller->update_existing_order_addresses( $this->order, $billing, $request['shipping_address'] );
// Update customer object with validated order addresses.
foreach ( $billing as $key => $value ) {
@@ -220,7 +215,8 @@ class CheckoutOrder extends AbstractCartRoute {
}
}
- foreach ( $shipping as $key => $value ) {
+ // When the order keeps its own shipping address, the customer's shipping follows billing.
+ foreach ( $shipping ?? $billing as $key => $value ) {
if ( is_callable( [ $customer, "set_shipping_$key" ] ) ) {
$customer->{"set_shipping_$key"}( $value );
}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
index 79b7023b62d..a0811f15b59 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
@@ -18,6 +18,17 @@ use Exception;
*/
class OrderController {
+ /**
+ * Address fields that can change the price. Shipping zones match on country, state and postcode, and tax rates
+ * also match on city. Other fields, like name or street, do not change the price.
+ *
+ * @see \WC_Shipping_Zones::get_zone_matching_package()
+ * @see \WC_Tax::find_rates()
+ *
+ * @var string[]
+ */
+ private const LOCATION_FIELDS = [ 'country', 'state', 'postcode', 'city' ];
+
/**
* Checkout fields controller.
*
@@ -186,6 +197,161 @@ class OrderController {
$this->validate_addresses( $order, $order->needs_shipping() );
}
+ /**
+ * Sets new addresses on an existing order and validates them, without saving the order.
+ *
+ * Rejects a location change that would change the shipping cost or tax. The request recalculates
+ * tax but not shipping, and the total should stay what the order was priced at.
+ *
+ * @since 11.4.0
+ *
+ * @throws RouteException When an address is not valid or its location cannot change.
+ * @param \WC_Order $order Existing order.
+ * @param array $billing New billing address.
+ * @param array|null $shipping New shipping address, or null to keep the order's.
+ * @return array|null Shipping address set on the order, or null if the order kept its own.
+ */
+ public function update_existing_order_addresses( \WC_Order $order, array $billing, ?array $shipping ): ?array {
+ $old_shipping_location = self::get_shipping_location( $order );
+ $old_tax_location = $order->get_taxable_location();
+ $old_states = [
+ 'billing' => $order->get_billing_state( 'edit' ),
+ // An order with no shipping address ships to billing, so its shipping starts from the billing state.
+ 'shipping' => $order->get_shipping_country() ? $order->get_shipping_state( 'edit' ) : $order->get_billing_state( 'edit' ),
+ ];
+
+ if ( wc_ship_to_billing_address_only() ) {
+ // Stores that ship to billing only ignore the request's shipping. An order that already ships somewhere
+ // else, e.g. one made in the admin, keeps its shipping, since copying billing over it would block payment.
+ $shipping = self::locations_differ( $old_shipping_location, $order->get_address( 'billing' ) ) ? null : $billing;
+ } elseif ( null === $shipping && ! $order->get_shipping_country() ) {
+ // An order with no shipping address to keep ships to billing.
+ $shipping = $billing;
+ }
+
+ $order->set_billing_address( $billing );
+ if ( null !== $shipping ) {
+ $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;
+ }
+
+ /**
+ * Gets the location an order ships to.
+ *
+ * An order with no shipping address, e.g. one created in the admin, ships to its billing address.
+ * WC_Abstract_Order::get_tax_location() uses the same fallback.
+ *
+ * @param \WC_Order $order Order to read.
+ * @return array
+ */
+ private static function get_shipping_location( \WC_Order $order ): array {
+ return $order->get_address( $order->get_shipping_country() ? 'shipping' : 'billing' );
+ }
+
+ /**
+ * Keeps a state the order stores as a name, e.g. "New York", when the request sends its code, e.g. "NY".
+ *
+ * This is because some edge cases would have an order with a state name (New York) instead of NY.
+ * Switching from New York to NY might trigger new totals so we avoid that by keeping the full name.
+ *
+ * @param \WC_Order $order Order with the new addresses set.
+ * @param string[] $old_states State per address type before the new addresses were set.
+ */
+ private function restore_state_names( \WC_Order $order, array $old_states ): void {
+ $validation_utils = new ValidationUtils();
+
+ foreach ( $old_states as $address_type => $old_state ) {
+ $state = (string) $order->{"get_{$address_type}_state"}( 'edit' );
+ $country = (string) $order->{"get_{$address_type}_country"}( 'edit' );
+
+ if ( $old_state !== $state && $validation_utils->format_state( $old_state, $country ) === $state ) {
+ $order->set_props( [ "{$address_type}_state" => $old_state ] );
+ }
+ }
+ }
+
+ /**
+ * Rejects a location change that would change the order's shipping cost or tax.
+ *
+ * @throws RouteException When the shipping or tax location changed.
+ * @param \WC_Order $order Order with the new addresses set.
+ * @param array $old_shipping_location Location the order shipped to before the change.
+ * @param array $old_tax_location Location the order was taxed on before the change.
+ * @param bool $needs_shipping Whether the order needs shipping.
+ */
+ private function validate_location_change( \WC_Order $order, array $old_shipping_location, array $old_tax_location, bool $needs_shipping ): void {
+ // An order with no address, e.g. one a merchant made for the shopper to fill in, is taxed at the shop's
+ // base address and has no shipping zone yet, so its first address is allowed. Store API orders get
+ // their address at checkout, so they never need this.
+ if ( empty( $old_shipping_location['country'] ) && 'store-api' !== $order->get_created_via() ) {
+ return;
+ }
+
+ if ( $needs_shipping && self::locations_differ( $old_shipping_location, self::get_shipping_location( $order ) ) ) {
+ throw new RouteException(
+ 'woocommerce_rest_checkout_order_address_change_not_allowed',
+ esc_html__( 'Sorry, the shipping address on this order cannot be changed because the shipping cost was calculated for the original address. Please use the original address, or contact us to have the order updated.', 'woocommerce' ),
+ 400
+ );
+ }
+
+ // get_taxable_location() follows whichever address sets the tax, including the shop base for local
+ // pickup and anything the woocommerce_order_get_tax_location filter returns. A store that collects
+ // no tax has no tax location to protect.
+ if ( wc_tax_enabled() && self::locations_differ( $old_tax_location, $order->get_taxable_location() ) ) {
+ throw new RouteException(
+ 'woocommerce_rest_checkout_order_address_change_not_allowed',
+ esc_html__( 'Sorry, the address on this order cannot be changed because the tax was calculated for the original address. Please use the original address, or contact us to have the order updated.', 'woocommerce' ),
+ 400
+ );
+ }
+ }
+
+ /**
+ * Checks whether two locations match a different shipping zone or tax rate.
+ *
+ * Shipping zones and tax rates ignore case and postcode spacing, so a change in only those does not count.
+ *
+ * @param array $old_location Location before the change.
+ * @param array $new_location Location after the change.
+ * @return bool
+ */
+ private static function locations_differ( array $old_location, array $new_location ): bool {
+ foreach ( self::LOCATION_FIELDS as $field ) {
+ if ( self::normalize_location_field( $field, $old_location ) !== self::normalize_location_field( $field, $new_location ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Normalizes a location field the way shipping zones and tax rates match it.
+ *
+ * @param string $field Field name.
+ * @param array $location Location the field belongs to.
+ * @return string
+ */
+ private static function normalize_location_field( string $field, array $location ): string {
+ $value = (string) ( $location[ $field ] ?? '' );
+
+ if ( 'postcode' === $field ) {
+ return wc_normalize_postcode( $value );
+ }
+
+ // Both lookups use strtoupper(), which leaves multibyte characters alone, and the tax lookup does
+ // not trim. Matching them keeps the check from treating two values as equal when a lookup would not.
+ return strtoupper( $value );
+ }
+
/**
* Perform custom order validation via WooCommerce hooks.
*
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 19b6acb09d4..99f02f0ff42 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -283,6 +283,8 @@ class Checkout extends \WP_Test_REST_TestCase {
'woocommerce_pickup_location_settings',
'pickup_location_pickup_locations',
'woocommerce_calc_taxes',
+ 'woocommerce_tax_based_on',
+ 'woocommerce_ship_to_destination',
);
foreach ( $option_names as $option_name ) {
@@ -2451,9 +2453,9 @@ class Checkout extends \WP_Test_REST_TestCase {
'company' => 'AT&T',
'address_1' => '123 Test St',
'address_2' => '',
- 'city' => 'Test City',
- 'state' => 'CA',
- 'postcode' => '90210',
+ 'city' => 'WooCity',
+ 'state' => 'NY',
+ 'postcode' => '12345',
'country' => 'US',
'email' => $order->get_billing_email(),
'phone' => '555-32123',
@@ -2507,14 +2509,6 @@ class Checkout extends \WP_Test_REST_TestCase {
$original_shipping_country = $order->get_shipping_country();
$original_total = $order->get_total();
- $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout/' . $order->get_id() );
- $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
- $request->set_query_params(
- array(
- 'key' => $order->get_order_key(),
- 'billing_email' => $order->get_billing_email(),
- )
- );
// Forbidden country (IN) on both addresses is what should trigger the rejection.
$invalid_address = array(
'first_name' => 'Test',
@@ -2528,15 +2522,9 @@ class Checkout extends \WP_Test_REST_TestCase {
'country' => 'IN',
'phone' => '',
);
- $request->set_body_params(
- array(
- 'billing_address' => array_merge( $invalid_address, array( 'email' => $order->get_billing_email() ) ),
- 'shipping_address' => $invalid_address,
- 'payment_method' => WC_Gateway_BACS::ID,
- )
- );
- $response = rest_get_server()->dispatch( $request );
+ $response = $this->dispatch_pay_for_order_request( $order, $invalid_address, $invalid_address );
+
$this->assertEquals( 400, $response->get_status() );
$this->assertEquals( 'woocommerce_rest_invalid_address_country', $response->get_data()['code'] );
@@ -2546,6 +2534,488 @@ class Checkout extends \WP_Test_REST_TestCase {
$this->assertEquals( $original_total, $stored_order->get_total() );
}
+ /**
+ * Returns a destination in a different shipping zone and tax location to the fixture's US address.
+ *
+ * @return array
+ */
+ private function get_different_destination() {
+ return array(
+ 'city' => 'London',
+ 'state' => '',
+ 'postcode' => 'SW1A 1AA',
+ 'country' => 'GB',
+ );
+ }
+
+ /**
+ * Dispatches a pay-for-order request as the guest the order belongs to.
+ *
+ * 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.
+ * @return \WP_REST_Response
+ */
+ private function dispatch_pay_for_order_request( \WC_Order $order, array $billing = array(), ?array $shipping = array() ) {
+ $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' ) );
+ $request->set_query_params(
+ array(
+ 'key' => $order->get_order_key(),
+ 'billing_email' => $order->get_billing_email(),
+ )
+ );
+ $body = array(
+ 'billing_address' => array_merge( $address, $billing ),
+ 'payment_method' => WC_Gateway_BACS::ID,
+ );
+ if ( null !== $shipping ) {
+ $body['shipping_address'] = array_merge( $address, $shipping );
+ }
+ $request->set_body_params( $body );
+
+ return rest_get_server()->dispatch( $request );
+ }
+
+ /**
+ * Creates a guest order that already carries a shipping address, as one placed through checkout would.
+ *
+ * @return \WC_Order
+ */
+ private function create_pay_for_order_with_shipping_address() {
+ $order = \WC_Helper_Order::create_order( 0 );
+ $order->set_shipping_address( $order->get_address( 'billing' ) );
+ $order->save();
+
+ return $order;
+ }
+
+ /**
+ * Configures the store to collect tax based on the given address group.
+ *
+ * @param string $group Address group tax is based on: 'billing' or 'shipping'.
+ */
+ private function set_taxes_based_on( string $group ) {
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_tax_based_on', $group );
+ }
+
+ /**
+ * @testdox Existing order payment should reject a shipping address that moves the order to a different destination.
+ */
+ public function test_checkout_order_rejects_shipping_location_change() {
+ $this->set_taxes_based_on( 'shipping' );
+
+ $order = $this->create_pay_for_order_with_shipping_address();
+ $original_total = $order->get_total();
+
+ $response = $this->dispatch_pay_for_order_request( $order, array(), $this->get_different_destination() );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'woocommerce_rest_checkout_order_address_change_not_allowed', $response->get_data()['code'] );
+ // The message is what distinguishes the shipping zone check from the tax check below it.
+ $this->assertStringContainsString( 'the shipping cost was calculated', $response->get_data()['message'] );
+
+ $stored_order = wc_get_order( $order->get_id() );
+ $this->assertEquals( 'US', $stored_order->get_shipping_country(), 'A rejected request must not persist the address' );
+ $this->assertEquals( $original_total, $stored_order->get_total(), 'A rejected request must not change the total' );
+ }
+
+ /**
+ * @testdox Existing order payment should allow address changes that leave the destination alone.
+ */
+ public function test_checkout_order_allows_address_change_that_keeps_the_location() {
+ $this->set_taxes_based_on( 'shipping' );
+
+ $order = $this->create_pay_for_order_with_shipping_address();
+
+ $response = $this->dispatch_pay_for_order_request(
+ $order,
+ array(
+ 'first_name' => 'Renamed',
+ 'address_1' => '9 Updated Street',
+ 'phone' => '555-99999',
+ ),
+ array( 'first_name' => 'Renamed' )
+ );
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+
+ $stored_order = wc_get_order( $order->get_id() );
+ $this->assertEquals( 'Renamed', $stored_order->get_billing_first_name() );
+ $this->assertEquals( '9 Updated Street', $stored_order->get_billing_address_1() );
+ $this->assertEquals( 'US', $stored_order->get_shipping_country() );
+ }
+
+ /**
+ * @testdox Existing order payment should allow a first address on an order that holds none at all.
+ */
+ public function test_checkout_order_allows_first_address_when_order_holds_none() {
+ $this->set_taxes_based_on( 'shipping' );
+
+ // A merchant-created order awaiting the shopper's details has no shipping cost or tax tied to a location.
+ $order = \WC_Helper_Order::create_order( 0 );
+ $order->set_billing_address( array_fill_keys( array( 'city', 'state', 'postcode', 'country' ), '' ) );
+ $order->save();
+
+ $response = $this->dispatch_pay_for_order_request( $order, $this->get_different_destination(), $this->get_different_destination() );
+
+ $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 reject a first shipping address away from the billing address the order ships to.
+ */
+ public function test_checkout_order_rejects_first_shipping_address_away_from_billing() {
+ $this->set_taxes_based_on( 'shipping' );
+
+ // With no shipping country the order ships to its billing address, e.g. one created in the admin.
+ $order = \WC_Helper_Order::create_order( 0 );
+ $this->assertEquals( '', $order->get_shipping_country() );
+
+ $response = $this->dispatch_pay_for_order_request( $order, array(), $this->get_different_destination() );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'woocommerce_rest_checkout_order_address_change_not_allowed', $response->get_data()['code'] );
+ $this->assertStringContainsString( 'the shipping cost was calculated', $response->get_data()['message'] );
+ $this->assertEquals( '', wc_get_order( $order->get_id() )->get_shipping_country() );
+ }
+
+ /**
+ * @testdox Existing order payment should reject a first shipping address that moves the tax location.
+ */
+ public function test_checkout_order_rejects_first_shipping_address_that_moves_the_tax_location() {
+ $this->set_taxes_based_on( 'shipping' );
+
+ // With no shipping country the order is taxed on billing, so a shipping address relocates the tax.
+ $virtual_product = \WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) );
+ $order = \WC_Helper_Order::create_order( 0, $virtual_product );
+
+ $response = $this->dispatch_pay_for_order_request( $order, array(), $this->get_different_destination() );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'woocommerce_rest_checkout_order_address_change_not_allowed', $response->get_data()['code'] );
+ // A virtual order skips the shipping zone check, so only the tax check can reject this.
+ $this->assertStringContainsString( 'the tax was calculated', $response->get_data()['message'] );
+ $this->assertEquals( '', wc_get_order( $order->get_id() )->get_shipping_country() );
+ }
+
+ /**
+ * @testdox Existing order payment should keep the order's shipping address when the request leaves it out.
+ */
+ public function test_checkout_order_keeps_shipping_address_when_request_omits_it() {
+ $this->set_taxes_based_on( 'shipping' );
+
+ $order = $this->create_pay_for_order_with_shipping_address();
+
+ $response = $this->dispatch_pay_for_order_request( $order, $this->get_different_destination(), null );
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+
+ $stored_order = wc_get_order( $order->get_id() );
+ $this->assertEquals( 'GB', $stored_order->get_billing_country() );
+ $this->assertEquals( 'US', $stored_order->get_shipping_country(), 'A billing-only request must not re-address the order' );
+ }
+
+ /**
+ * @testdox Existing order payment should ship to billing when neither the order nor the request holds a shipping address.
+ */
+ public function test_checkout_order_copies_billing_to_shipping_when_neither_holds_one() {
+ $this->set_taxes_based_on( 'shipping' );
+
+ // An order made in the admin often has no shipping address, and a client may send only billing.
+ $order = \WC_Helper_Order::create_order( 0 );
+ $this->assertEquals( '', $order->get_shipping_country() );
+
+ $response = $this->dispatch_pay_for_order_request( $order, array( 'address_1' => '1 New Street' ), null );
+
+ $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( '1 New Street', $stored_order->get_shipping_address_1() );
+ }
+
+ /**
+ * @testdox Existing order payment should reject a billing change when the store ships to the billing address only.
+ */
+ public function test_checkout_order_rejects_billing_change_when_store_ships_to_billing_only() {
+ update_option( 'woocommerce_calc_taxes', 'no' );
+ update_option( 'woocommerce_ship_to_destination', 'billing_only' );
+
+ $order = $this->create_pay_for_order_with_shipping_address();
+
+ // Shipping follows billing on this store, so a new billing address is a new destination.
+ $response = $this->dispatch_pay_for_order_request( $order, $this->get_different_destination(), null );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'woocommerce_rest_checkout_order_address_change_not_allowed', $response->get_data()['code'] );
+ $this->assertStringContainsString( 'the shipping cost was calculated', $response->get_data()['message'] );
+ $this->assertEquals( 'US', wc_get_order( $order->get_id() )->get_shipping_country() );
+ }
+
+ /**
+ * @testdox Existing order payment should ship to billing when the store ships to the billing address only, whatever shipping address the request sends.
+ */
+ public function test_checkout_order_ignores_shipping_address_when_store_ships_to_billing_only() {
+ update_option( 'woocommerce_calc_taxes', 'no' );
+ update_option( 'woocommerce_ship_to_destination', 'billing_only' );
+
+ $order = $this->create_pay_for_order_with_shipping_address();
+
+ // A street change does not change the price, so the request passes and shows where shipping was copied from.
+ $response = $this->dispatch_pay_for_order_request( $order, array( 'address_1' => '1 New Street' ), $this->get_different_destination() );
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+ $order = wc_get_order( $order->get_id() );
+ $this->assertEquals( '1 New Street', $order->get_shipping_address_1() );
+ $this->assertEquals( 'US', $order->get_shipping_country() );
+ }
+
+ /**
+ * @testdox Existing order payment should keep an order's own shipping address when the store ships to the billing address only.
+ */
+ public function test_checkout_order_keeps_distinct_shipping_address_when_store_ships_to_billing_only() {
+ update_option( 'woocommerce_calc_taxes', 'no' );
+ update_option( 'woocommerce_ship_to_destination', 'billing_only' );
+
+ // An order made in the admin can ship somewhere other than its billing address, even on this store.
+ $order = \WC_Helper_Order::create_order( 0 );
+ $order->set_shipping_address( array_merge( $order->get_address( 'billing' ), $this->get_different_destination() ) );
+ $order->save();
+
+ $response = $this->dispatch_pay_for_order_request( $order, array( 'address_1' => '1 New Street' ) );
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+ $stored_order = wc_get_order( $order->get_id() );
+ $this->assertEquals( '1 New Street', $stored_order->get_billing_address_1() );
+ $this->assertEquals( 'GB', $stored_order->get_shipping_country(), 'Billing must not be copied over the address the order already ships to' );
+ }
+
+ /**
+ * @testdox Existing order payment should reject a shipping city change even when tax is billing based.
+ */
+ public function test_checkout_order_rejects_shipping_city_change() {
+ $this->set_taxes_based_on( 'billing' );
+
+ $order = $this->create_pay_for_order_with_shipping_address();
+
+ // Tax follows billing here, so only the shipping zone check can reject a shipping city change.
+ $response = $this->dispatch_pay_for_order_request( $order, array(), array( 'city' => 'Elsewhere' ) );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'woocommerce_rest_checkout_order_address_change_not_allowed', $response->get_data()['code'] );
+ $this->assertStringContainsString( 'the shipping cost was calculated', $response->get_data()['message'] );
+ }
+
+ /**
+ * @testdox Existing order payment should allow a billing change on a local pickup order taxed at the shop base.
+ */
+ public function test_checkout_order_allows_billing_change_on_local_pickup_order() {
+ $this->set_taxes_based_on( 'billing' );
+
+ // Local pickup pins the tax location to the shop base, so billing cannot move what is charged.
+ $order = $this->create_pay_for_order_with_shipping_address();
+ $pickup_method = new \WC_Order_Item_Shipping();
+ $pickup_method->set_props(
+ array(
+ 'method_title' => 'Local pickup',
+ 'method_id' => 'local_pickup',
+ 'total' => 0,
+ )
+ );
+ $order->add_item( $pickup_method );
+ $order->save();
+
+ $response = $this->dispatch_pay_for_order_request( $order, $this->get_different_destination() );
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+ $this->assertEquals( 'GB', wc_get_order( $order->get_id() )->get_billing_country() );
+ }
+
+ /**
+ * @testdox Existing order payment should allow an address change when the store does not collect tax.
+ */
+ public function test_checkout_order_allows_address_change_when_taxes_are_disabled() {
+ update_option( 'woocommerce_calc_taxes', 'no' );
+ update_option( 'woocommerce_tax_based_on', 'shipping' );
+
+ // A digital order has no shipping cost either, so the address cannot change the total.
+ $virtual_product = \WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) );
+ $order = \WC_Helper_Order::create_order( 0, $virtual_product );
+ $order->set_shipping_address( $order->get_address( 'billing' ) );
+ $order->save();
+ $original_total = $order->get_total();
+
+ $response = $this->dispatch_pay_for_order_request( $order, $this->get_different_destination(), $this->get_different_destination() );
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+
+ $stored_order = wc_get_order( $order->get_id() );
+ $this->assertEquals( 'GB', $stored_order->get_shipping_country() );
+ $this->assertEquals( $original_total, $stored_order->get_total(), 'Without tax the address cannot change the total' );
+ }
+
+ /**
+ * @testdox Existing order payment should allow location fields that differ from the order's only in case.
+ */
+ public function test_checkout_order_allows_location_fields_that_differ_only_in_case() {
+ $this->set_taxes_based_on( 'billing' );
+
+ $order = $this->create_pay_for_order_with_shipping_address();
+
+ $response = $this->dispatch_pay_for_order_request(
+ $order,
+ // The zone and tax lookups uppercase the city and state, so both of these match the order's location.
+ array(
+ 'city' => 'woocity',
+ 'state' => 'ny',
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+ }
+
+ /**
+ * @testdox Existing order payment should keep a state name the order holds when the request sends its code.
+ */
+ public function test_checkout_order_keeps_state_name_the_order_holds() {
+ $this->set_taxes_based_on( 'billing' );
+ $this->inserted_tax_rate_ids[] = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => 'NY',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'NY Sales Tax',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '0',
+ 'tax_rate_order' => '1',
+ )
+ );
+
+ // Orders created outside the Store API (REST API, import) can hold the state name, which matches no tax
+ // rate. The Store API always sends the code, which does, so saving it would change the total.
+ $order = $this->create_pay_for_order_with_shipping_address();
+ $order->set_billing_state( 'New York' );
+ $order->calculate_totals();
+ $order->save();
+ $original_total = $order->get_total();
+
+ $response = $this->dispatch_pay_for_order_request( $order, array( 'state' => 'NY' ) );
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+
+ $stored_order = wc_get_order( $order->get_id() );
+ $this->assertEquals( 'New York', $stored_order->get_billing_state() );
+ $this->assertEquals( $original_total, $stored_order->get_total(), 'An unchanged address must not change the total' );
+ }
+
+ /**
+ * @testdox Existing order payment should keep a state name on the shipping address it copies from billing.
+ */
+ public function test_checkout_order_keeps_state_name_when_shipping_is_copied_from_billing() {
+ $this->set_taxes_based_on( 'shipping' );
+ $this->inserted_tax_rate_ids[] = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => 'NY',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'NY Sales Tax',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '0',
+ 'tax_rate_order' => '1',
+ )
+ );
+
+ // With no shipping address the order is taxed on billing, which holds the state name.
+ $order = \WC_Helper_Order::create_order( 0 );
+ $order->set_billing_state( 'New York' );
+ $order->calculate_totals();
+ $order->save();
+ $original_total = $order->get_total();
+
+ $response = $this->dispatch_pay_for_order_request( $order, array( 'state' => 'NY' ), null );
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+ $stored_order = wc_get_order( $order->get_id() );
+ $this->assertEquals( 'New York', $stored_order->get_shipping_state() );
+ $this->assertEquals( $original_total, $stored_order->get_total(), 'An unchanged location must not change the total' );
+ }
+
+ /**
+ * @testdox Existing order payment should reject a state that differs from the order's only in surrounding whitespace.
+ */
+ public function test_checkout_order_rejects_state_that_differs_only_in_whitespace() {
+ $this->set_taxes_based_on( 'billing' );
+ $this->inserted_tax_rate_ids[] = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => 'NY',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'NY Sales Tax',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '0',
+ 'tax_rate_order' => '1',
+ )
+ );
+
+ // Tax rates match the stored state as it is, so the stray space kept this order away from the NY rate.
+ $order = $this->create_pay_for_order_with_shipping_address();
+ $order->set_billing_state( ' NY' );
+ $order->calculate_totals();
+ $order->save();
+ $original_total = $order->get_total();
+
+ $response = $this->dispatch_pay_for_order_request( $order, array( 'state' => 'NY' ) );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'woocommerce_rest_checkout_order_address_change_not_allowed', $response->get_data()['code'] );
+ $this->assertStringContainsString( 'the tax was calculated', $response->get_data()['message'] );
+ $this->assertEquals( $original_total, wc_get_order( $order->get_id() )->get_total(), 'A rejected request must not change the total' );
+ }
+
+ /**
+ * @testdox Existing order payment should reject a billing address change when tax is based on billing.
+ */
+ public function test_checkout_order_rejects_billing_address_change_when_tax_is_billing_based() {
+ $this->set_taxes_based_on( 'billing' );
+
+ $order = $this->create_pay_for_order_with_shipping_address();
+
+ $response = $this->dispatch_pay_for_order_request( $order, $this->get_different_destination() );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'woocommerce_rest_checkout_order_address_change_not_allowed', $response->get_data()['code'] );
+ // Shipping is untouched, so only the tax check can reject this.
+ $this->assertStringContainsString( 'the tax was calculated', $response->get_data()['message'] );
+ $this->assertEquals( 'US', wc_get_order( $order->get_id() )->get_billing_country() );
+ }
+
+ /**
+ * @testdox Existing order payment should allow a billing address change when tax is based on shipping.
+ */
+ public function test_checkout_order_allows_billing_address_change_when_tax_is_shipping_based() {
+ $this->set_taxes_based_on( 'shipping' );
+
+ $order = $this->create_pay_for_order_with_shipping_address();
+
+ // Tax follows shipping here, so a new billing country cannot change the total.
+ $response = $this->dispatch_pay_for_order_request( $order, $this->get_different_destination() );
+
+ $this->assertEquals( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+
+ $stored_order = wc_get_order( $order->get_id() );
+ $this->assertEquals( 'GB', $stored_order->get_billing_country() );
+ $this->assertEquals( 'US', $stored_order->get_shipping_country() );
+ }
+
/**
* @testdox Paying an existing order enforces the coupon's global usage limit.
* @testWith ["route-global-limited", true, 409, false]
@@ -2573,35 +3043,7 @@ class Checkout extends \WP_Test_REST_TestCase {
$order->set_recorded_coupon_usage_counts( false );
$order->save();
- $address = array(
- 'first_name' => 'Test',
- 'last_name' => 'User',
- 'company' => '',
- 'address_1' => '123 Test St',
- 'address_2' => '',
- 'city' => 'Test City',
- 'state' => 'CA',
- 'postcode' => '90210',
- 'country' => 'US',
- 'phone' => '555-32123',
- );
- $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout/' . $order->get_id() );
- $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
- $request->set_query_params(
- array(
- 'key' => $order->get_order_key(),
- 'billing_email' => $order->get_billing_email(),
- )
- );
- $request->set_body_params(
- array(
- 'billing_address' => array_merge( $address, array( 'email' => $order->get_billing_email() ) ),
- 'shipping_address' => $address,
- 'payment_method' => WC_Gateway_BACS::ID,
- )
- );
-
- $response = rest_get_server()->dispatch( $request );
+ $response = $this->dispatch_pay_for_order_request( $order );
$this->assertEquals( $expected_status, $response->get_status(), wp_json_encode( $response->get_data() ) );
@@ -2636,35 +3078,8 @@ class Checkout extends \WP_Test_REST_TestCase {
$order->save();
$order->apply_coupon( $coupon->get_code() );
- $address = array(
- 'first_name' => 'Test',
- 'last_name' => 'User',
- 'company' => '',
- 'address_1' => '123 Test St',
- 'address_2' => '',
- 'city' => 'Test City',
- 'state' => 'CA',
- 'postcode' => '90210',
- 'country' => 'US',
- 'phone' => '555-32123',
- );
- $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout/' . $order->get_id() );
- $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
- $request->set_query_params(
- array(
- 'key' => $order->get_order_key(),
- 'billing_email' => 'reserve@example.com',
- )
- );
- $request->set_body_params(
- array(
- 'billing_address' => array_merge( $address, array( 'email' => 'buyer@example.com' ) ),
- 'shipping_address' => $address,
- 'payment_method' => WC_Gateway_BACS::ID,
- )
- );
-
- $response = rest_get_server()->dispatch( $request );
+ // Only the email changes, so the address guard lets the request reach the coupon check.
+ $response = $this->dispatch_pay_for_order_request( $order, array( 'email' => 'buyer@example.com' ) );
$this->assertSame( 409, $response->get_status(), print_r( $response->get_data(), true ) );
$this->assertSame( 'woocommerce_rest_order_coupon_errors', $response->get_data()['code'] );