Commit d2f79728d23 for woocommerce
commit d2f79728d23653a894dff02637620a497ee27676
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Wed Jul 15 15:22:16 2026 +0300
Preserve physical Store API shipping details in admin (#66627)
* fix: preserve physical order shipping details
Store API orders without a shipping method can still contain physical products when shipping is disabled or a method is removed later. Treating every missing shipping line as a virtual-only order hid persisted fulfillment details in the admin summary.
Require a non-empty, entirely virtual and resolvable product-line set before suppressing matching billing-derived shipping data. Retained shipping lines remain the historical signal, so later catalog edits continue to leave physical order summaries unchanged.
Refs #66613
* test: codify shipping summary product transitions
Store API orders without shipping lines intentionally use current product fulfillment semantics because order items do not retain an order-time virtual snapshot.
Cover both catalog transition directions so later changes do not accidentally claim immutable historical classification.
Refs #66613
* test: cover ambiguous shipping summary products
Deleted products and mixed virtual and physical orders must not be classified as entirely virtual when no shipping line exists.
Codify the conservative visible fallback for unresolved or currently physical product lines.
Refs #66613
* docs: clarify shipping summary product semantics
The no-line fallback intentionally reflects current filterable product state rather than reconstructing immutable order-time fulfillment intent.
Document catalog-edit and ambiguity behavior so the helper contract matches its regression coverage.
Refs #66613
* test: cover deleted shipping summary variations
Order item product resolution prefers variation IDs, so the existing deleted simple-product case did not exercise the variation-specific path.
Cover a virtual ordered variation being permanently deleted and verify the conservative shipping-address fallback remains visible.
Refs #66613
* docs: explain shipping summary helper's local aggregation
The `order_has_only_virtual_products()` helper duplicates the
iterate-items / resolve-product / `needs_shipping()` shape already present
in `WC_Order::needs_shipping()` and the shipping-label helpers, which
invites a future "simplification" that swaps one of them in.
Those helpers are not behavior-preserving substitutes: `needs_shipping()`
short-circuits on the global shipping setting, and none of them default
unresolved or product-less orders to the conservative "keep visible"
outcome this admin summary requires. Document why the aggregation and
fallback are deliberately local so the divergence is not mistaken for
accidental reinvention.
Refs #66613
* test: cover shipping summary product-less order fallbacks
The virtual-only gate keeps shipping details visible whenever an order's
product-line set is empty or contains a non-product item, but no test
reached either branch: every case built orders through a factory that
always adds exactly one product line item.
Add a fee-only Store API order to exercise the empty product-line
fallback, and a filtered non-product line item to exercise the
`WC_Order_Item_Product` guard, both asserting the billing-derived
shipping summary stays visible. Extract the shared billing-derived
address setup into a helper so the new cases reuse it.
Refs #66613
* docs: name the catalog-edit-immunity departure in shipping summary helper
The order_has_only_virtual_products() docblock already described that the
fallback reflects live, filterable catalog state, but it did not connect that
to PR #66488's principle that suppression should rest on order-time evidence
immune to later catalog edits. A reviewer looking for that principle would not
land on the reasoning for departing from it.
Add a paragraph that names the departure explicitly, scopes it to the
no-shipping-line population (the only orders that reach this fallback, since
order_has_no_shipping() gates it), and records the accepted tradeoff: a later
virtual reclassification can re-hide a no-line order that shipped. It points at
the test that pins that behavior as intended. Documentation only; no behavior
change.
Refs #66627
diff --git a/plugins/woocommerce/changelog/fix-66613-physical-order-admin-shipping-summary b/plugins/woocommerce/changelog/fix-66613-physical-order-admin-shipping-summary
new file mode 100644
index 00000000000..626ce350a89
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-66613-physical-order-admin-shipping-summary
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep shipping details visible in admin for physical Store API orders without a shipping method.
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-data.php b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-data.php
index e09e220375d..aef6a07c8c6 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-data.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-data.php
@@ -185,13 +185,11 @@ class WC_Meta_Box_Order_Data {
}
/**
- * Whether an order was placed without any shipping, based on persisted order data.
+ * Whether an order has no persisted shipping method.
*
- * A purchase that needs no fulfillment is saved without a shipping line item. Relying
- * on the order's own shipping items — rather than the current virtual state of the
- * catalog products, which a merchant can change after the order is placed — keeps a
- * catalog edit from ever rewriting a historical order's shipping summary, and keeps
- * orders that retain a shipping line (including Store API local pickup) showing.
+ * A shipping line is order-time fulfillment evidence that remains stable when a
+ * merchant later changes the ordered catalog products. Orders that retain one,
+ * including Store API local pickup orders, must keep showing shipping details.
*
* @param WC_Order $order Order object.
* @return bool
@@ -200,6 +198,57 @@ class WC_Meta_Box_Order_Data {
return 0 === count( $order->get_shipping_methods() );
}
+ /**
+ * Whether every resolvable product line on an order is currently virtual.
+ *
+ * This check is used only when no persisted shipping method provides historical
+ * fulfillment evidence. It intentionally reflects current, filterable catalog
+ * semantics, so product edits can change the summary for no-line orders. Empty
+ * or unresolved product sets remain visible because suppressing their persisted
+ * shipping details would be ambiguous.
+ *
+ * Reading live catalog state is a deliberate, bounded departure from PR #66488's
+ * principle that suppression should rest on order-time evidence immune to later
+ * catalog edits. This fallback runs only for orders with no persisted shipping line
+ * (guaranteed by order_has_no_shipping() earlier in the gate's && chain), which is
+ * exactly the population that carries no such order-time evidence, so the catalog
+ * read is the only available signal and can never override a persisted shipping line.
+ * The accepted tradeoff: reclassifying a product as virtual can later re-hide a
+ * no-line order that genuinely shipped. That recurrence is display-only, admin-only,
+ * filterable, and pinned as intended by
+ * test_hides_shipping_details_after_physical_product_without_shipping_line_becomes_virtual().
+ *
+ * The aggregation and fallback here are deliberately local rather than reusing
+ * WC_Order::needs_shipping() or the shipping-label helpers: needs_shipping()
+ * short-circuits on the global shipping setting and, like those helpers, does not
+ * default unresolved or product-less orders to the conservative "keep visible"
+ * outcome this summary requires.
+ *
+ * @param WC_Order $order Order object.
+ * @return bool
+ */
+ private static function order_has_only_virtual_products( $order ) {
+ $items = $order->get_items();
+
+ if ( empty( $items ) ) {
+ return false;
+ }
+
+ foreach ( $items as $item ) {
+ if ( ! $item instanceof WC_Order_Item_Product ) {
+ return false;
+ }
+
+ $product = $item->get_product();
+
+ if ( ! $product instanceof WC_Product || $product->needs_shipping() ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
/**
* Whether an order's shipping address is still the billing-derived copy.
*
@@ -613,7 +662,10 @@ class WC_Meta_Box_Order_Data {
if ( $order->get_user_id() !== 0 && is_wp_error( $user ) ) {
echo '<p>' . esc_html( $details_not_available_message ) . '</p>';
} else {
- $hide_core_shipping_details = 'store-api' === $order->get_created_via() && self::order_has_no_shipping( $order ) && self::order_shipping_matches_billing( $order );
+ $hide_core_shipping_details = 'store-api' === $order->get_created_via()
+ && self::order_has_no_shipping( $order )
+ && self::order_has_only_virtual_products( $order )
+ && self::order_shipping_matches_billing( $order );
/**
* Filters whether billing-derived shipping details are hidden in the order admin summary.
diff --git a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-order-data-test.php b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-order-data-test.php
index 0f7d48cdb94..1df7d6e168c 100644
--- a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-order-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-order-data-test.php
@@ -168,6 +168,44 @@ class WC_Meta_Box_Order_Data_Test extends WC_Unit_Test_Case {
$this->assertStringNotContainsString( 'No shipping address set.', $summary );
}
+ /**
+ * @testdox The read-only summary displays shipping details for a physical Store API order without a shipping method.
+ */
+ public function test_displays_shipping_details_for_physical_store_api_order_without_shipping_method(): void {
+ $order = $this->create_order_with_shipping_data( false, 'store-api', 'flat_rate', false );
+
+ $this->assertCount( 0, $order->get_shipping_methods(), 'The physical order should exercise the missing shipping-method path.' );
+ $this->assertFalse( $this->products[0]->is_virtual(), 'The ordered product should require physical fulfillment.' );
+
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( 'Virtual Customer', $summary );
+ $this->assertStringContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringContainsString( '555-0100', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary preserves physical shipping details after the shipping method is removed.
+ */
+ public function test_preserves_physical_shipping_details_after_shipping_method_is_removed(): void {
+ $order = $this->create_order_with_shipping_data( true );
+
+ foreach ( array_keys( $order->get_shipping_methods() ) as $shipping_item_id ) {
+ $order->remove_item( $shipping_item_id );
+ }
+ $order->save();
+ $order = wc_get_order( $order->get_id() );
+
+ $this->assertCount( 0, $order->get_shipping_methods(), 'The shipping method should be removed from the persisted order.' );
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( 'Virtual Customer', $summary );
+ $this->assertStringContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringContainsString( '555-0100', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
/**
* @testdox The read-only summary preserves explicit shipping details for non-Store API orders.
*/
@@ -225,6 +263,181 @@ class WC_Meta_Box_Order_Data_Test extends WC_Unit_Test_Case {
$this->assertStringNotContainsString( 'No shipping address set.', $summary );
}
+ /**
+ * @testdox The read-only summary displays shipping details after a no-line virtual product becomes physical.
+ */
+ public function test_displays_shipping_details_after_virtual_product_without_shipping_line_becomes_physical(): void {
+ $order = $this->create_order_with_shipping_data( false );
+
+ $this->products[0]->set_virtual( false );
+ $this->products[0]->save();
+ $order = wc_get_order( $order->get_id() );
+
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringContainsString( '555-0100', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary hides matching shipping details after a no-line physical product becomes virtual.
+ */
+ public function test_hides_shipping_details_after_physical_product_without_shipping_line_becomes_virtual(): void {
+ $order = $this->create_order_with_shipping_data( false, 'store-api', 'flat_rate', false );
+
+ $this->products[0]->set_virtual( true );
+ $this->products[0]->save();
+ $order = wc_get_order( $order->get_id() );
+
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( 'No shipping address set.', $summary );
+ $this->assertStringNotContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringNotContainsString( '555-0100', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary displays shipping details when the ordered product no longer resolves.
+ */
+ public function test_displays_shipping_details_after_virtual_product_is_deleted(): void {
+ $order = $this->create_order_with_shipping_data( false );
+
+ $this->products[0]->delete( true );
+ $order = wc_get_order( $order->get_id() );
+
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringContainsString( '555-0100', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary displays shipping details when the ordered variation no longer resolves.
+ */
+ public function test_displays_shipping_details_after_virtual_variation_is_deleted(): void {
+ $order = $this->create_order_with_shipping_data( false );
+
+ $parent_product = new WC_Product_Variable();
+ $parent_product->set_name( 'Order admin variable product' );
+ $parent_product->save();
+
+ $variation = WC_Helper_Product::create_product_variation_object(
+ $parent_product->get_id(),
+ 'ORDER ADMIN DELETED VARIATION ' . wp_generate_uuid4(),
+ 10,
+ array()
+ );
+ $variation->set_virtual( true );
+ $variation->save();
+
+ $item = current( $order->get_items() );
+ $this->assertInstanceOf( WC_Order_Item_Product::class, $item );
+ $item->set_product( $variation );
+ $item->save();
+
+ $this->products[] = $parent_product;
+ $this->products[] = $variation;
+ $variation->delete( true );
+ $order = wc_get_order( $order->get_id() );
+
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringContainsString( '555-0100', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary displays shipping details for a mixed order without a shipping line.
+ */
+ public function test_displays_shipping_details_for_mixed_order_without_shipping_line(): void {
+ $order = $this->create_order_with_shipping_data( false );
+
+ $physical_product = WC_Helper_Product::create_simple_product();
+ $physical_product->set_virtual( false );
+ $physical_product->save();
+
+ $item = new WC_Order_Item_Product();
+ $item->set_product( $physical_product );
+ $item->set_quantity( 1 );
+ $order->add_item( $item );
+ $order->save();
+
+ $this->products[] = $physical_product;
+ $order = wc_get_order( $order->get_id() );
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringContainsString( '555-0100', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary displays shipping details for a Store API order with no product line items.
+ */
+ public function test_displays_shipping_details_for_store_api_order_without_product_lines(): void {
+ $order = wc_create_order( array( 'customer_id' => 0 ) );
+ $order->set_created_via( 'store-api' );
+
+ // A fee-only order has no product line items, so the virtual-only gate
+ // hits its empty-items fallback and must keep shipping details visible.
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_name( 'Handling fee' );
+ $fee->set_amount( '10' );
+ $fee->set_total( '10' );
+ $order->add_item( $fee );
+
+ $this->apply_billing_derived_shipping_data( $order );
+ $order->save();
+ $this->orders[] = $order;
+
+ $order = wc_get_order( $order->get_id() );
+
+ $this->assertCount( 0, $order->get_shipping_methods(), 'The fee-only order should exercise the missing shipping-method path.' );
+ $this->assertEmpty( $order->get_items(), 'The order should have no product line items so the empty-items fallback is exercised.' );
+
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringContainsString( '555-0100', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary displays shipping details when a non-product line item is present on an otherwise virtual order.
+ */
+ public function test_displays_shipping_details_when_order_has_non_product_line_item(): void {
+ // This order would hide the billing-derived summary on its own (virtual-only,
+ // Store API, no shipping method, shipping matches billing).
+ $order = $this->create_order_with_shipping_data( false );
+
+ // Inject a non-product line item through the items filter to exercise the
+ // conservative guard that keeps details visible for unclassifiable lines.
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_name( 'Handling fee' );
+ $fee->set_amount( '10' );
+ $fee->set_total( '10' );
+
+ $inject_non_product_item = static function ( $items ) use ( $fee ) {
+ $items[] = $fee;
+
+ return $items;
+ };
+ add_filter( 'woocommerce_order_get_items', $inject_non_product_item );
+
+ try {
+ $summary = $this->render_shipping_address_summary( $order );
+ } finally {
+ remove_filter( 'woocommerce_order_get_items', $inject_non_product_item );
+ }
+
+ $this->assertStringContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringContainsString( '555-0100', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
/**
* @testdox The read-only summary shows explicitly edited shipping details on a Store API order without shipping.
*/
@@ -350,15 +563,21 @@ class WC_Meta_Box_Order_Data_Test extends WC_Unit_Test_Case {
/**
* Create an order with billing-derived shipping data.
*
- * @param bool $add_shipping_method Whether to add a shipping method to the order.
- * @param string $created_via Order creation source.
- * @param string $shipping_method_id Shipping method ID.
+ * @param bool $add_shipping_method Whether to add a shipping method to the order.
+ * @param string $created_via Order creation source.
+ * @param string $shipping_method_id Shipping method ID.
+ * @param bool|null $product_is_virtual Whether the ordered product is virtual. Defaults to the inverse of $add_shipping_method.
* @return WC_Order
*/
- private function create_order_with_shipping_data( bool $add_shipping_method, string $created_via = 'store-api', string $shipping_method_id = 'flat_rate' ): WC_Order {
+ private function create_order_with_shipping_data(
+ bool $add_shipping_method,
+ string $created_via = 'store-api',
+ string $shipping_method_id = 'flat_rate',
+ ?bool $product_is_virtual = null
+ ): WC_Order {
$product = WC_Helper_Product::create_simple_product();
$product->set_name( 'Order admin display product' );
- $product->set_virtual( ! $add_shipping_method );
+ $product->set_virtual( $product_is_virtual ?? ! $add_shipping_method );
$product->save();
$order = wc_create_order( array( 'customer_id' => 0 ) );
@@ -369,6 +588,32 @@ class WC_Meta_Box_Order_Data_Test extends WC_Unit_Test_Case {
$item->set_quantity( 1 );
$order->add_item( $item );
+ $this->apply_billing_derived_shipping_data( $order );
+
+ if ( $add_shipping_method ) {
+ $shipping_item = new WC_Order_Item_Shipping();
+ $shipping_item->set_method_title( 'Shipping method' );
+ $shipping_item->set_method_id( $shipping_method_id );
+ $order->add_item( $shipping_item );
+ }
+
+ $order->save();
+
+ $this->products[] = $product;
+ $this->orders[] = $order;
+
+ return $order;
+ }
+
+ /**
+ * Apply billing-derived shipping data to an order.
+ *
+ * Mirrors the Store API behavior of copying the billing address into the
+ * shipping address for a purchase that needs no fulfillment.
+ *
+ * @param WC_Order $order Order to populate.
+ */
+ private function apply_billing_derived_shipping_data( WC_Order $order ): void {
$order->set_billing_first_name( 'Virtual' );
$order->set_billing_last_name( 'Customer' );
$order->set_billing_address_1( '500 Billing Avenue' );
@@ -385,20 +630,6 @@ class WC_Meta_Box_Order_Data_Test extends WC_Unit_Test_Case {
$order->set_shipping_postcode( '94105' );
$order->set_shipping_country( 'US' );
$order->set_shipping_phone( '555-0100' );
-
- if ( $add_shipping_method ) {
- $shipping_item = new WC_Order_Item_Shipping();
- $shipping_item->set_method_title( 'Shipping method' );
- $shipping_item->set_method_id( $shipping_method_id );
- $order->add_item( $shipping_item );
- }
-
- $order->save();
-
- $this->products[] = $product;
- $this->orders[] = $order;
-
- return $order;
}
/**