diff --git a/vendor/magento/module-checkout/Model/Config.php b/vendor/magento/module-checkout/Model/Config.php
new file mode 100644
index 0000000000000..65a520cc8c22d
--- /dev/null
+++ b/vendor/magento/module-checkout/Model/Config.php
@@ -0,0 +1,37 @@
+scopeConfig->getValue(self::XML_PATH_CART_MERGE_PREFERENCE);
+ }
+}
diff --git a/vendor/magento/module-checkout/Model/Config/Source/CartMergePreference.php b/vendor/magento/module-checkout/Model/Config/Source/CartMergePreference.php
new file mode 100644
index 0000000000000..96dbf87879239
--- /dev/null
+++ b/vendor/magento/module-checkout/Model/Config/Source/CartMergePreference.php
@@ -0,0 +1,27 @@
+ 'guest', 'label' => __('Guest Priority – Override with guest cart quantity')],
+ ['value' => 'customer', 'label' => __('Customer Priority – Override with customer cart quantity')],
+ ['value' => 'merge', 'label' => __('Merge Quantities – Merge quantities of customer and guest cart')]
+ ];
+ }
+}
diff --git a/vendor/magento/module-checkout/etc/adminhtml/system.xml b/vendor/magento/module-checkout/etc/adminhtml/system.xml
index 944914b34e1ea..3eaa8fe96be94 100644
--- a/vendor/magento/module-checkout/etc/adminhtml/system.xml
+++ b/vendor/magento/module-checkout/etc/adminhtml/system.xml
@@ -57,6 +57,11 @@
Magento\Config\Model\Config\Source\Yesno
+
+
+ Magento\Checkout\Model\Config\Source\CartMergePreference
+ Select how cart item quantities should be merged
+
diff --git a/vendor/magento/module-checkout/etc/config.xml b/vendor/magento/module-checkout/etc/config.xml
index ef4afdf8d4b27..1ae26a26de8d6 100644
--- a/vendor/magento/module-checkout/etc/config.xml
+++ b/vendor/magento/module-checkout/etc/config.xml
@@ -21,6 +21,7 @@
20
1
0
+ merge
1
diff --git a/vendor/magento/module-quote-graph-ql/Model/Cart/MergeCarts/CartQuantityValidator.php b/vendor/magento/module-quote-graph-ql/Model/Cart/MergeCarts/CartQuantityValidator.php
index 4d44108a2b0b3..87413f2745ad5 100644
--- a/vendor/magento/module-quote-graph-ql/Model/Cart/MergeCarts/CartQuantityValidator.php
+++ b/vendor/magento/module-quote-graph-ql/Model/Cart/MergeCarts/CartQuantityValidator.php
@@ -1,46 +1,52 @@
cartItemRepository = $cartItemRepository;
- $this->stockRegistry = $stockRegistry;
}
/**
- * Validate combined cart quantities to make sure they are within available stock
+ * Validate combined cart quantities to ensure they are within available stock
*
* @param CartInterface $customerCart
* @param CartInterface $guestCart
@@ -49,28 +55,187 @@ public function __construct(
public function validateFinalCartQuantities(CartInterface $customerCart, CartInterface $guestCart): bool
{
$modified = false;
- /** @var CartItemInterface $guestCartItem */
+ $this->cumulativeQty = [];
+
+ /** @var \Magento\Quote\Model\Quote $guestCart */
+ /** @var \Magento\Quote\Model\Quote $customerCart */
+ /** @var \Magento\Quote\Model\Quote\Item $guestCartItem */
foreach ($guestCart->getAllVisibleItems() as $guestCartItem) {
foreach ($customerCart->getAllItems() as $customerCartItem) {
- if ($customerCartItem->compare($guestCartItem)) {
- $product = $customerCartItem->getProduct();
- $stockCurrentQty = $this->stockRegistry->getStockStatus(
- $product->getId(),
- $product->getStore()->getWebsiteId()
- )->getQty();
- if ($stockCurrentQty < $guestCartItem->getQty() + $customerCartItem->getQty()) {
- try {
- $this->cartItemRepository->deleteById($guestCart->getId(), $guestCartItem->getItemId());
- $modified = true;
- } catch (NoSuchEntityException $e) {
- continue;
- } catch (CouldNotSaveException $e) {
- continue;
- }
- }
+ if (!$customerCartItem->compare($guestCartItem)) {
+ continue;
+ }
+
+ $mergePreference = $this->config->getCartMergePreference();
+
+ if ($mergePreference === Config::CART_PREFERENCE_CUSTOMER) {
+ $this->safeDeleteCartItem((int)$guestCart->getId(), (int)$guestCartItem->getItemId());
+ $modified = true;
+ break;
}
+
+ $product = $customerCartItem->getProduct();
+ $sku = $product->getSku();
+ $websiteId = (int) $product->getStore()->getWebsiteId();
+
+ $isQtyValid = $customerCartItem->getChildren()
+ ? $this->validateCompositeProductQty($guestCartItem, $customerCartItem)
+ : $this->validateProductQty(
+ $product,
+ $sku,
+ $guestCartItem->getQty(),
+ $customerCartItem->getQty(),
+ $websiteId
+ );
+
+ if ($mergePreference === Config::CART_PREFERENCE_GUEST) {
+ $this->safeDeleteCartItem((int)$customerCart->getId(), (int)$customerCartItem->getItemId());
+ $modified = true;
+ }
+
+ if (!$isQtyValid) {
+ $this->safeDeleteCartItem((int)$guestCart->getId(), (int)$guestCartItem->getItemId());
+ $modified = true;
+ }
+
+ break;
}
}
+
+ $this->cumulativeQty = [];
+
return $modified;
}
+
+ /**
+ * Validate product quantity against available stock
+ *
+ * @param ProductInterface $product
+ * @param string $sku
+ * @param float $guestItemQty
+ * @param float $customerItemQty
+ * @param int $websiteId
+ * @return bool
+ */
+ private function validateProductQty(
+ ProductInterface $product,
+ string $sku,
+ float $guestItemQty,
+ float $customerItemQty,
+ int $websiteId
+ ): bool {
+ $salableQty = $this->stockRegistry->getStockStatus($product->getId(), $websiteId)->getQty();
+
+ $this->cumulativeQty[$sku] ??= 0;
+ $this->cumulativeQty[$sku] += $this->getCurrentCartItemQty($guestItemQty, $customerItemQty);
+
+ // If backorders are enabled, allow quantities beyond available stock
+ if ($this->isBackordersEnabled($product)) {
+ return true;
+ }
+
+ return $salableQty >= $this->cumulativeQty[$sku];
+ }
+
+ /**
+ * Validate composite product quantities against available stock
+ *
+ * @param Item $guestItem
+ * @param Item $customerItem
+ * @return bool
+ */
+ private function validateCompositeProductQty(Item $guestItem, Item $customerItem): bool
+ {
+ $guestChildren = $guestItem->getChildren();
+ $customerChildren = $customerItem->getChildren();
+
+ foreach ($customerChildren as $customerChild) {
+ $sku = $customerChild->getProduct()->getSku();
+ $guestChild = $this->retrieveChildItem($guestChildren, $sku);
+
+ $guestQty = $guestChild ? $guestItem->getQty() * $guestChild->getQty() : 0;
+ $customerQty = $customerItem->getQty() * $customerChild->getQty();
+
+ $product = $customerChild->getProduct();
+ $websiteId = (int) $product->getStore()->getWebsiteId();
+
+ // If backorders are enabled for this product, skip quantity validation
+ if ($this->isBackordersEnabled($product)) {
+ continue;
+ }
+
+ if (!$this->validateProductQty($product, $sku, $guestQty, $customerQty, $websiteId)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Find a child item by SKU in the list of children
+ *
+ * @param CartItemInterface[] $children
+ * @param string $sku
+ * @return CartItemInterface|null
+ */
+ private function retrieveChildItem(array $children, string $sku): ?CartItemInterface
+ {
+ foreach ($children as $child) {
+ /** @var \Magento\Quote\Model\Quote\Item $child */
+ if ($child->getProduct()->getSku() === $sku) {
+ return $child;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the current cart item quantity based on the merge preference
+ *
+ * @param float $guestCartItemQty
+ * @param float $customerCartItemQty
+ * @return float
+ */
+ private function getCurrentCartItemQty(float $guestCartItemQty, float $customerCartItemQty): float
+ {
+ return match ($this->config->getCartMergePreference()) {
+ Config::CART_PREFERENCE_CUSTOMER => $customerCartItemQty,
+ Config::CART_PREFERENCE_GUEST => $guestCartItemQty,
+ default => $guestCartItemQty + $customerCartItemQty
+ };
+ }
+
+ /**
+ * Safely delete a cart item by ID, logging any exceptions
+ *
+ * @param int $cartId
+ * @param int $itemId
+ * @return void
+ */
+ private function safeDeleteCartItem(int $cartId, int $itemId): void
+ {
+ try {
+ $this->cartItemRepository->deleteById($cartId, $itemId);
+ } catch (NoSuchEntityException | CouldNotSaveException $e) {
+ $this->logger->error($e->getMessage());
+ }
+ }
+
+ /**
+ * Check if backorders are enabled for the stock item
+ *
+ * @param Product $product
+ * @return bool
+ */
+ private function isBackordersEnabled(Product $product): bool
+ {
+ $backorders = $this->stockRegistry->getStockItem(
+ $product->getId(),
+ $product->getStore()->getWebsiteId()
+ )->getBackorders();
+ return $backorders == Stock::BACKORDERS_YES_NONOTIFY ||
+ $backorders == Stock::BACKORDERS_YES_NOTIFY;
+ }
}
diff --git a/vendor/magento/module-quote-graph-ql/Model/Resolver/MergeCarts.php b/vendor/magento/module-quote-graph-ql/Model/Resolver/MergeCarts.php
index 7f7889c8174bc..45074a0eacb20 100644
--- a/vendor/magento/module-quote-graph-ql/Model/Resolver/MergeCarts.php
+++ b/vendor/magento/module-quote-graph-ql/Model/Resolver/MergeCarts.php
@@ -1,16 +1,13 @@
getCartForUser = $getCartForUser;
- $this->cartRepository = $cartRepository;
- $this->customerCartResolver = $customerCartResolver
- ?: ObjectManager::getInstance()->get(CustomerCartResolver::class);
- $this->quoteIdToMaskedQuoteId = $quoteIdToMaskedQuoteId
- ?: ObjectManager::getInstance()->get(QuoteIdToMaskedQuoteIdInterface::class);
- $this->cartItemRepository = $cartItemRepository
- ?: ObjectManager::getInstance()->get(CartItemRepositoryInterface::class);
- $this->stockRegistry = $stockRegistry
- ?: ObjectManager::getInstance()->get(StockRegistryInterface::class);
- $this->cartQuantityValidator = $cartQuantityValidator
- ?: ObjectManager::getInstance()->get(CartQuantityValidatorInterface::class);
}
/**
@@ -109,69 +53,76 @@ public function resolve(
ResolveInfo $info,
array $value = null,
array $args = null
- ) {
+ ): array {
if (empty($args['source_cart_id'])) {
- throw new GraphQlInputException(__(
- 'Required parameter "source_cart_id" is missing'
- ));
+ throw new GraphQlInputException(__('Required parameter "source_cart_id" is missing'));
+ }
+
+ if (isset($args['destination_cart_id']) && empty($args['destination_cart_id'])) {
+ throw new GraphQlInputException(__('The parameter "destination_cart_id" cannot be empty'));
}
/** @var ContextInterface $context */
- if (false === $context->getExtensionAttributes()->getIsCustomer()) {
- throw new GraphQlAuthorizationException(__(
- 'The current customer isn\'t authorized.'
- ));
+ if (!$context->getExtensionAttributes()->getIsCustomer()) {
+ throw new GraphQlAuthorizationException(__('The current customer isn\'t authorized.'));
}
+
$currentUserId = $context->getUserId();
+ $storeId = (int)$context->getExtensionAttributes()->getStore()->getId();
+ $guestMaskedCartId = $args['source_cart_id'];
+
+ // Resolve destination cart ID
+ $customerMaskedCartId = $args['destination_cart_id'] ?? null;
- if (!isset($args['destination_cart_id'])) {
+ if (!$customerMaskedCartId) {
try {
$cart = $this->customerCartResolver->resolve($currentUserId);
+ $customerMaskedCartId = $this->quoteIdToMaskedQuoteId->execute((int) $cart->getId());
} catch (CouldNotSaveException $exception) {
throw new GraphQlNoSuchEntityException(
__('Could not create empty cart for customer'),
$exception
);
}
- $customerMaskedCartId = $this->quoteIdToMaskedQuoteId->execute(
- (int) $cart->getId()
- );
- } else {
- if (empty($args['destination_cart_id'])) {
- throw new GraphQlInputException(__(
- 'The parameter "destination_cart_id" cannot be empty'
- ));
- }
}
- $guestMaskedCartId = $args['source_cart_id'];
- $customerMaskedCartId = $customerMaskedCartId ?? $args['destination_cart_id'];
+ // Fetch guest and customer carts
+ $customerCart = $this->getCartForUser->execute($customerMaskedCartId, $currentUserId, $storeId);
+ $guestCart = $this->getCartForUser->execute($guestMaskedCartId, null, $storeId);
- $storeId = (int)$context->getExtensionAttributes()->getStore()->getId();
- // passing customerId as null enforces source cart should always be a guestcart
- $guestCart = $this->getCartForUser->execute(
- $guestMaskedCartId,
- null,
- $storeId
- );
- $customerCart = $this->getCartForUser->execute(
- $customerMaskedCartId,
- $currentUserId,
- $storeId
- );
+ // Validate cart quantities before merging and reload cart before cart merge
if ($this->cartQuantityValidator->validateFinalCartQuantities($customerCart, $guestCart)) {
- $guestCart = $this->getCartForUser->execute(
- $guestMaskedCartId,
- null,
- $storeId
- );
+ $guestCart = $this->getCartForUser->execute($guestMaskedCartId, null, $storeId);
+ $customerCart = $this->getCartForUser->execute($customerMaskedCartId, $currentUserId, $storeId);
}
+
+ // Merge carts and save
$customerCart->merge($guestCart);
$guestCart->setIsActive(false);
+ // Check and update gift options from guest cart to customer cart
+ $customerCart = $this->updateGiftOptions($guestCart, $customerCart);
+
$this->cartRepository->save($customerCart);
$this->cartRepository->save($guestCart);
- return [
- 'model' => $customerCart,
- ];
+
+ return ['model' => $customerCart];
+ }
+
+ /**
+ * Check and update gift options in customer cart from guest cart
+ *
+ * @param Quote $guestCart
+ * @param Quote $customerCart
+ * @return Quote
+ */
+ private function updateGiftOptions(Quote $guestCart, Quote $customerCart): Quote
+ {
+ foreach ($this->fields as $field) {
+ if (!empty($guestCart->getData($field)) && empty($customerCart->getData($field))) {
+ $customerCart->setData($field, $guestCart->getData($field));
+ }
+ }
+
+ return $customerCart;
}
}
diff --git a/vendor/magento/module-quote-graph-ql/etc/graphql/di.xml b/vendor/magento/module-quote-graph-ql/etc/graphql/di.xml
index 83942f1daebb2..59e1abf1e93e3 100644
--- a/vendor/magento/module-quote-graph-ql/etc/graphql/di.xml
+++ b/vendor/magento/module-quote-graph-ql/etc/graphql/di.xml
@@ -70,6 +70,14 @@
- checkout/sidebar/display
- checkout/sidebar/max_items_display_count
- checkout/cart/delete_quote_after
+ - checkout/cart/cart_merge_preference
+
+
+
+
+
+
+ - gift_message_id
diff --git a/vendor/magento/module-quote-graph-ql/etc/schema.graphqls b/vendor/magento/module-quote-graph-ql/etc/schema.graphqls
index 53da2d8751127..b2323999d0b39 100644
--- a/vendor/magento/module-quote-graph-ql/etc/schema.graphqls
+++ b/vendor/magento/module-quote-graph-ql/etc/schema.graphqls
@@ -515,6 +515,7 @@ type StoreConfig {
minicart_display: Boolean @doc(description: "Extended Config Data - checkout/sidebar/display")
minicart_max_items: Int @doc(description: "Extended Config Data - checkout/sidebar/count")
cart_expires_in_days: Int @doc(description: "Extended Config Data - checkout/cart/delete_quote_after")
+ cart_merge_preference: String! @doc(description: "Configuration data from checkout/cart/cart_merge_preference")
}
input EstimateTotalsInput {