Commit 881a5cd5a17 for woocommerce

commit 881a5cd5a175b8f80225fb9f0f4d8cf9ef65a006
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date:   Thu Oct 1 11:04:32 2026 +0100

    Fix additional checkout field display across checkout types (#68780)

    * Fix additional checkout field display across checkout types

    Refs #68156.

    * Add changelog for additional checkout field display

    * Keep checkout defaults out of saved order fields

    Refs #59792.

    * Update changelog for saved order field defaults

    * Make additional field defaults optional in the shared reader

    Refs #59792 and #68156.

    * Clarify changelog for saved additional checkout fields

    * Make bulk field defaults optional while preserving checkout prefills

    Refs #59792 and #68156.

    * Revert bulk field defaults and Store API response changes

    Reverts commit 506cee677db5e61089bd2858b5a1b7d0989527f2. Keep the fix limited to saved order field display. Refs #59792 and #68156.

diff --git a/plugins/woocommerce/changelog/fix-additional-checkout-field-order-values b/plugins/woocommerce/changelog/fix-additional-checkout-field-order-values
new file mode 100644
index 00000000000..c8ffa726ffc
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-additional-checkout-field-order-values
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Only show additional checkout fields with saved values, from any checkout flow.
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/CheckboxFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/CheckboxFieldType.php
index a543fbd4929..16ea566b8cd 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/CheckboxFieldType.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/CheckboxFieldType.php
@@ -50,13 +50,13 @@ class CheckboxFieldType extends AbstractFieldType {
 	}

 	/**
-	 * Converts the stored '1'/'0' meta value back to a boolean.
+	 * Converts the stored '1'/'0' meta value back to a boolean, returning null for missing or empty values.
 	 *
 	 * @param mixed $value The stored value.
-	 * @return bool
+	 * @return bool|null
 	 */
 	public function from_storage( $value ) {
-		return '1' === $value;
+		return null === $value || '' === $value ? null : '1' === $value;
 	}

 	/**
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
index 7a9420d58ff..6f34341204f 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
@@ -923,7 +923,7 @@ class CheckoutFields {
 	}

 	/**
-	 * Get additional fields for an order.
+	 * Get saved additional fields for an order without applying checkout defaults.
 	 *
 	 * @param WC_Order $order Order object.
 	 * @param string   $location The location to get fields for (address|contact|order).
@@ -933,21 +933,13 @@ class CheckoutFields {
 	 */
 	public function get_order_additional_fields_with_values( WC_Order $order, string $location, string $group = 'other', string $context = 'edit' ) {

-		// Because the Additional Checkout Fields API only applies to orders created with Store API, we should not
-		// return any values unless it was created using Store API. This is mainly to prevent "empty" checkbox values
-		// from being shown on the order confirmation page for orders placed using the shortcode. It's rare that this
-		// will happen but not impossible.
-		if ( 'store-api' !== $order->get_created_via() ) {
-			return [];
-		}
-
 		$location           = $this->prepare_location_name( $location );
 		$group              = $this->prepare_group_name( $group );
 		$fields             = $this->get_fields_for_location( $location );
 		$fields_with_values = [];

 		foreach ( $fields as $field_key => $field ) {
-			$value = $this->get_field_from_object( $field_key, $order, $group );
+			$value = $this->get_field_from_object( $field_key, $order, $group, false );

 			if ( '' === $value || null === $value ) {
 				continue;
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsStorage.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsStorage.php
index 9067cd94d94..ef650767071 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsStorage.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsStorage.php
@@ -84,18 +84,21 @@ trait CheckoutFieldsStorage {
 	/**
 	 * Returns a field value for a given object.
 	 *
+	 * @since 11.3.0 Added the $apply_defaults parameter.
+	 *
 	 * @param string               $key The field key.
 	 * @param WC_Customer|WC_Order $wc_object The customer or order to get the field value for.
 	 * @param string               $group The group to get the field value for (shipping|billing|other).
+	 * @param bool                 $apply_defaults Whether to apply the default-value filter when no value is set.
 	 *
 	 * @return mixed The field value.
 	 */
-	public function get_field_from_object( string $key, WC_Data $wc_object, string $group = 'other' ) {
+	public function get_field_from_object( string $key, WC_Data $wc_object, string $group = 'other', bool $apply_defaults = true ) {
 		$group    = $this->prepare_group_name( $group );
 		$meta_key = self::get_group_key( $group ) . $key;
 		$value    = $wc_object->get_meta( $meta_key, true );

-		if ( ! $value && '0' !== $value ) {
+		if ( $apply_defaults && ! $value && '0' !== $value ) {
 			/**
 			 * Allow providing a default value for additional fields if no value is already set.
 			 *
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsFrontendTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsFrontendTest.php
index 916ecf9a621..50a02117573 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsFrontendTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsFrontendTest.php
@@ -380,7 +380,6 @@ class CheckoutFieldsFrontendTest extends \WC_Unit_Test_Case {
 		);

 		$order = \WC_Helper_Order::create_order();
-		$order->set_created_via( 'store-api' );
 		$this->controller->persist_field_for_order( 'mynamespace/billing_confirmation_field', 'secret value', $order, 'billing', false );
 		$order->save();

@@ -404,7 +403,6 @@ class CheckoutFieldsFrontendTest extends \WC_Unit_Test_Case {
 		);

 		$order = \WC_Helper_Order::create_order();
-		$order->set_created_via( 'store-api' );
 		$this->controller->persist_field_for_order( 'mynamespace/billing_confirmation_field', 'visible value', $order, 'billing', false );
 		$order->save();

diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php
index a09d2810109..3d3c962bc8b 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php
@@ -204,6 +204,115 @@ class CheckoutFieldsTest extends WP_UnitTestCase {
 		$this->assertArrayNotHasKey( 'namespace/vat-number', $fields );
 	}

+	/**
+	 * @testdox Missing checkboxes stay hidden while saved answers are shown.
+	 * @testWith [null, "", null]
+	 *           [false, false, "No"]
+	 *           [true, true, "Yes"]
+	 *
+	 * @param bool|null   $saved_value Checkbox answer, or null for a missing value.
+	 * @param bool|string $expected_value Expected raw value.
+	 * @param string|null $expected_display Expected display value, or null for a hidden field.
+	 */
+	public function test_order_checkbox_values( ?bool $saved_value, $expected_value, ?string $expected_display ): void {
+		$order = new \WC_Order();
+		$key   = 'plugin-namespace/leave-on-porch';
+
+		if ( null !== $saved_value ) {
+			$this->controller->persist_field_for_order( $key, $saved_value, $order, 'other', false );
+		}
+
+		$this->assertSame( $expected_value, $this->controller->get_field_from_object( $key, $order ), 'Missing values must remain distinct from saved unchecked answers.' );
+
+		$fields = $this->controller->get_order_additional_fields_with_values( $order, 'order', 'other', 'view' );
+		if ( null === $expected_display ) {
+			$this->assertArrayNotHasKey( $key, $fields, 'A missing checkbox must not appear on the order.' );
+		} else {
+			$this->assertArrayHasKey( $key, $fields, 'A saved checkbox must appear on the order.' );
+			$this->assertSame( $expected_display, $fields[ $key ]['value'] );
+		}
+	}
+
+	/**
+	 * @testdox Order fields use saved values and never apply checkout defaults.
+	 * @testWith ["text", null, null, null]
+	 *           ["text", "", null, null]
+	 *           ["text", "Saved answer", "Saved answer", "Saved answer"]
+	 *           ["text", "0", "0", "0"]
+	 *           ["checkbox", null, null, null]
+	 *           ["checkbox", "", null, null]
+	 *           ["checkbox", "0", false, "No"]
+	 *           ["checkbox", "1", true, "Yes"]
+	 *
+	 * @param string           $type Field type.
+	 * @param string|null      $saved_value Saved answer, or null for a missing value.
+	 * @param bool|string|null $expected_edit Expected edit value, or null for an omitted field.
+	 * @param string|null      $expected_view Expected display value, or null for an omitted field.
+	 */
+	public function test_order_fields_do_not_use_defaults( string $type, ?string $saved_value, $expected_edit, ?string $expected_view ): void {
+		$key = 'plugin-namespace/default-address-field';
+		woocommerce_register_additional_checkout_field(
+			array(
+				'id'       => $key,
+				'label'    => 'Default address field',
+				'location' => 'address',
+				'type'     => $type,
+			)
+		);
+
+		$default_calls = 0;
+		add_filter(
+			"woocommerce_get_default_value_for_{$key}",
+			static function () use ( &$default_calls ) {
+				++$default_calls;
+				return '1';
+			}
+		);
+
+		$order = new \WC_Order();
+		if ( null !== $saved_value ) {
+			$this->controller->persist_field_for_order( $key, $saved_value, $order, 'billing', false );
+		}
+
+		$this->assertSame( $expected_edit ?? '', $this->controller->get_field_from_object( $key, $order, 'billing', false ), 'The reader must preserve saved values when defaults are disabled.' );
+
+		$expected_values = array(
+			'edit' => $expected_edit,
+			'view' => $expected_view,
+		);
+		foreach ( $expected_values as $context => $expected ) {
+			$fields = $this->controller->get_order_additional_fields_with_values( $order, 'address', 'billing', $context );
+			if ( null === $expected ) {
+				$this->assertArrayNotHasKey( $key, $fields, 'Missing and empty answers must not be replaced by defaults.' );
+			} else {
+				$this->assertArrayHasKey( $key, $fields, 'Saved answers must remain available.' );
+				$this->assertSame( $expected, $fields[ $key ]['value'] );
+			}
+		}
+
+		$this->assertSame( 0, $default_calls, 'Reading order fields must not call the default-value filter.' );
+	}
+
+	/**
+	 * @testdox Checkbox defaults still apply when no value is saved.
+	 * @testWith ["0", false]
+	 *           ["1", true]
+	 *
+	 * @param string $default_value Filtered default value.
+	 * @param bool   $expected_value Expected checkbox value.
+	 */
+	public function test_missing_checkbox_uses_filtered_default( string $default_value, bool $expected_value ): void {
+		$key = 'plugin-namespace/leave-on-porch';
+		add_filter(
+			"woocommerce_get_default_value_for_{$key}",
+			static function () use ( $default_value ) {
+				return $default_value;
+			}
+		);
+
+		$this->assertSame( $expected_value, $this->controller->get_field_from_object( $key, new \WC_Order() ), 'The default-value filter must still supply checkbox values.' );
+	}
+
 	/**
 	 * @testdox Date fields can be registered, with their constraints stored as registered.
 	 */