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 ) );
+ }
}