Skip to content

Commit 5d19147

Browse files
authored
LYNX-889: [AC-2.4.9] Merging guest and customer cart logic using Admin Configuration (#371)
* LYNX-899: [AC-2.4.9] Merge carts using configurations * Performance failure fix * PAT failure fix * Review feedback fixed * Review feedback fixed * Review comments fixed
1 parent 70a897f commit 5d19147

File tree

10 files changed

+1163
-54
lines changed

10 files changed

+1163
-54
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Checkout\Model;
9+
10+
use Magento\Framework\App\Config\ScopeConfigInterface;
11+
12+
class Config
13+
{
14+
public const CART_PREFERENCE_CUSTOMER = "customer";
15+
public const CART_PREFERENCE_GUEST = "guest";
16+
private const XML_PATH_CART_MERGE_PREFERENCE = 'checkout/cart/cart_merge_preference';
17+
18+
/**
19+
* Config Constructor
20+
*
21+
* @param ScopeConfigInterface $scopeConfig
22+
*/
23+
public function __construct(
24+
private readonly ScopeConfigInterface $scopeConfig
25+
) {
26+
}
27+
28+
/**
29+
* Get Cart Merge Preference config to update cart quantities
30+
*
31+
* @return string
32+
*/
33+
public function getCartMergePreference(): string
34+
{
35+
return $this->scopeConfig->getValue(self::XML_PATH_CART_MERGE_PREFERENCE);
36+
}
37+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Checkout\Model\Config\Source;
9+
10+
use Magento\Framework\Data\OptionSourceInterface;
11+
12+
class CartMergePreference implements OptionSourceInterface
13+
{
14+
/**
15+
* Retrieve options for cart merge preference
16+
*
17+
* @return array[]
18+
*/
19+
public function toOptionArray(): array
20+
{
21+
return [
22+
['value' => 'guest', 'label' => __('Guest Priority – Override with guest cart quantity')],
23+
['value' => 'customer', 'label' => __('Customer Priority – Override with customer cart quantity')],
24+
['value' => 'merge', 'label' => __('Merge Quantities – Merge quantities of customer and guest cart')]
25+
];
26+
}
27+
}

app/code/Magento/Checkout/etc/adminhtml/system.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@
5757
<label>Enable Clear Shopping Cart</label>
5858
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
5959
</field>
60+
<field id="cart_merge_preference" translate="label" type="select" sortOrder="100" showInDefault="1" showInWebsite="0" showInStore="0">
61+
<label>Cart Merge Preference</label>
62+
<source_model>Magento\Checkout\Model\Config\Source\CartMergePreference</source_model>
63+
<comment>Select how cart item quantities should be merged</comment>
64+
</field>
6065
</group>
6166
<group id="cart_link" translate="label" sortOrder="3" showInDefault="1" showInWebsite="1">
6267
<label>My Cart Link</label>

app/code/Magento/Checkout/etc/config.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<number_items_to_display_pager>20</number_items_to_display_pager>
2222
<crosssell_enabled>1</crosssell_enabled>
2323
<enable_clear_shopping_cart>0</enable_clear_shopping_cart>
24+
<cart_merge_preference>merge</cart_merge_preference>
2425
</cart>
2526
<cart_link>
2627
<use_qty>1</use_qty>
Lines changed: 167 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,50 @@
11
<?php
22
/**
3-
* Copyright © Magento, Inc. All rights reserved.
4-
* See COPYING.txt for license details.
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
55
*/
66
declare(strict_types=1);
77

88
namespace Magento\QuoteGraphQl\Model\Cart\MergeCarts;
99

10-
1110
use Magento\CatalogInventory\Api\StockRegistryInterface;
1211
use Magento\Framework\Exception\CouldNotSaveException;
1312
use Magento\Framework\Exception\NoSuchEntityException;
1413
use Magento\Quote\Api\CartItemRepositoryInterface;
1514
use Magento\Quote\Api\Data\CartInterface;
1615
use Magento\Quote\Api\Data\CartItemInterface;
16+
use Magento\Quote\Model\Quote\Item;
17+
use Magento\Checkout\Model\Config;
18+
use Psr\Log\LoggerInterface;
19+
use Magento\Catalog\Api\Data\ProductInterface;
1720

1821
class CartQuantityValidator implements CartQuantityValidatorInterface
1922
{
2023
/**
21-
* @var CartItemRepositoryInterface
22-
*/
23-
private $cartItemRepository;
24-
25-
/**
26-
* @var StockRegistryInterface
24+
* Array to hold cumulative quantities for each SKU
25+
*
26+
* @var array
2727
*/
28-
private $stockRegistry;
28+
private array $cumulativeQty = [];
2929

3030
/**
31+
* CartQuantityValidator Constructor
32+
*
3133
* @param CartItemRepositoryInterface $cartItemRepository
3234
* @param StockRegistryInterface $stockRegistry
35+
* @param Config $config
36+
* @param LoggerInterface $logger
3337
*/
3438
public function __construct(
35-
CartItemRepositoryInterface $cartItemRepository,
36-
StockRegistryInterface $stockRegistry
39+
private readonly CartItemRepositoryInterface $cartItemRepository,
40+
private readonly StockRegistryInterface $stockRegistry,
41+
private readonly Config $config,
42+
private readonly LoggerInterface $logger
3743
) {
38-
$this->cartItemRepository = $cartItemRepository;
39-
$this->stockRegistry = $stockRegistry;
4044
}
4145

4246
/**
43-
* Validate combined cart quantities to make sure they are within available stock
47+
* Validate combined cart quantities to ensure they are within available stock
4448
*
4549
* @param CartInterface $customerCart
4650
* @param CartInterface $guestCart
@@ -49,28 +53,157 @@ public function __construct(
4953
public function validateFinalCartQuantities(CartInterface $customerCart, CartInterface $guestCart): bool
5054
{
5155
$modified = false;
52-
/** @var CartItemInterface $guestCartItem */
53-
foreach ($guestCart->getAllVisibleItems() as $guestCartItem) {
54-
foreach ($customerCart->getAllItems() as $customerCartItem) {
55-
if ($customerCartItem->compare($guestCartItem)) {
56-
$product = $customerCartItem->getProduct();
57-
$stockCurrentQty = $this->stockRegistry->getStockStatus(
58-
$product->getId(),
59-
$product->getStore()->getWebsiteId()
60-
)->getQty();
61-
if ($stockCurrentQty < $guestCartItem->getQty() + $customerCartItem->getQty()) {
62-
try {
63-
$this->cartItemRepository->deleteById($guestCart->getId(), $guestCartItem->getItemId());
64-
$modified = true;
65-
} catch (NoSuchEntityException $e) {
66-
continue;
67-
} catch (CouldNotSaveException $e) {
68-
continue;
69-
}
70-
}
56+
$this->cumulativeQty = [];
57+
58+
foreach ($guestCart->getAllVisibleItems() as $guestItem) {
59+
foreach ($customerCart->getAllItems() as $customerItem) {
60+
if (!$customerItem->compare($guestItem)) {
61+
continue;
62+
}
63+
64+
$mergePreference = $this->config->getCartMergePreference();
65+
66+
if ($mergePreference === Config::CART_PREFERENCE_CUSTOMER) {
67+
$this->safeDeleteCartItem((int)$guestCart->getId(), (int)$guestItem->getItemId());
68+
$modified = true;
69+
break;
70+
}
71+
72+
$product = $customerItem->getProduct();
73+
$sku = $product->getSku();
74+
$websiteId = (int) $product->getStore()->getWebsiteId();
75+
76+
$isQtyValid = $customerItem->getChildren()
77+
? $this->validateCompositeProductQty($guestItem, $customerItem)
78+
: $this->validateProductQty(
79+
$product,
80+
$sku,
81+
$guestItem->getQty(),
82+
$customerItem->getQty(),
83+
$websiteId
84+
);
85+
86+
if ($mergePreference === Config::CART_PREFERENCE_GUEST) {
87+
$this->safeDeleteCartItem((int)$customerCart->getId(), (int)$customerItem->getItemId());
88+
$modified = true;
89+
}
90+
91+
if (!$isQtyValid) {
92+
$this->safeDeleteCartItem((int)$guestCart->getId(), (int)$guestItem->getItemId());
93+
$modified = true;
7194
}
95+
96+
break;
7297
}
7398
}
99+
100+
$this->cumulativeQty = [];
101+
74102
return $modified;
75103
}
104+
105+
/**
106+
* Validate product quantity against available stock
107+
*
108+
* @param ProductInterface $product
109+
* @param string $sku
110+
* @param float $guestItemQty
111+
* @param float $customerItemQty
112+
* @param int $websiteId
113+
* @return bool
114+
*/
115+
private function validateProductQty(
116+
ProductInterface $product,
117+
string $sku,
118+
float $guestItemQty,
119+
float $customerItemQty,
120+
int $websiteId
121+
): bool {
122+
$salableQty = $this->stockRegistry->getStockStatus($product->getId(), $websiteId)->getQty();
123+
124+
$this->cumulativeQty[$sku] ??= 0;
125+
$this->cumulativeQty[$sku] += $this->getCurrentCartItemQty($guestItemQty, $customerItemQty);
126+
127+
return $salableQty >= $this->cumulativeQty[$sku];
128+
}
129+
130+
/**
131+
* Validate composite product quantities against available stock
132+
*
133+
* @param Item $guestItem
134+
* @param Item $customerItem
135+
* @return bool
136+
*/
137+
private function validateCompositeProductQty(Item $guestItem, Item $customerItem): bool
138+
{
139+
$guestChildren = $guestItem->getChildren();
140+
$customerChildren = $customerItem->getChildren();
141+
142+
foreach ($customerChildren as $customerChild) {
143+
$sku = $customerChild->getProduct()->getSku();
144+
$guestChild = $this->retrieveChildItem($guestChildren, $sku);
145+
146+
$guestQty = $guestChild ? $guestItem->getQty() * $guestChild->getQty() : 0;
147+
$customerQty = $customerItem->getQty() * $customerChild->getQty();
148+
149+
$product = $customerChild->getProduct();
150+
$websiteId = (int) $product->getStore()->getWebsiteId();
151+
152+
if (!$this->validateProductQty($product, $sku, $guestQty, $customerQty, $websiteId)) {
153+
return false;
154+
}
155+
}
156+
157+
return true;
158+
}
159+
160+
/**
161+
* Find a child item by SKU in the list of children
162+
*
163+
* @param CartItemInterface[] $children
164+
* @param string $sku
165+
* @return CartItemInterface|null
166+
*/
167+
private function retrieveChildItem(array $children, string $sku): ?CartItemInterface
168+
{
169+
foreach ($children as $child) {
170+
if ($child->getProduct()->getSku() === $sku) {
171+
return $child;
172+
}
173+
}
174+
175+
return null;
176+
}
177+
178+
/**
179+
* Get the current cart item quantity based on the merge preference
180+
*
181+
* @param float $guestCartItemQty
182+
* @param float $customerCartItemQty
183+
* @return float
184+
*/
185+
private function getCurrentCartItemQty(float $guestCartItemQty, float $customerCartItemQty): float
186+
{
187+
return match ($this->config->getCartMergePreference()) {
188+
Config::CART_PREFERENCE_CUSTOMER => $customerCartItemQty,
189+
Config::CART_PREFERENCE_GUEST => $guestCartItemQty,
190+
default => $guestCartItemQty + $customerCartItemQty
191+
};
192+
}
193+
194+
/**
195+
* Safely delete a cart item by ID, logging any exceptions
196+
*
197+
* @param int $cartId
198+
* @param int $itemId
199+
* @return void
200+
*/
201+
private function safeDeleteCartItem(int $cartId, int $itemId): void
202+
{
203+
try {
204+
$this->cartItemRepository->deleteById($cartId, $itemId);
205+
} catch (NoSuchEntityException | CouldNotSaveException $e) {
206+
$this->logger->error($e->getMessage());
207+
}
208+
}
76209
}

app/code/Magento/QuoteGraphQl/Model/Resolver/MergeCarts.php

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,6 @@
2424

2525
class MergeCarts implements ResolverInterface
2626
{
27-
/**
28-
* @var array
29-
*/
30-
private array $fields;
31-
3227
/**
3328
* MergeCarts Constructor
3429
*
@@ -45,9 +40,8 @@ public function __construct(
4540
private readonly CustomerCartResolver $customerCartResolver,
4641
private readonly QuoteIdToMaskedQuoteIdInterface $quoteIdToMaskedQuoteId,
4742
private readonly CartQuantityValidatorInterface $cartQuantityValidator,
48-
array $fields
43+
private readonly array $fields
4944
) {
50-
$this->fields = $fields;
5145
}
5246

5347
/**
@@ -96,9 +90,10 @@ public function resolve(
9690
$customerCart = $this->getCartForUser->execute($customerMaskedCartId, $currentUserId, $storeId);
9791
$guestCart = $this->getCartForUser->execute($guestMaskedCartId, null, $storeId);
9892

99-
// Validate cart quantities before merging
93+
// Validate cart quantities before merging and reload cart before cart merge
10094
if ($this->cartQuantityValidator->validateFinalCartQuantities($customerCart, $guestCart)) {
10195
$guestCart = $this->getCartForUser->execute($guestMaskedCartId, null, $storeId);
96+
$customerCart = $this->getCartForUser->execute($customerMaskedCartId, $currentUserId, $storeId);
10297
}
10398

10499
// Merge carts and save

app/code/Magento/QuoteGraphQl/etc/graphql/di.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
<item name="grouped_product_image" xsi:type="string">checkout/cart/grouped_product_image</item>
7474
<item name="configurable_product_image" xsi:type="string">checkout/cart/configurable_product_image</item>
7575
<item name="is_checkout_agreements_enabled" xsi:type="string">checkout/options/enable_agreements</item>
76+
<item name="cart_merge_preference" xsi:type="string">checkout/cart/cart_merge_preference</item>
7677
</argument>
7778
</arguments>
7879
</type>

app/code/Magento/QuoteGraphQl/etc/schema.graphqls

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,7 @@ type StoreConfig {
523523
grouped_product_image: ProductImageThumbnail! @doc(description: "checkout/cart/grouped_product_image: which image to use for grouped products.") @resolver(class: "Magento\\QuoteGraphQl\\Model\\Resolver\\StoreConfig")
524524
configurable_product_image: ProductImageThumbnail! @doc(description: "checkout/cart/configurable_product_image: which image to use for configurable products.") @resolver(class: "Magento\\QuoteGraphQl\\Model\\Resolver\\StoreConfig")
525525
is_checkout_agreements_enabled: Boolean! @doc(description: "Configuration data from checkout/options/enable_agreements")
526+
cart_merge_preference: String! @doc(description: "Configuration data from checkout/cart/cart_merge_preference")
526527
}
527528

528529
enum ProductImageThumbnail {

0 commit comments

Comments
 (0)