Commit a0a5fdd5dce for woocommerce

commit a0a5fdd5dce661de88a8520908019c811086d722
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date:   Fri Sep 11 15:43:03 2026 +0200

    Fix conditional checkout field rules for fields with no value yet (#68220)

    * Fix conditional field rules matching when a field has no value yet

    * Add changelog entry for conditional checkout field rules fix

    * Remove leftover experimental-blocks gate from field persistence

    * Update feature flag docs after ungating conditional field persistence

    * update changelog

    * remove duplicate changelog (thanks AI!)

    * Add conditional rule tests for contact and address location fields

    * Remove redundant AI comment

    * update comment

    * Provide schema and order properties for CheckoutTrait in DocumentObject tests

    * Fix saved checkout field values during validation

    * Update changelog for conditional checkout field fixes

    * Fix nullable order handling in checkout payment route

diff --git a/plugins/woocommerce/changelog/fix-66943-additional-fields-empty-document-object b/plugins/woocommerce/changelog/fix-66943-additional-fields-empty-document-object
new file mode 100644
index 00000000000..d5707d73bc1
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-66943-additional-fields-empty-document-object
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix conditional checkout fields when dependency values are missing, contain punctuation, or come from a saved order during payment retries.
diff --git a/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md b/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md
index 70fec879e22..ef5d5dd77ab 100644
--- a/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md
+++ b/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md
@@ -15,7 +15,7 @@ WooCommerce currently uses two feature-flag systems in Blocks:

 | Flag | Defaults | Current Blocks usage |
 | --- | --- | --- |
-| `experimental-blocks` | Enabled in [development](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/client/admin/config/development.json) and disabled in [core builds](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/client/admin/config/core.json). | Exposed to the editor through [`isExperimentalBlocksEnabled()`](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/client/blocks/assets/js/settings/blocks/feature-flags.ts). It currently gates the **Disable product descriptions** editor control in Checkout Order Summary Cart Items and conditional checkout-field processing in the Store API. It does not determine which block scripts webpack builds or which general block types are registered. |
+| `experimental-blocks` | Enabled in [development](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/client/admin/config/development.json) and disabled in [core builds](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/client/admin/config/core.json). | Exposed to the editor through [`isExperimentalBlocksEnabled()`](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/client/blocks/assets/js/settings/blocks/feature-flags.ts). It currently gates the **Disable product descriptions** editor control in Checkout Order Summary Cart Items. It does not determine which block scripts webpack builds or which general block types are registered. |
 | `rest-api-v4` | Disabled in both development and core build configurations. | Exposed through [`isExperimentalWcRestApiV4Enabled()`](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/client/blocks/assets/js/settings/blocks/feature-flags.ts). It switches product entities from `/wc/v3/products` to `/wc/v4/products`, registers the settings entity, and enables the v4 product-data paths used by Product Price and Product Button in the editor. |

 ### Runtime feature flags
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 9c69a31fec6..862b3c8a582 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -68316,12 +68316,6 @@ parameters:
 			count: 1
 			path: src/StoreApi/Routes/V1/CheckoutOrder.php

-		-
-			message: '#^Property Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\CheckoutOrder\:\:\$order \(WC_Order\) does not accept WC_Order\|WC_Order_Refund\|false\.$#'
-			identifier: assign.propertyType
-			count: 1
-			path: src/StoreApi/Routes/V1/CheckoutOrder.php
-
 		-
 			message: '#^Call to an undefined method WC_Order\|WC_Order_Refund\:\:get_billing_email\(\)\.$#'
 			identifier: method.notFound
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index e02f627e696..3b01357619b 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -272,10 +272,11 @@ class Checkout extends AbstractCartRoute {
 		$invalid_details = [];
 		$is_partial      = in_array( $request->get_method(), [ 'PUT', 'PATCH' ], true );

+		$document_object = $this->get_document_object_from_rest_request( $request );
+
 		foreach ( $validate_contexts as $context => $context_data ) {
 			$errors = new \WP_Error();

-			$document_object = $this->get_document_object_from_rest_request( $request );
 			$document_object->set_context( $context );
 			$additional_fields = $this->additional_fields_controller->get_contextual_fields_for_location( $context_data['location'], $document_object );

@@ -915,9 +916,10 @@ class Checkout extends AbstractCartRoute {
 			],
 		];

+		$document_object = $this->get_document_object_from_rest_request( $request );
+
 		foreach ( $additional_field_contexts as $context => $context_data ) {

-			$document_object = $this->get_document_object_from_rest_request( $request );
 			$document_object->set_context( $context );
 			$additional_fields = $this->additional_fields_controller->get_contextual_fields_for_location( $context_data['location'], $document_object );

diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php b/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php
index 47a883e24d2..db93be762f4 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php
@@ -31,7 +31,7 @@ class CheckoutOrder extends AbstractCartRoute {
 	/**
 	 * Holds the current order being processed.
 	 *
-	 * @var \WC_Order
+	 * @var \WC_Order|null
 	 */
 	private $order = null;

@@ -106,15 +106,16 @@ class CheckoutOrder extends AbstractCartRoute {
 	 * @return \WP_REST_Response
 	 */
 	protected function get_route_post_response( \WP_REST_Request $request ) {
-		$order_id    = absint( $request['id'] );
-		$this->order = wc_get_order( $order_id );
+		$order_id = absint( $request['id'] );
+		$order    = wc_get_order( $order_id );

-		if ( ! $this->order instanceof \WC_Order || ! $this->order->needs_payment() ) {
+		if ( ! $order instanceof \WC_Order || ! $order->needs_payment() ) {
 			return new \WP_Error(
 				'invalid_order_update_status',
 				__( 'This order cannot be paid for.', 'woocommerce' )
 			);
 		}
+		$this->order = $order;

 		/**
 		 * Process request data.
@@ -198,6 +199,7 @@ class CheckoutOrder extends AbstractCartRoute {
 	 * @param \WP_REST_Request $request Full details about the request.
 	 */
 	private function update_billing_address( \WP_REST_Request $request ) {
+		$order    = $this->get_order_or_throw();
 		$customer = wc()->customer;

 		// Billing address is a required field.
@@ -206,9 +208,9 @@ class CheckoutOrder extends AbstractCartRoute {
 		// 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 );
+		$order->set_billing_address( $billing );
+		$order->set_shipping_address( $shipping );
+		$this->order_controller->validate_existing_order_before_update( $order );

 		// Update customer object with validated order addresses.
 		foreach ( $billing as $key => $value ) {
@@ -234,8 +236,8 @@ class CheckoutOrder extends AbstractCartRoute {
 		do_action( 'woocommerce_store_api_checkout_update_customer_from_request', $customer, $request );

 		$customer->save();
-		$this->order->save();
-		$this->order->calculate_totals();
+		$order->save();
+		$order->calculate_totals();
 	}

 	/**
@@ -249,7 +251,7 @@ class CheckoutOrder extends AbstractCartRoute {
 		$request_payment_method = wc_clean( wp_unslash( $request['payment_method'] ?? '' ) );

 		if ( empty( $request_payment_method ) ) {
-			if ( $this->order->needs_payment() ) {
+			if ( $this->get_order_or_throw()->needs_payment() ) {
 				throw new RouteException(
 					'woocommerce_rest_checkout_missing_payment_method',
 					__( 'No payment method provided.', 'woocommerce' ),
@@ -283,6 +285,6 @@ class CheckoutOrder extends AbstractCartRoute {
 	 * @param \WP_REST_Request $request Request object.
 	 */
 	private function process_customer( \WP_REST_Request $request ) {
-		$this->order_controller->sync_customer_data_with_order( $this->order );
+		$this->order_controller->sync_customer_data_with_order( $this->get_order_or_throw() );
 	}
 }
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
index b78eefee637..7fed5edb616 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
@@ -324,21 +324,17 @@ class CheckoutSchema extends AbstractSchema {
 			)
 			: $this->additional_fields_controller->get_all_fields_from_object( $wc_object, 'other' );

-		$additional_field_schema = $this->get_additional_fields_schema();
-		foreach ( $fields as $key => $value ) {
-			if ( ! isset( $additional_field_schema[ $key ] ) ) {
-				unset( $fields[ $key ] );
-				continue;
-			}
+		$response = [];
+		foreach ( $this->get_additional_fields_schema() as $key => $field_schema ) {
 			// This makes sure we're casting checkboxes from "1" and "0" to boolean. In the frontend, "0" is treated as truthy.
-			if ( isset( $additional_field_schema[ $key ]['type'] ) && 'boolean' === $additional_field_schema[ $key ]['type'] ) {
-				$fields[ $key ] = (bool) $value;
+			if ( isset( $field_schema['type'] ) && 'boolean' === $field_schema['type'] ) {
+				$response[ $key ] = (bool) ( $fields[ $key ] ?? false );
 			} else {
-				$fields[ $key ] = $this->prepare_html_response( $value );
+				$response[ $key ] = $this->prepare_html_response( $fields[ $key ] ?? '' );
 			}
 		}

-		return (object) $fields;
+		return (object) $response;
 	}

 	/**
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
index e6bd00f2651..2a4da72c0bb 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
@@ -7,7 +7,6 @@ use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
 use Automattic\WooCommerce\StoreApi\Payments\PaymentContext;
 use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
 use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\DocumentObject;
-use Automattic\WooCommerce\Admin\Features\Features;
 use WC_Customer;

 /**
@@ -327,19 +326,12 @@ trait CheckoutTrait {
 	 * @param callable         $persist Callback invoked as `$persist( string $key, mixed $value )` for each field.
 	 */
 	private function resolve_and_persist_additional_fields( \WP_REST_Request $request, callable $persist ): void {
-		if ( Features::is_enabled( 'experimental-blocks' ) ) {
-			$document_object = $this->get_document_object_from_rest_request( $request );
-			$document_object->set_context( 'order' );
-			$additional_fields = array_merge(
-				$this->additional_fields_controller->get_contextual_fields_for_location( 'order', $document_object ),
-				$this->additional_fields_controller->get_contextual_fields_for_location( 'contact', $document_object )
-			);
-		} else {
-			$additional_fields = array_merge(
-				$this->additional_fields_controller->get_fields_for_location( 'order' ),
-				$this->additional_fields_controller->get_fields_for_location( 'contact' )
-			);
-		}
+		$document_object = $this->get_document_object_from_rest_request( $request );
+		$document_object->set_context( 'order' );
+		$additional_fields = array_merge(
+			$this->additional_fields_controller->get_contextual_fields_for_location( 'order', $document_object ),
+			$this->additional_fields_controller->get_contextual_fields_for_location( 'contact', $document_object )
+		);

 		$field_values = isset( $request['additional_fields'] ) ? (array) $request['additional_fields'] : array();

@@ -361,27 +353,47 @@ trait CheckoutTrait {
 	 * Returns a document object from a REST request.
 	 *
 	 * @param \WP_REST_Request $request The REST request.
-	 * @return DocumentObject The document object or null if experimental blocks are not enabled.
+	 * @return DocumentObject The document object.
 	 */
 	public function get_document_object_from_rest_request( \WP_REST_Request $request ) {
+		// Keep the order local so validation errors do not release its stock or coupon holds.
+		$order        = $this->order ?? $this->get_draft_order();
+		$saved_fields = wc()->customer instanceof WC_Customer
+			? $this->additional_fields_controller->get_all_fields_from_object( wc()->customer, 'other' )
+			: [];
+		if ( $order instanceof \WC_Order ) {
+			$saved_fields = wp_parse_args(
+				$this->additional_fields_controller->get_all_fields_from_object( $order, 'other' ),
+				$saved_fields
+			);
+		}
+
+		$field_values      = wp_parse_args( $request['additional_fields'] ?? [], $saved_fields );
+		$additional_fields = [];
+		$registered_fields = array_merge(
+			$this->additional_fields_controller->get_fields_for_location( 'contact' ),
+			$this->additional_fields_controller->get_fields_for_location( 'order' )
+		);
+
+		// Conditions need raw values and explicit empty values for missing fields.
+		foreach ( $registered_fields as $key => $field ) {
+			$additional_fields[ $key ] = 'checkbox' === $field['type']
+				? (bool) ( $field_values[ $key ] ?? false )
+				: ( $field_values[ $key ] ?? '' );
+		}
+
 		return new DocumentObject(
 			[
 				'customer' => [
 					'billing_address'   => $request['billing_address'],
 					'shipping_address'  => $request['shipping_address'],
-					'additional_fields' => array_intersect_key(
-						$request['additional_fields'] ?? [],
-						array_flip( $this->additional_fields_controller->get_contact_fields_keys() )
-					),
+					'additional_fields' => $this->additional_fields_controller->filter_fields_for_location( $additional_fields, 'contact' ),
 				],
 				'checkout' => [
 					'payment_method'    => $request['payment_method'],
 					'create_account'    => $request['create_account'],
 					'customer_note'     => $request['customer_note'],
-					'additional_fields' => array_intersect_key(
-						$request['additional_fields'] ?? [],
-						array_flip( $this->additional_fields_controller->get_order_fields_keys() )
-					),
+					'additional_fields' => $this->additional_fields_controller->filter_fields_for_location( $additional_fields, 'order' ),
 				],
 			]
 		);
diff --git a/plugins/woocommerce/tests/e2e/test-plugins/blocks/additional-checkout-fields.php b/plugins/woocommerce/tests/e2e/test-plugins/blocks/additional-checkout-fields.php
index ae2bfcf214d..0b98bd7f5b9 100644
--- a/plugins/woocommerce/tests/e2e/test-plugins/blocks/additional-checkout-fields.php
+++ b/plugins/woocommerce/tests/e2e/test-plugins/blocks/additional-checkout-fields.php
@@ -325,6 +325,38 @@ class Additional_Checkout_Fields_Test_Helper {
 				),
 			)
 		);
+
+		// Only shown once "How did you hear about us?" is set to Facebook. Covers a rule that
+		// reads another additional field, which needs that field to have a value from the start.
+		woocommerce_register_additional_checkout_field(
+			array(
+				'id'       => 'third-plugin-namespace/facebook-page',
+				'label'    => 'Which Facebook page did you see us on?',
+				'location' => 'order',
+				'type'     => 'text',
+				'hidden'   => array(
+					'not' => array(
+						'type'       => 'object',
+						'properties' => array(
+							'checkout' => array(
+								'type'       => 'object',
+								'properties' => array(
+									'additional_fields' => array(
+										'type'       => 'object',
+										'properties' => array(
+											'third-plugin-namespace/how-did-you-hear-about-us' => array(
+												'type'  => 'string',
+												'const' => 'facebook',
+											),
+										),
+									),
+								),
+							),
+						),
+					),
+				),
+			)
+		);
 	}
 }

diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/checkout/additional-fields.guest-shopper.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/checkout/additional-fields.guest-shopper.block_theme.spec.ts
index 626908309ce..cf45a55174d 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/checkout/additional-fields.guest-shopper.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/checkout/additional-fields.guest-shopper.block_theme.spec.ts
@@ -368,6 +368,32 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
 			).toBeHidden();
 		} );

+		test( 'Conditional field stays hidden while the field it depends on has no value', async ( {
+			checkoutPageObject,
+			frontendUtils,
+		} ) => {
+			await frontendUtils.goToShop();
+			await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+			await frontendUtils.goToCheckout();
+
+			const facebookPage = checkoutPageObject.page.getByLabel(
+				'Which Facebook page did you see us on?'
+			);
+			const howDidYouHear = checkoutPageObject.page.getByLabel(
+				'How did you hear about us?'
+			);
+
+			// Nothing has been picked yet, so the rule must not match.
+			await expect( howDidYouHear ).toHaveValue( '' );
+			await expect( facebookPage ).toBeHidden();
+
+			await howDidYouHear.selectOption( 'facebook' );
+			await expect( facebookPage ).toBeVisible();
+
+			await howDidYouHear.selectOption( 'google' );
+			await expect( facebookPage ).toBeHidden();
+		} );
+
 		test( 'Conditional fields are shown/hidden based on cart state', async ( {
 			checkoutPageObject,
 			frontendUtils,
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsSchema/DocumentObjectTests.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsSchema/DocumentObjectTests.php
index 2f49d42df9a..86a537fa772 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsSchema/DocumentObjectTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsSchema/DocumentObjectTests.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Blocks\Domain\Services\CheckoutFieldsSche
 use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\DocumentObject;
 use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
 use Automattic\WooCommerce\StoreApi\Utilities\CheckoutTrait;
+use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
 use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
 use Automattic\WooCommerce\Blocks\Package;
 use Opis\JsonSchema\{
@@ -27,6 +28,7 @@ class DocumentObjectTests extends \WC_Unit_Test_Case {
 	 * to test the DocumentObject class.
 	 */
 	use CheckoutTrait;
+	use DraftOrderTrait;

 	/**
 	 * Checkout fields controller.
@@ -34,6 +36,12 @@ class DocumentObjectTests extends \WC_Unit_Test_Case {
 	 */
 	protected $additional_fields_controller;

+	/**
+	 * Current order, needed for the trait.
+	 * @var \WC_Order|null
+	 */
+	private $order = null;
+
 	/**
 	 * Fixture data.
 	 * @var FixtureData
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php
index 47730995ec9..1882ab8934a 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
 use Automattic\WooCommerce\Blocks\Package;
 use WC_Gateway_BACS;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Enums\OrderStatus;
 use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;

 /**
@@ -2460,7 +2461,7 @@ class AdditionalFields extends \WP_Test_REST_TestCase {
 		$this->assertEquals( 'billing-saved-gov-id', ( (array) $data['billing_address'] )['plugin-namespace/gov-id'], print_r( $data, true ) );
 		$this->assertEquals( 'shipping-saved-gov-id', ( (array) $data['shipping_address'] )['plugin-namespace/gov-id'], print_r( $data, true ) );
 		$this->assertEquals( 'engineering', $additional_fields['plugin-namespace/job-function'], print_r( $data, true ) );
-		$this->assertArrayNotHasKey( 'plugin-namespace/leave-on-porch', $additional_fields, print_r( $data, true ) );
+		$this->assertFalse( $additional_fields['plugin-namespace/leave-on-porch'], print_r( $data, true ) );
 	}

 	/**
@@ -2952,4 +2953,310 @@ class AdditionalFields extends \WP_Test_REST_TestCase {
 		\__internal_woocommerce_blocks_deregister_checkout_field( $id );
 		$this->assertFalse( $this->controller->is_field( $id ), sprintf( '%s is still registered', $id ) );
 	}
+
+	/**
+	 * Returns a rule matching the referral-source value at the given path.
+	 *
+	 * @param string[] $path Property path to the object holding the field (e.g. [ 'checkout', 'additional_fields' ]).
+	 * @param string   $value The value to match.
+	 * @return array The rule schema.
+	 */
+	private function get_referral_source_rule( array $path = array( 'checkout', 'additional_fields' ), string $value = 'other' ) {
+		$rule = array(
+			'type'       => 'object',
+			'properties' => array(
+				'plugin-namespace/referral-source' => array(
+					'type'  => 'string',
+					'const' => $value,
+				),
+			),
+		);
+		foreach ( array_reverse( $path ) as $segment ) {
+			$rule = array(
+				'type'       => 'object',
+				'properties' => array( $segment => $rule ),
+			);
+		}
+		return $rule;
+	}
+
+	/**
+	 * Registers a referral-source select field and a referral-detail field with a conditional rule on it.
+	 *
+	 * @param string $rule_prop Which conditional rule to give the detail field (hidden|required).
+	 * @param string $location The location to register both fields in (order|contact|address).
+	 */
+	private function register_conditional_referral_fields( $rule_prop, $location = 'order' ) {
+		$rule_paths = array(
+			'order'   => array( 'checkout', 'additional_fields' ),
+			'contact' => array( 'customer', 'additional_fields' ),
+			'address' => array( 'customer', 'billing_address' ),
+		);
+		$rule       = $this->get_referral_source_rule( $rule_paths[ $location ] );
+
+		\woocommerce_register_additional_checkout_field(
+			array(
+				'id'       => 'plugin-namespace/referral-source',
+				'label'    => 'How did you find us?',
+				'location' => $location,
+				'type'     => 'select',
+				'options'  => array(
+					array(
+						'value' => 'search',
+						'label' => 'Search engine',
+					),
+					array(
+						'value' => 'other',
+						'label' => 'Other',
+					),
+				),
+			)
+		);
+		\woocommerce_register_additional_checkout_field(
+			array(
+				'id'       => 'plugin-namespace/referral-detail',
+				'label'    => 'Please specify',
+				'location' => $location,
+				'type'     => 'text',
+				$rule_prop => 'hidden' === $rule_prop
+					? array( 'not' => $rule )
+					: $rule,
+			)
+		);
+	}
+
+	/**
+	 * Builds a checkout request with a valid address and the given additional field values.
+	 *
+	 * @param string $method The request method (POST|PUT|PATCH).
+	 * @param array  $additional_fields The additional field values to send.
+	 * @param array  $address_fields Additional address field values, merged into both addresses.
+	 * @return \WP_REST_Request The request.
+	 */
+	private function get_checkout_request_with_fields( $method, array $additional_fields, array $address_fields = array() ) {
+		$address = array_merge(
+			array(
+				'first_name' => 'test',
+				'last_name'  => 'test',
+				'address_1'  => 'test',
+				'city'       => 'test',
+				'state'      => '',
+				'postcode'   => 'cb241ab',
+				'country'    => 'GB',
+				'email'      => 'testaccount@test.com',
+			),
+			$address_fields
+		);
+		$request = new \WP_REST_Request( $method, '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params(
+			array(
+				'billing_address'   => (object) $address,
+				'shipping_address'  => (object) $address,
+				'payment_method'    => WC_Gateway_BACS::ID,
+				'additional_fields' => $additional_fields,
+			)
+		);
+		return $request;
+	}
+
+	/**
+	 * @testdox A conditionally hidden field stays hidden while the field it depends on has no value, so its posted value is cleared.
+	 */
+	public function test_conditional_hidden_field_cleared_when_dependency_has_no_value() {
+		$this->unregister_fields();
+		$this->register_conditional_referral_fields( 'hidden' );
+
+		$request  = $this->get_checkout_request_with_fields( 'POST', array( 'plugin-namespace/referral-detail' => 'posted while hidden' ) );
+		$response = rest_get_server()->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status(), print_r( $data, true ) );
+		$fields = (array) $data['additional_fields'];
+		$this->assertSame( '', $fields['plugin-namespace/referral-detail'], 'The detail field is hidden while referral-source has no value, so its posted value must be cleared' );
+	}
+
+	/**
+	 * @testdox A conditionally required field is not required while the field it depends on has no value, even when that field's key is absent from the request.
+	 */
+	public function test_conditional_required_field_ignores_missing_dependency_key() {
+		$this->unregister_fields();
+		$this->register_conditional_referral_fields( 'required' );
+
+		// Dependency never given a value: the detail field must not be required.
+		$response = rest_get_server()->dispatch( $this->get_checkout_request_with_fields( 'POST', array() ) );
+		$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+		$this->reset_session();
+
+		// Dependency set to the trigger value: the detail field is required.
+		$response = rest_get_server()->dispatch( $this->get_checkout_request_with_fields( 'POST', array( 'plugin-namespace/referral-source' => 'other' ) ) );
+		$this->assertEquals( 400, $response->get_status(), print_r( $response->get_data(), true ) );
+		$this->reset_session();
+
+		$response = rest_get_server()->dispatch(
+			$this->get_checkout_request_with_fields(
+				'POST',
+				array(
+					'plugin-namespace/referral-source' => 'other',
+					'plugin-namespace/referral-detail' => 'a friend',
+				)
+			)
+		);
+		$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+	}
+
+	/**
+	 * @testdox A conditional rule reads the persisted dependency value when the request omits its key, so omitting the trigger field does not bypass a required field.
+	 */
+	public function test_conditional_required_field_uses_persisted_dependency_value() {
+		$this->unregister_fields();
+		$this->register_conditional_referral_fields( 'required' );
+
+		$response = rest_get_server()->dispatch( $this->get_checkout_request_with_fields( 'PUT', array( 'plugin-namespace/referral-source' => 'other' ) ) );
+		$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+
+		// The POST omits referral-source, but its persisted value still makes the detail field required.
+		$response = rest_get_server()->dispatch( $this->get_checkout_request_with_fields( 'POST', array() ) );
+		$this->assertEquals( 400, $response->get_status(), print_r( $response->get_data(), true ) );
+
+		// The customer object survives reset_session, so clear the value this test persisted.
+		$this->controller->persist_field_for_customer( 'plugin-namespace/referral-source', '', wc()->customer, 'other' );
+		wc()->customer->save();
+	}
+
+	/**
+	 * @testdox A payment retry cannot clear a field required by a value saved on the order.
+	 * @testWith ["POST"]
+	 *           ["PUT"]
+	 *           ["PATCH"]
+	 * @param string $method The request method.
+	 */
+	public function test_conditional_required_field_uses_pending_order_value( string $method ): void {
+		$this->unregister_fields();
+		$this->register_conditional_referral_fields( 'required' );
+		update_option( 'woocommerce_manage_stock', 'yes' );
+		$this->products[0]->set_manage_stock( true );
+		$this->products[0]->set_stock_quantity( 10 );
+		$this->products[0]->save();
+
+		$order = wc_create_order();
+		$order->add_product( $this->products[0], 1 );
+		$order->set_status( OrderStatus::PENDING );
+		$order->set_total( 30 );
+		$order->set_cart_hash( wc()->cart->get_cart_hash() );
+		$this->controller->persist_field_for_order( 'plugin-namespace/referral-source', 'other', $order, 'other', false );
+		$this->controller->persist_field_for_order( 'plugin-namespace/referral-detail', 'a friend', $order, 'other', false );
+		$order->save();
+		wc_reserve_stock_for_order( $order );
+		$this->assertSame( 1, wc_get_held_stock_quantity( $this->products[0] ), 'The pending order must hold stock before the retry.' );
+		wc()->session->set( 'store_api_draft_order', $order->get_id() );
+
+		$request  = $this->get_checkout_request_with_fields( $method, array( 'plugin-namespace/referral-detail' => '' ) );
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status(), print_r( $response->get_data(), true ) );
+		$this->assertSame( 'a friend', $this->controller->get_field_from_object( 'plugin-namespace/referral-detail', wc_get_order( $order->get_id() ) ), 'Rejected requests must leave the saved field unchanged.' );
+		$this->assertSame( 1, wc_get_held_stock_quantity( $this->products[0] ), 'Field validation errors must leave the existing stock hold unchanged.' );
+	}
+
+	/**
+	 * @testdox A partial update matches saved text without changing its punctuation.
+	 * @testWith ["order", "checkout"]
+	 *           ["contact", "customer"]
+	 * @param string $location The field location.
+	 * @param string $context The document object property holding the fields.
+	 */
+	public function test_conditional_hidden_field_uses_raw_saved_text( string $location, string $context ): void {
+		$this->unregister_fields();
+		\woocommerce_register_additional_checkout_field(
+			array(
+				'id'       => 'plugin-namespace/referral-source',
+				'label'    => 'Source',
+				'location' => $location,
+				'type'     => 'text',
+			)
+		);
+		\woocommerce_register_additional_checkout_field(
+			array(
+				'id'       => 'plugin-namespace/referral-detail',
+				'label'    => 'Please specify',
+				'location' => $location,
+				'type'     => 'text',
+				'hidden'   => array( 'not' => $this->get_referral_source_rule( array( $context, 'additional_fields' ), "John's" ) ),
+			)
+		);
+
+		$request  = $this->get_checkout_request_with_fields(
+			'PUT',
+			array(
+				'plugin-namespace/referral-source' => "John's",
+				'plugin-namespace/referral-detail' => 'first value',
+			)
+		);
+		$response = rest_get_server()->dispatch( $request );
+		$this->assertSame( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+		$this->assertSame( 'first value', ( (array) $response->get_data()['additional_fields'] )['plugin-namespace/referral-detail'] );
+
+		$request  = $this->get_checkout_request_with_fields( 'PUT', array( 'plugin-namespace/referral-detail' => 'second value' ) );
+		$response = rest_get_server()->dispatch( $request );
+		$this->assertSame( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+		$this->assertSame( 'second value', ( (array) $response->get_data()['additional_fields'] )['plugin-namespace/referral-detail'], 'The saved dependency must match the same rule as its posted value.' );
+	}
+
+	/**
+	 * @testdox A conditionally required contact field follows the same rules as order fields when its dependency has no value.
+	 */
+	public function test_conditional_required_contact_field_ignores_missing_dependency_key() {
+		$this->unregister_fields();
+		$this->register_conditional_referral_fields( 'required', 'contact' );
+
+		$response = rest_get_server()->dispatch( $this->get_checkout_request_with_fields( 'POST', array() ) );
+		$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+		$this->reset_session();
+
+		$response = rest_get_server()->dispatch( $this->get_checkout_request_with_fields( 'POST', array( 'plugin-namespace/referral-source' => 'other' ) ) );
+		$this->assertEquals( 400, $response->get_status(), print_r( $response->get_data(), true ) );
+		$this->reset_session();
+
+		$response = rest_get_server()->dispatch(
+			$this->get_checkout_request_with_fields(
+				'POST',
+				array(
+					'plugin-namespace/referral-source' => 'other',
+					'plugin-namespace/referral-detail' => 'a friend',
+				)
+			)
+		);
+		$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+	}
+
+	/**
+	 * @testdox A conditionally required address field follows the same rules as order fields when its dependency has no value.
+	 */
+	public function test_conditional_required_address_field_ignores_missing_dependency_key() {
+		$this->unregister_fields();
+		$this->register_conditional_referral_fields( 'required', 'address' );
+
+		$response = rest_get_server()->dispatch( $this->get_checkout_request_with_fields( 'POST', array() ) );
+		$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+		$this->reset_session();
+
+		$response = rest_get_server()->dispatch(
+			$this->get_checkout_request_with_fields( 'POST', array(), array( 'plugin-namespace/referral-source' => 'other' ) )
+		);
+		$this->assertEquals( 400, $response->get_status(), print_r( $response->get_data(), true ) );
+		$this->reset_session();
+
+		$response = rest_get_server()->dispatch(
+			$this->get_checkout_request_with_fields(
+				'POST',
+				array(),
+				array(
+					'plugin-namespace/referral-source' => 'other',
+					'plugin-namespace/referral-detail' => 'a friend',
+				)
+			)
+		);
+		$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+	}
 }