Commit 67bca62ee98 for woocommerce

commit 67bca62ee98a8bd0081603a90bcb1efef44a8078
Author: Kathy <507025+helgatheviking@users.noreply.github.com>
Date:   Thu Oct 1 16:15:33 2026 -0400

    First pass at passing product add to cart params via checkout-link (#59341)

    * First pass at passing product add to cart params via checkout-link

    * get product ID from SKU

    * Restores original schema `id:qty:data` with optional `data`.

    * Improve readability. Kay/value pairs are separated by `&`

    * revert back to ; delimiter for custom product data

    * use sanitize_key() on array key

    * Remove $product_id as array key for get_products_from_checkout_link()

    * attempt some code standards cleanup

    * First attempt to add unit testing

    * Revise parsing a checkout link to get a product by SKU

    * Test for a specific variation

    * Check for attribute names, then slugs, then a stripped down key derived from the name.

    * Restore shared Store API cart behavior

    * Separate checkout link product data

    * Add checkout link parameter coverage

    * Handle escaped checkout-link data and numeric SKUs

    * Remove resolved checkout link PHPStan baseline

    * Document checkout link product data format

    ---------

    Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>

diff --git a/docs/best-practices/urls-and-routing/checkout-urls.md b/docs/best-practices/urls-and-routing/checkout-urls.md
index 39c3d7736fa..4071995d6af 100644
--- a/docs/best-practices/urls-and-routing/checkout-urls.md
+++ b/docs/best-practices/urls-and-routing/checkout-urls.md
@@ -5,7 +5,7 @@ sidebar_label: Checkout URLs

 # Shareable Checkout URLs

-Custom checkout links automatically populate the cart with specific products and redirect customers straight to checkout with a unique session id.
+Custom checkout links automatically populate the cart with specific products and redirect customers straight to checkout with a unique session ID.

 The custom checkout link path is `/checkout-link/` and is not customizable.

@@ -13,11 +13,43 @@ The custom checkout link path is `/checkout-link/` and is not customizable.

 ### Products

+The `products` parameter accepts a comma-separated list of products. Each product uses the following positional format:
+
+```plaintext
+id-or-sku:quantity:variation-data:cart-item-data
+```
+
+The quantity, variation data, and cart item data are optional. The default quantity is `1`. Keep an empty field between colons when omitting a field that comes before one you want to provide.
+
+The product identifier can be:
+
+- A product or variation ID, such as `123`.
+- A nonnumeric SKU, such as `BLUE-SHIRT`.
+- A numeric SKU prefixed with `sku=`, such as `sku=999999`. Without the prefix, a numeric identifier is treated as a product ID.
+
+Variation and cart item data use `key=value` pairs. Separate multiple pairs in the same field with semicolons. Variation data selects a variation, while cart item data passes additional values consumed by extensions or custom product types. The accepted cart item data depends on the extension handling the product.
+
+For example:
+
+```plaintext
+products=123
+products=123:2
+products=BLUE-SHIRT:2
+products=sku=999999:2
+products=123:1:color=black;size=medium
+products=123:1::nyp=120
+products=123:2:color=black;size=medium:nyp=120
+```
+
+The last three examples add variation data, cart item data, or both. The empty variation field in `123:1::nyp=120` is required so that `nyp=120` is treated as cart item data.
+
+Commas separate products, colons separate product fields, and semicolons separate data pairs. Prefix one of these delimiters with `~` when it is part of an SKU or data value rather than a separator. For example, the SKU `ABC,123:BLUE` is represented as follows:
+
 ```plaintext
-products=123:2,456:1
+products=ABC~,123~:BLUE:1
 ```

-A comma-separated list of product IDs and quantities. For example, `123:2,456:1`. This feature supports simple products with no additional options. Individual variations can also be added to cart by using the correct variation ID.
+Existing links containing only product IDs and quantities continue to work.

 ### Coupon

@@ -35,10 +67,10 @@ https://yourstore.com/checkout-link/?products=123:2,456:1&coupon=SPRING10

 In this link:

-- Product ID `123` will be added with quantity `2`
-- Product ID `456` with quantity `1`
-- The coupon code `SPRING10` will be applied
-- The customer is taken directly to the checkout page
+- Product ID `123` will be added with quantity `2`.
+- Product ID `456` will be added with quantity `1`.
+- The coupon code `SPRING10` will be applied.
+- The customer will be taken directly to the checkout page.

 ## Sessions

diff --git a/plugins/woocommerce/changelog/issues-59332-checkout-link b/plugins/woocommerce/changelog/issues-59332-checkout-link
new file mode 100644
index 00000000000..b3b91b0f7ce
--- /dev/null
+++ b/plugins/woocommerce/changelog/issues-59332-checkout-link
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Support adding additional parameters to a product in checkout-link
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 48dcfe4212a..16080aab306 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -52521,12 +52521,6 @@ parameters:
 			count: 1
 			path: src/Blocks/Domain/Services/CheckoutLink.php

-		-
-			message: '#^Parameter \#2 \$str of function explode expects string, array\|string given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Blocks/Domain/Services/CheckoutLink.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CreateAccount\:\:customer_new_account\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutLink.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutLink.php
index f4f16e511e6..cea96b2ff8f 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutLink.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutLink.php
@@ -88,32 +88,125 @@ class CheckoutLink {
 	/**
 	 * Get the products from the checkout link.
 	 *
-	 * @return array The products (keys) and their quantities (values).
+	 * Products use the format `id-or-sku:quantity:variation-data:cart-item-data`. Quantity and both data groups are optional,
+	 * while an empty variation group separates cart item data. Multiple key-value pairs within a data group use semicolons.
+	 * Prefix numeric SKUs with `sku=`. Delimiters within identifiers, keys, or values must be escaped with a tilde.
+	 *
+	 * @return array[] Array of product data formatted for CartController::add_to_cart().
 	 */
 	protected function get_products_from_checkout_link() {
-		$raw_products = array_filter( explode( ',', wc_clean( wp_unslash( $_GET['products'] ?? '' ) ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
-		$products     = [];
+		$products_query = wp_unslash( $_GET['products'] ?? '' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+
+		if ( ! is_string( $products_query ) ) {
+			return [];
+		}
+
+		$products_query = sanitize_text_field( $products_query );
+		$raw_products   = array_filter( $this->split_escaped( $products_query, ',' ) );
+		$products       = [];
+
+		foreach ( $raw_products as $raw_product ) {
+			$segments           = $this->split_escaped( $raw_product, ':', 4 );
+			$product_identifier = $segments[0] ?? '';
+			$quantity           = '' !== ( $segments[1] ?? '' ) ? absint( $segments[1] ) : 1;
+			$variation_data     = $this->parse_product_data( $segments[2] ?? '' );
+			$cart_item_data     = $this->parse_product_data( $segments[3] ?? '' );

-		foreach ( $raw_products as $product_id_qty ) {
-			if ( strpos( $product_id_qty, ':' ) !== false ) {
-				list( $product_id, $qty ) = explode( ':', $product_id_qty );
+			if ( ! $product_identifier || ! $quantity ) {
+				continue;
+			}
+
+			if ( 0 === strpos( $product_identifier, 'sku=' ) ) {
+				$product_id = wc_get_product_id_by_sku( substr( $product_identifier, 4 ) );
+			} elseif ( is_numeric( $product_identifier ) ) {
+				$product_id = absint( $product_identifier );
 			} else {
-				$product_id = $product_id_qty;
-				$qty        = 1;
+				$product_id = wc_get_product_id_by_sku( $product_identifier );
 			}
-			$product_id = absint( $product_id );
-			$qty        = absint( $qty );

-			if ( ! $product_id || ! $qty ) {
+			if ( ! $product_id ) {
 				continue;
 			}

-			$products[ $product_id ] = $qty;
+			$variation = array_map(
+				function ( $key, $value ) {
+					return [
+						'attribute' => $key,
+						'value'     => $value,
+					];
+				},
+				array_keys( $variation_data ),
+				$variation_data
+			);
+
+			$products[] = [
+				'id'             => $product_id,
+				'quantity'       => $quantity,
+				'variation'      => $variation,
+				'cart_item_data' => $cart_item_data,
+			];
 		}

 		return $products;
 	}

+	/**
+	 * Parse a semicolon-separated list of key-value pairs.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $raw_data Raw product data from the checkout link.
+	 * @return array<string, string> Sanitized product data.
+	 */
+	protected function parse_product_data( $raw_data ) {
+		$data = [];
+
+		foreach ( $this->split_escaped( $raw_data, ';' ) as $pair ) {
+			if ( false === strpos( $pair, '=' ) ) {
+				continue;
+			}
+
+			list( $key, $value ) = explode( '=', $pair, 2 );
+			$key                 = sanitize_key( $key );
+
+			if ( '' !== $key ) {
+				$data[ $key ] = sanitize_text_field( $value );
+			}
+		}
+
+		return $data;
+	}
+
+	/**
+	 * Split a string on unescaped delimiters.
+	 *
+	 * @param string $value     Value to split.
+	 * @param string $delimiter Single-character delimiter.
+	 * @param int    $limit     Maximum number of returned segments.
+	 * @return string[] Split values with delimiter escapes removed.
+	 */
+	private function split_escaped( $value, $delimiter, $limit = PHP_INT_MAX ) {
+		$segments = [ '' ];
+		$index    = 0;
+		$length   = strlen( $value );
+
+		for ( $position = 0; $position < $length; ++$position ) {
+			$character = $value[ $position ];
+
+			if ( '~' === $character && $position + 1 < $length && $delimiter === $value[ $position + 1 ] ) {
+				$segments[ $index ] .= $delimiter;
+				++$position;
+			} elseif ( $delimiter === $character && count( $segments ) < $limit ) {
+				$segments[] = '';
+				++$index;
+			} else {
+				$segments[ $index ] .= $character;
+			}
+		}
+
+		return $segments;
+	}
+
 	/**
 	 * Add error notices to the cart.
 	 *
@@ -136,14 +229,9 @@ class CheckoutLink {
 		$products   = $this->get_products_from_checkout_link();
 		$errors     = new \WP_Error();

-		foreach ( $products as $product_id => $qty ) {
+		foreach ( $products as $product_data ) {
 			try {
-				$controller->add_to_cart(
-					[
-						'id'       => $product_id,
-						'quantity' => $qty,
-					]
-				);
+				$controller->add_to_cart( $product_data );
 			} catch ( \Exception $e ) {
 				$errors->add( 'error', $e->getMessage() );
 			}
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 8773329b10a..bbc3c4261ec 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutLinkTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutLinkTest.php
@@ -50,26 +50,34 @@ class CheckoutLinkTest extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * Test that products and coupon are added and token in url.
+	 * @testdox Adds legacy products, quantities, SKUs, variations, additional data, and a coupon from a checkout link.
 	 */
-	public function test_products_and_coupon_are_added_and_token_in_url() {
-		$test_products = [
-			\WC_Helper_Product::create_simple_product(),
-			\WC_Helper_Product::create_simple_product(),
-			\WC_Helper_Product::create_simple_product(),
-		];
-
-		$product_ids = array_map(
-			function ( $product ) {
-				return $product->get_id();
-			},
-			$test_products
+	public function test_products_and_coupon_are_added_and_token_in_url(): void {
+		$legacy_product      = \WC_Helper_Product::create_simple_product();
+		$quantity_product    = \WC_Helper_Product::create_simple_product();
+		$sku_product         = \WC_Helper_Product::create_simple_product();
+		$numeric_sku_product = \WC_Helper_Product::create_simple_product();
+		$variable_product    = \WC_Helper_Product::create_variation_product();
+		$available_options   = $variable_product->get_available_variations();
+		$chosen_variation    = array_shift( $available_options );
+
+		$sku_product->set_sku( 'CHECKOUT,LINK:SKU' );
+		$sku_product->save();
+		$numeric_sku_product->set_sku( '999999' );
+		$numeric_sku_product->save();
+
+		$_GET['products'] = implode(
+			',',
+			[
+				(string) $legacy_product->get_id(),
+				$quantity_product->get_id() . ':2',
+				'CHECKOUT~,LINK~:SKU',
+				'sku=999999',
+				$variable_product->get_id() . ':1:' . http_build_query( $chosen_variation['attributes'], '', ';' ) . ':nyp=99;note=alpha~,beta~;gamma:delta',
+			]
 		);
-
-		$coupon = CouponHelper::create_coupon( 'test-coupon' );
-
-		$_GET['products'] = implode( ',', $product_ids );
 		$_GET['coupon']   = 'test-coupon';
+		CouponHelper::create_coupon( 'test-coupon' );

 		$service = new class() extends CheckoutLink {
 			/**
@@ -82,31 +90,100 @@ class CheckoutLinkTest extends \WC_Unit_Test_Case {
 			}
 		};

-		$url = $service->get_checkout_link_test();
+		$url                  = $service->get_checkout_link_test();
+		$cart_by_product      = [];
+		$expected_product_ids = [
+			$legacy_product->get_id(),
+			$quantity_product->get_id(),
+			$sku_product->get_id(),
+			$numeric_sku_product->get_id(),
+			$variable_product->get_id(),
+		];

-		$cart_contents    = WC()->cart->get_cart();
-		$cart_product_ids = array_map(
-			function ( $item ) {
-				return $item['product_id'];
-			},
-			$cart_contents
+		foreach ( WC()->cart->get_cart() as $cart_item ) {
+			$cart_by_product[ $cart_item['product_id'] ] = $cart_item;
+		}
+
+		$this->assertSame( $expected_product_ids, array_keys( $cart_by_product ), 'All checkout-link product identifier formats should resolve.' );
+		$this->assertSame( 1, $cart_by_product[ $legacy_product->get_id() ]['quantity'], 'A legacy product ID should default to quantity one.' );
+		$this->assertSame( 2, $cart_by_product[ $quantity_product->get_id() ]['quantity'], 'A legacy product ID and quantity should remain supported.' );
+		$this->assertSame( 1, $cart_by_product[ $sku_product->get_id() ]['quantity'], 'An escaped SKU should resolve to its product.' );
+		$this->assertSame( 1, $cart_by_product[ $numeric_sku_product->get_id() ]['quantity'], 'An explicitly prefixed numeric SKU should resolve to its product.' );
+		$this->assertSame(
+			[
+				'attribute_pa_colour' => '',
+				'attribute_pa_number' => '',
+				'attribute_pa_size'   => 'small',
+			],
+			$cart_by_product[ $variable_product->get_id() ]['variation'],
+			'Variation data should select the requested variation.'
 		);
+		$this->assertSame( '99', $cart_by_product[ $variable_product->get_id() ]['nyp'], 'Additional product data should be retained on the cart item.' );
+		$this->assertSame( 'alpha,beta;gamma:delta', $cart_by_product[ $variable_product->get_id() ]['note'], 'Escaped delimiters should be retained in additional product data.' );
+		$this->assertArrayNotHasKey( 'nyp', $cart_by_product[ $variable_product->get_id() ]['variation'], 'Additional product data should not be treated as variation data.' );
+		$this->assertSame( [ 'test-coupon' ], WC()->cart->get_applied_coupons(), 'The checkout-link coupon should be applied.' );
+		$this->assertStringContainsString( 'session=', $url, 'Guest checkout links should include a cart session token.' );
+	}
+
+	/**
+	 * @testdox Preserves escaped delimiters in variation and cart item data.
+	 */
+	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 {
+			/**
+			 * Get parsed checkout-link products for testing.
+			 *
+			 * @return array[] Parsed product data.
+			 */
+			public function get_products_test() {
+				return parent::get_products_from_checkout_link();
+			}
+		};

-		$applied_coupons      = WC()->cart->get_coupons();
-		$applied_coupon_codes = array_map(
-			function ( $coupon ) {
-				return $coupon->get_code();
-			},
-			$applied_coupons
+		$this->assertSame(
+			[
+				[
+					'id'             => 123,
+					'quantity'       => 1,
+					'variation'      => [
+						[
+							'attribute' => 'ratio',
+							'value'     => '10:20,wide;special',
+						],
+					],
+					'cart_item_data' => [ 'note' => 'a,b;c:d' ],
+				],
+			],
+			$service->get_products_test(),
+			'Escaped delimiters should be treated as data rather than checkout-link separators.'
 		);
+	}

-		$this->assertEquals( array_values( $product_ids ), array_values( $cart_product_ids ) );
-		$this->assertEquals( array_values( [ 'test-coupon' ] ), array_values( $applied_coupon_codes ) );
-		$this->assertStringContainsString( 'session=', $url );
+	/**
+	 * @testdox Does not treat an unresolved numeric product ID as a numeric SKU.
+	 */
+	public function test_unresolved_numeric_product_id_does_not_resolve_as_sku(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$product->set_sku( '999999' );
+		$product->save();

-		// Clean up.
-		foreach ( $test_products as $product ) {
-			wp_delete_post( $product->get_id(), true );
-		}
+		$_GET['products'] = '999999';
+
+		$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();
+			}
+		};
+
+		$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.' );
 	}
 }