Commit f2e3d3338a6 for woocommerce

commit f2e3d3338a6380eaa61c6d3f49fd98ab10ddefa3
Author: Mike Jolley <mike.jolley@me.com>
Date:   Mon Oct 12 14:41:01 2026 +0100

    Send shoppers to the product page when a checkout link variation needs options (#69614)

    * Send shoppers to the product page when a checkout link variation needs options

    Checkout links failed for variations with an "Any" attribute, landing the
    shopper on an empty cart with a missing variation data error. They now
    redirect to the product page with known attributes preselected and a notice
    to choose options. Other products in the link are still added to the cart.

    Closes #61446.

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * Only redirect checkout links to the product page for published variations

    A private variation with a published parent was sent to the parent's
    product page, where the variation isn't offered. Require the variation
    to be published too. Also tighten the unpublished-product test, which
    passed for any URL, and name the system under test `$sut`.

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * Use ProductStatus constants in the unpublished checkout link test

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/61446-checkout-link-any-attribute-variations b/plugins/woocommerce/changelog/61446-checkout-link-any-attribute-variations
new file mode 100644
index 00000000000..cf29b728e5e
--- /dev/null
+++ b/plugins/woocommerce/changelog/61446-checkout-link-any-attribute-variations
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Send shoppers to the product page to choose options when a checkout link includes a variation with an "Any" attribute.
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutLink.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutLink.php
index cea96b2ff8f..7042f1f1123 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutLink.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutLink.php
@@ -8,6 +8,8 @@ declare(strict_types=1);
 namespace Automattic\WooCommerce\Blocks\Domain\Services;

 use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
+use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
 use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils;
 use Automattic\WooCommerce\StoreApi\Utilities\CartController;

@@ -225,30 +227,64 @@ class CheckoutLink {
 	 * @return string The checkout link.
 	 */
 	protected function get_checkout_link() {
-		$controller = new CartController();
-		$products   = $this->get_products_from_checkout_link();
-		$errors     = new \WP_Error();
+		$controller    = new CartController();
+		$products      = $this->get_products_from_checkout_link();
+		$errors        = new \WP_Error();
+		$needs_options = [];
+		$options_url   = '';

 		foreach ( $products as $product_data ) {
 			try {
 				$controller->add_to_cart( $product_data );
 			} catch ( \Exception $e ) {
-				$errors->add( 'error', $e->getMessage() );
+				// Variations with an "Any" attribute need the shopper to choose a value, so send them to the product page.
+				$product             = $this->is_missing_variation_data_error( $e ) ? wc_get_product( $product_data['id'] ) : null;
+				$product_options_url = $product ? $this->get_product_options_url( $product, $product_data['variation'] ) : '';
+
+				if ( ! $product || ! $product_options_url ) {
+					$errors->add( 'error', $e->getMessage() );
+					continue;
+				}
+
+				/* translators: %s: product name */
+				$needs_options[] = sprintf( _x( '&ldquo;%s&rdquo;', 'Item name in quotes', 'woocommerce' ), esc_html( $product->get_name() ) );
+
+				if ( '' === $options_url ) {
+					$options_url = $product_options_url;
+				}
 			}
 		}

-		// Nothing was added to the cart. We need to redirect to the cart page with an error notice. Since guests may not
-		// have a session, add the notice in the query string.
+		if ( $needs_options ) {
+			$errors->add(
+				'checkout_link_needs_options',
+				sprintf(
+					/* translators: %s: comma-separated list of product names */
+					_n( 'Choose options for %s to add it to your cart.', 'Choose options for %s to add them to your cart.', count( $needs_options ), 'woocommerce' ),
+					wc_format_list_of_items( $needs_options )
+				)
+			);
+		}
+
+		// Nothing was added to the cart. Redirect to the product page if options are needed, otherwise the cart page, with
+		// an error notice. Since guests may not have a session, add the notice in the query string.
 		if ( wc()->cart->is_empty() ) {
-			$errors->add( 'error', __( 'The provided checkout link was out of date or invalid. No products were added to the cart.', 'woocommerce' ) );
+			if ( $options_url ) {
+				$redirect_url = $options_url;
+				$notice_code  = 'checkout_link_needs_options';
+			} else {
+				$redirect_url = wc_get_cart_url();
+				$notice_code  = 'error';
+				$errors->add( 'error', __( 'The provided checkout link was out of date or invalid. No products were added to the cart.', 'woocommerce' ) );
+			}

 			if ( ! wc()->session->has_session() ) {
-				return add_query_arg( 'wc_error', rawurlencode( $errors->get_error_message() ), wc_get_cart_url() );
-			} else {
-				$this->add_error_notices( $errors );
+				return add_query_arg( 'wc_error', rawurlencode( $errors->get_error_message( $notice_code ) ), $redirect_url );
 			}

-			return wc_get_cart_url();
+			$this->add_error_notices( $errors );
+
+			return $redirect_url;
 		}

 		// Apply coupon if provided.
@@ -265,7 +301,7 @@ class CheckoutLink {
 		// Add error notices to the cart. This requires a session otherwise the notices will not be displayed.
 		$this->add_error_notices( $errors );

-		$redirect_url = wc_get_checkout_url();
+		$redirect_url = $options_url ? $options_url : wc_get_checkout_url();

 		// Preserve the query string--pass it to the checkout page.
 		if ( ! empty( $_SERVER['QUERY_STRING'] ) ) {
@@ -287,4 +323,58 @@ class CheckoutLink {

 		return $redirect_url;
 	}
+
+	/**
+	 * Check if an add to cart error was caused by missing variation attributes.
+	 *
+	 * @param \Exception $exception The add to cart exception.
+	 * @return bool
+	 */
+	private function is_missing_variation_data_error( \Exception $exception ) {
+		return $exception instanceof RouteException
+			&& in_array( $exception->getErrorCode(), [ 'woocommerce_rest_missing_variation_data', 'woocommerce_rest_missing_attributes' ], true );
+	}
+
+	/**
+	 * Get the product page URL where the shopper can choose the attributes missing from a checkout link.
+	 *
+	 * Attributes set by the variation, or passed in the link, are added to the URL so they are preselected.
+	 *
+	 * @param \WC_Product $product   The variable product or variation from the checkout link.
+	 * @param array[]     $variation Variation attributes parsed from the checkout link.
+	 * @return string The product page URL, or an empty string if the product page is not available.
+	 */
+	private function get_product_options_url( \WC_Product $product, array $variation ) {
+		$is_variation   = $product instanceof \WC_Product_Variation;
+		$parent_product = $is_variation ? wc_get_product( $product->get_parent_id() ) : $product;
+
+		if ( ! $parent_product || ProductStatus::PUBLISH !== $parent_product->get_status() || ProductStatus::PUBLISH !== $product->get_status() ) {
+			return '';
+		}
+
+		$selected  = $is_variation ? array_filter( $product->get_variation_attributes(), 'wc_array_filter_default_attributes' ) : [];
+		$requested = wp_list_pluck( $variation, 'value', 'attribute' );
+
+		foreach ( $parent_product->get_attributes() as $attribute ) {
+			if ( ! $attribute->get_variation() ) {
+				continue;
+			}
+
+			$attribute_name = $attribute->get_name();
+			$query_key      = wc_variation_attribute_name( $attribute_name );
+
+			foreach ( [ $query_key, $attribute_name, strtolower( wc_attribute_label( $attribute_name, $parent_product ) ) ] as $requested_key ) {
+				if ( isset( $requested[ $requested_key ] ) && '' !== $requested[ $requested_key ] ) {
+					$selected[ $query_key ] = $attribute->is_taxonomy() ? sanitize_title( $requested[ $requested_key ] ) : $requested[ $requested_key ];
+					break;
+				}
+			}
+		}
+
+		// Encode keys and values so add_query_arg() keeps them intact, matching WC_Product_Variation::get_permalink().
+		return add_query_arg(
+			array_combine( array_map( 'urlencode', array_keys( $selected ) ), array_map( 'urlencode', $selected ) ),
+			$parent_product->get_permalink()
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutLinkTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutLinkTest.php
index bbc3c4261ec..8d0007468c2 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutLinkTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutLinkTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Blocks\Domain\Services;

 use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutLink;
+use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\RestApi\UnitTests\Helpers\CouponHelper;

 /**
@@ -79,18 +80,9 @@ class CheckoutLinkTest extends \WC_Unit_Test_Case {
 		$_GET['coupon']   = 'test-coupon';
 		CouponHelper::create_coupon( 'test-coupon' );

-		$service = new class() extends CheckoutLink {
-			/**
-			 * Get the checkout link for testing.
-			 *
-			 * @return string The checkout link.
-			 */
-			public function get_checkout_link_test() {
-				return parent::get_checkout_link();
-			}
-		};
+		$sut = $this->get_checkout_link_service();

-		$url                  = $service->get_checkout_link_test();
+		$url                  = $sut->get_checkout_link_test();
 		$cart_by_product      = [];
 		$expected_product_ids = [
 			$legacy_product->get_id(),
@@ -131,7 +123,7 @@ class CheckoutLinkTest extends \WC_Unit_Test_Case {
 	public function test_escaped_product_data_delimiters_are_preserved(): void {
 		$_GET['products'] = '123:1:ratio=10~:20~,wide~;special:note=a~,b~;c:d';

-		$service = new class() extends CheckoutLink {
+		$sut = new class() extends CheckoutLink {
 			/**
 			 * Get parsed checkout-link products for testing.
 			 *
@@ -156,7 +148,7 @@ class CheckoutLinkTest extends \WC_Unit_Test_Case {
 					'cart_item_data' => [ 'note' => 'a,b;c:d' ],
 				],
 			],
-			$service->get_products_test(),
+			$sut->get_products_test(),
 			'Escaped delimiters should be treated as data rather than checkout-link separators.'
 		);
 	}
@@ -171,7 +163,107 @@ class CheckoutLinkTest extends \WC_Unit_Test_Case {

 		$_GET['products'] = '999999';

-		$service = new class() extends CheckoutLink {
+		$sut = $this->get_checkout_link_service();
+
+		$sut->get_checkout_link_test();
+
+		$this->assertTrue( WC()->cart->is_empty(), 'An unresolved numeric ID must not add a product with the same numeric SKU.' );
+	}
+
+	/**
+	 * @testdox Redirects to the product page with known attributes preselected when a variation has an "Any" attribute.
+	 */
+	public function test_variation_with_any_attribute_redirects_to_product_page(): void {
+		$variable_product = \WC_Helper_Product::create_variation_product();
+		$any_variation    = wc_get_product( wc_get_product_id_by_sku( 'DUMMY SKU VARIABLE SMALL' ) );
+
+		$_GET['products'] = $any_variation->get_id() . ':2:pa_colour=red';
+
+		$url = $this->get_checkout_link_service()->get_checkout_link_test();
+
+		$this->assertTrue( WC()->cart->is_empty(), 'A variation with a missing "Any" attribute should not be added to the cart.' );
+		$this->assertStringStartsWith( $variable_product->get_permalink(), $url, 'The shopper should be sent to the product page.' );
+
+		wp_parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query );
+
+		$this->assertSame( 'small', $query['attribute_pa_size'] ?? null, 'Attributes set by the variation should be preselected.' );
+		$this->assertSame( 'red', $query['attribute_pa_colour'] ?? null, 'Attributes passed in the link should be preselected.' );
+		$this->assertArrayNotHasKey( 'attribute_pa_number', $query, 'The missing attribute should be left for the shopper to choose.' );
+
+		$notices = wc_get_notices( 'error' );
+		$notice  = $query['wc_error'] ?? ( $notices[0]['notice'] ?? '' );
+
+		$this->assertStringContainsString( 'Choose options for', $notice, 'The shopper should be told to choose the missing options.' );
+	}
+
+	/**
+	 * @testdox Adds resolvable products and redirects to the first product that needs options.
+	 */
+	public function test_resolvable_products_are_added_when_another_needs_options(): void {
+		$simple_product   = \WC_Helper_Product::create_simple_product();
+		$variable_product = \WC_Helper_Product::create_variation_product();
+		$any_variation    = wc_get_product( wc_get_product_id_by_sku( 'DUMMY SKU VARIABLE HUGE BLUE ANY NUMBER' ) );
+		$second_variation = wc_get_product( wc_get_product_id_by_sku( 'DUMMY SKU VARIABLE LARGE' ) );
+
+		$_GET['products'] = implode( ',', [ (string) $simple_product->get_id(), (string) $any_variation->get_id(), (string) $second_variation->get_id() ] );
+
+		$url = $this->get_checkout_link_service()->get_checkout_link_test();
+
+		$this->assertSame( [ $simple_product->get_id() ], array_values( wp_list_pluck( WC()->cart->get_cart(), 'product_id' ) ), 'Only the resolvable product should be added to the cart.' );
+		$this->assertStringStartsWith( $variable_product->get_permalink(), $url, 'The shopper should be sent to the first product that needs options.' );
+		$this->assertStringContainsString( 'attribute_pa_colour=blue', $url, 'Attributes of the first product that needs options should be preselected.' );
+		$this->assertStringContainsString( 'session=', $url, 'Guests should keep the cart through the session token.' );
+
+		$notices = wc_get_notices( 'error' );
+
+		$this->assertCount( 1, $notices, 'One notice should list every product that needs options.' );
+		$this->assertStringContainsString( 'to add them to your cart', $notices[0]['notice'], 'The notice should cover both products.' );
+	}
+
+	/**
+	 * Unpublished product cases for checkout links that need options.
+	 *
+	 * @return array<string, array{string, string}>
+	 */
+	public function unpublished_product_provider(): array {
+		return [
+			'draft parent'      => [ 'parent', ProductStatus::DRAFT ],
+			'private variation' => [ 'variation', ProductStatus::PRIVATE ],
+		];
+	}
+
+	/**
+	 * @testdox Keeps the cart page error when the $unpublished product that needs options is not published.
+	 * @dataProvider unpublished_product_provider
+	 *
+	 * @param string $unpublished Which product to unpublish: the parent or the variation.
+	 * @param string $status      The unpublished status to set.
+	 */
+	public function test_unpublished_product_that_needs_options_redirects_to_cart( string $unpublished, string $status ): void {
+		$variable_product = \WC_Helper_Product::create_variation_product();
+		$any_variation    = wc_get_product( wc_get_product_id_by_sku( 'DUMMY SKU VARIABLE SMALL' ) );
+		$product          = 'parent' === $unpublished ? $variable_product : $any_variation;
+
+		$product->set_status( $status );
+		$product->save();
+
+		$_GET['products'] = (string) $any_variation->get_id();
+
+		$url = $this->get_checkout_link_service()->get_checkout_link_test();
+
+		wp_parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query );
+
+		$this->assertStringNotContainsString( 'attribute_pa_size', $url, 'An unpublished product page should not be used as the redirect.' );
+		$this->assertStringContainsString( 'Missing variation data', $query['wc_error'] ?? '', 'The add to cart error should still be shown.' );
+	}
+
+	/**
+	 * Get a checkout link service that exposes the checkout link for testing.
+	 *
+	 * @return CheckoutLink
+	 */
+	private function get_checkout_link_service() {
+		return new class() extends CheckoutLink {
 			/**
 			 * Get the checkout link for testing.
 			 *
@@ -181,9 +273,5 @@ class CheckoutLinkTest extends \WC_Unit_Test_Case {
 				return parent::get_checkout_link();
 			}
 		};
-
-		$service->get_checkout_link_test();
-
-		$this->assertTrue( WC()->cart->is_empty(), 'An unresolved numeric ID must not add a product with the same numeric SKU.' );
 	}
 }