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( '“%s”', '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.' );
}
}