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.
 	 */