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.' );
}
}