Commit afa99df8ea for woocommerce
commit afa99df8ea24db5448b126fd04440a1d37d8b820
Author: Neil Carlo Sucuangco <succute@yahoo.com>
Date: Wed Nov 26 22:38:31 2025 +0800
Block duplicate variations in Order Again (#61344)
Co-authored-by: Łukasz Strączyński <lukasz.straczynski@automattic.com>
diff --git a/plugins/woocommerce/changelog/fix-order-again-sold-individually-variations b/plugins/woocommerce/changelog/fix-order-again-sold-individually-variations
new file mode 100644
index 0000000000..43ff87831c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-order-again-sold-individually-variations
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Respect “Sold individually” for variable products in Order Again; normalize variation attributes, enforce qty=1, prevent duplicate variations; add unit test.
diff --git a/plugins/woocommerce/includes/class-wc-cart-session.php b/plugins/woocommerce/includes/class-wc-cart-session.php
index 99d59f679d..fbc9c7e680 100644
--- a/plugins/woocommerce/includes/class-wc-cart-session.php
+++ b/plugins/woocommerce/includes/class-wc-cart-session.php
@@ -603,7 +603,12 @@ final class WC_Cart_Session {
foreach ( $item->get_meta_data() as $meta ) {
if ( taxonomy_is_product_attribute( $meta->key ) || meta_is_product_attribute( $meta->key, $meta->value, $product_id ) ) {
- $variations[ $meta->key ] = $meta->value;
+ $attribute_key = 'attribute_' . sanitize_title( $meta->key );
+ if ( taxonomy_is_product_attribute( $meta->key ) ) {
+ $variations[ $attribute_key ] = sanitize_title( $meta->value );
+ } else {
+ $variations[ $attribute_key ] = html_entity_decode( wc_clean( $meta->value ), ENT_QUOTES, get_bloginfo( 'charset' ) );
+ }
}
}
@@ -611,6 +616,26 @@ final class WC_Cart_Session {
continue;
}
+ $product_data = wc_get_product( $variation_id ? $variation_id : $product_id );
+ if ( $product_data instanceof WC_Product && $product_data->is_sold_individually() ) {
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Filter documented in CartController::validate_add_to_cart().
+ $quantity = apply_filters( 'woocommerce_add_to_cart_sold_individually_quantity', 1, $quantity, $product_id, $variation_id, $cart_item_data );
+
+ $cart_id = WC()->cart->generate_cart_id( $product_id, $variation_id, $variations, $cart_item_data );
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Filter first added in WC_Cart::add_to_cart(), add documentation there.
+ $found_in_cart = apply_filters( 'woocommerce_add_to_cart_sold_individually_found_in_cart', isset( $cart[ $cart_id ] ) && isset( $cart[ $cart_id ]['quantity'] ) && $cart[ $cart_id ]['quantity'] > 0, $product_id, $variation_id, $cart_item_data, $cart_id );
+ if ( $found_in_cart ) {
+ /* translators: %s: product name */
+ $message = sprintf( __( 'You cannot add another "%s" to your cart.', 'woocommerce' ), $product_data->get_name() );
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Filter documented in WC_Cart::add_to_cart().
+ $message = apply_filters( 'woocommerce_cart_product_cannot_add_another_message', $message, $product_data );
+ $wp_button_class = wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_theme_get_element_class_name( 'button' ) : '';
+ $message = sprintf( '%s <a href="%s" class="button wc-forward%s">%s</a>', $message, esc_url( wc_get_cart_url() ), esc_attr( $wp_button_class ), __( 'View cart', 'woocommerce' ) );
+ wc_add_notice( $message, 'error' );
+ continue;
+ }
+ }
+
// Add to cart directly.
$cart_id = WC()->cart->generate_cart_id( $product_id, $variation_id, $variations, $cart_item_data );
$product_data = wc_get_product( $variation_id ? $variation_id : $product_id );
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
index 96c7fa2e05..69fbcb41c9 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
@@ -4,6 +4,8 @@
*
* @package WooCommerce\Tests\Cart.
*/
+
+use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
/**
@@ -30,6 +32,86 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
WC()->session->set( 'wc_notices', null );
}
+ /**
+ * @testdox Order Again should enforce sold individually for variable products (no duplicates, qty forced to 1)
+ */
+ public function test_order_again_enforces_sold_individually_for_variations() {
+ $user_id = $this->factory->user->create();
+ wp_set_current_user( $user_id );
+
+ WC()->session = new WC_Session_Handler();
+ WC()->session->init();
+ WC()->session->set_customer_session_cookie( true );
+
+ WC()->cart->empty_cart();
+ WC()->session->set( 'wc_notices', null );
+
+ $variable_product = WC_Helper_Product::create_variation_product();
+ $variable_product->set_sold_individually( true );
+ $variable_product->save();
+
+ $variation_ids = $variable_product->get_children();
+ $this->assertNotEmpty( $variation_ids, 'Expected at least one variation.' );
+ $variation_id = (int) $variation_ids[0];
+ $variation = wc_get_product( $variation_id );
+
+ $this->assertTrue( $variation->is_sold_individually(), 'Variation should be sold individually.' );
+
+ $order = WC_Helper_Order::create_order( $user_id, $variation, array( 'status' => OrderStatus::COMPLETED ) );
+ $this->assertGreaterThan( 0, $order->get_id(), 'Order should be created.' );
+
+ $order_items = $order->get_items();
+ $this->assertNotEmpty( $order_items, 'Order should have at least one item.' );
+ $order_item = array_values( $order_items )[0];
+ foreach ( $variation->get_attributes() as $att_key => $att_val ) {
+ $order_item->add_meta_data( $att_key, $att_val, true );
+ }
+ $order_item->save();
+ $order->save();
+
+ $cart_session = new WC_Cart_Session( WC()->cart );
+ $ref = new ReflectionClass( WC_Cart_Session::class );
+ $method = $ref->getMethod( 'populate_cart_from_order' );
+ $method->setAccessible( true );
+ $current_cart = WC()->session->get( 'cart', null );
+ $populated = $method->invoke( $cart_session, $order->get_id(), $current_cart );
+ WC()->session->set( 'cart', $populated );
+ WC()->cart->set_cart_contents( $populated ? $populated : array() );
+
+ $cart_contents = WC()->cart->get_cart();
+ $this->assertCount( 1, $cart_contents, 'Cart should contain one item after Order Again for sold individually product.' );
+
+ $only_item = array_values( $cart_contents )[0];
+ $this->assertEquals( $variation_id, $only_item['variation_id'], 'Cart item should correspond to the ordered variation.' );
+ $this->assertEquals( 1, $only_item['quantity'], 'Quantity should be forced to 1 for sold individually products.' );
+
+ $available_variations = $variable_product->get_available_variations();
+ $attributes_for_variation = array();
+ foreach ( $available_variations as $v ) {
+ if ( (int) $v['variation_id'] === $variation_id ) {
+ $attributes_for_variation = $v['attributes'];
+ break;
+ }
+ }
+ $this->assertNotEmpty( $attributes_for_variation, 'Expected to find attributes for variation.' );
+
+ $added = WC()->cart->add_to_cart( $variable_product->get_id(), 1, $variation_id, $attributes_for_variation );
+ $this->assertFalse( $added, 'Adding duplicate sold individually variation should be blocked.' );
+
+ $notices = wc_get_notices();
+ $this->assertArrayHasKey( 'error', $notices );
+ $this->assertNotEmpty( $notices['error'], 'Expected an error notice when adding duplicate sold individually item.' );
+
+ $cart_contents_after = WC()->cart->get_cart();
+ $this->assertCount( 1, $cart_contents_after, 'Cart should still contain one item.' );
+ $only_item_after = array_values( $cart_contents_after )[0];
+ $this->assertEquals( 1, $only_item_after['quantity'], 'Quantity should remain 1.' );
+
+ WC_Helper_Order::delete_order( $order->get_id() );
+ WC_Helper_Product::delete_product( $variable_product->get_id() );
+ wp_delete_user( $user_id );
+ }
+
/**
* @testdox should throw a notice to the cart if an "any" attribute is empty.
*/