Skip to content

Commit b995835

Browse files
authored
Merge pull request #365 from cod40403/LYNX-889
LYNX-889: [AC-2.4.9] Merging guest and customer cart logic using Admin Configuration
2 parents e2b6f04 + 9f0d29b commit b995835

File tree

10 files changed

+1161
-40
lines changed

10 files changed

+1161
-40
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: 176 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,47 @@
11
<?php
22
/**
3-
* Copyright © Magento, Inc. All rights reserved.
4-
* See COPYING.txt for license details.
3+
* Copyright 2019 Adobe
4+
* All Rights Reserved.
55
*/
66
declare(strict_types=1);
77

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

10-
10+
use Magento\Catalog\Api\Data\ProductInterface;
11+
use Magento\Catalog\Api\ProductRepositoryInterface;
1112
use Magento\CatalogInventory\Api\StockRegistryInterface;
13+
use Magento\Checkout\Model\Config;
1214
use Magento\Framework\Exception\CouldNotSaveException;
1315
use Magento\Framework\Exception\NoSuchEntityException;
1416
use Magento\Quote\Api\CartItemRepositoryInterface;
1517
use Magento\Quote\Api\Data\CartInterface;
1618
use Magento\Quote\Api\Data\CartItemInterface;
19+
use Magento\Quote\Model\Quote\Item;
20+
use Psr\Log\LoggerInterface;
1721

1822
class CartQuantityValidator implements CartQuantityValidatorInterface
1923
{
2024
/**
21-
* @var CartItemRepositoryInterface
22-
*/
23-
private $cartItemRepository;
24-
25-
/**
26-
* @var StockRegistryInterface
25+
* @var array
2726
*/
28-
private $stockRegistry;
27+
private array $cumulativeQty = [];
2928

3029
/**
30+
* CartQuantityValidator Constructor
31+
*
3132
* @param CartItemRepositoryInterface $cartItemRepository
3233
* @param StockRegistryInterface $stockRegistry
34+
* @param Config $config
35+
* @param ProductRepositoryInterface $productRepository
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 ProductRepositoryInterface $productRepository,
43+
private readonly LoggerInterface $logger
3744
) {
38-
$this->cartItemRepository = $cartItemRepository;
39-
$this->stockRegistry = $stockRegistry;
4045
}
4146

4247
/**
@@ -45,32 +50,171 @@ public function __construct(
4550
* @param CartInterface $customerCart
4651
* @param CartInterface $guestCart
4752
* @return bool
53+
* @throws NoSuchEntityException
4854
*/
4955
public function validateFinalCartQuantities(CartInterface $customerCart, CartInterface $guestCart): bool
5056
{
5157
$modified = false;
52-
/** @var CartItemInterface $guestCartItem */
58+
$this->cumulativeQty = [];
59+
5360
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-
}
61+
foreach ($customerCart->getAllVisibleItems() as $customerCartItem) {
62+
if (!$customerCartItem->compare($guestCartItem)) {
63+
continue;
64+
}
65+
66+
if ($this->config->getCartMergePreference() === Config::CART_PREFERENCE_CUSTOMER) {
67+
$this->safeDeleteCartItem((int) $guestCart->getId(), (int) $guestCartItem->getItemId());
68+
$modified = true;
69+
continue;
70+
}
71+
72+
$sku = $this->getSkuFromItem($customerCartItem);
73+
$product = $this->getProduct((int) $customerCartItem->getProduct()->getId());
74+
$isAvailable = $customerCartItem->getChildren()
75+
? $this->isCompositeProductQtyValid($guestCartItem, $customerCartItem)
76+
: $this->isProductQtyValid($product, $sku, $guestCartItem->getQty(), $customerCartItem->getQty());
77+
78+
if ($this->config->getCartMergePreference() === Config::CART_PREFERENCE_GUEST) {
79+
$this->safeDeleteCartItem((int) $customerCart->getId(), (int) $customerCartItem->getItemId());
80+
$modified = true;
81+
}
82+
83+
if (!$isAvailable) {
84+
$this->safeDeleteCartItem((int) $guestCart->getId(), (int) $guestCartItem->getItemId());
85+
$modified = true;
7186
}
7287
}
7388
}
89+
7490
return $modified;
7591
}
92+
93+
/**
94+
* Get SKU from Cart Item
95+
*
96+
* @param CartItemInterface $item
97+
* @return string
98+
* @throws NoSuchEntityException
99+
*/
100+
private function getSkuFromItem(CartItemInterface $item): string
101+
{
102+
return $item->getProduct()->getOptions()
103+
? $this->getProduct((int) $item->getProduct()->getId())->getSku()
104+
: $item->getProduct()->getSku();
105+
}
106+
107+
/**
108+
* Get current cart item quantity based on merge preference
109+
*
110+
* @param float $guestQty
111+
* @param float $customerQty
112+
* @return float
113+
*/
114+
private function getCurrentCartItemQty(float $guestQty, float $customerQty): float
115+
{
116+
return match ($this->config->getCartMergePreference()) {
117+
Config::CART_PREFERENCE_CUSTOMER => $customerQty,
118+
Config::CART_PREFERENCE_GUEST => $guestQty,
119+
default => $guestQty + $customerQty
120+
};
121+
}
122+
123+
/**
124+
* Validate product stock availability
125+
*
126+
* @param ProductInterface $product
127+
* @param string $sku
128+
* @param float $guestQty
129+
* @param float $customerQty
130+
* @return bool
131+
*/
132+
private function isProductQtyValid(
133+
ProductInterface $product,
134+
string $sku,
135+
float $guestQty,
136+
float $customerQty
137+
): bool {
138+
$salableQty = $this->stockRegistry->getStockStatus(
139+
$product->getId(),
140+
$product->getStore()->getWebsiteId()
141+
)->getQty();
142+
143+
$this->cumulativeQty[$sku] ??= 0;
144+
$this->cumulativeQty[$sku] += $this->getCurrentCartItemQty($guestQty, $customerQty);
145+
146+
return $salableQty >= $this->cumulativeQty[$sku];
147+
}
148+
149+
/**
150+
* Validate composite product quantities
151+
*
152+
* @param Item $guestCartItem
153+
* @param Item $customerCartItem
154+
* @return bool
155+
* @throws NoSuchEntityException
156+
*/
157+
private function isCompositeProductQtyValid(
158+
Item $guestCartItem,
159+
Item $customerCartItem
160+
): bool {
161+
$guestChildItems = $this->retrieveChildItems($guestCartItem);
162+
foreach ($customerCartItem->getChildren() as $customerChildItem) {
163+
$childProduct = $customerChildItem->getProduct()->getOptions()
164+
? $this->getProduct((int) $customerChildItem->getProduct()->getId())
165+
: $customerChildItem->getProduct();
166+
$sku = $childProduct->getSku();
167+
$customerItemQty = $customerCartItem->getQty() * $customerChildItem->getQty();
168+
$guestItemQty = $guestCartItem->getQty() * $guestChildItems[$sku]->getQty();
169+
170+
if (!$this->isProductQtyValid($childProduct, $sku, $guestItemQty, $customerItemQty)) {
171+
return false;
172+
}
173+
}
174+
175+
return true;
176+
}
177+
178+
/**
179+
* Get product by ID
180+
*
181+
* @param int $productId
182+
* @return ProductInterface
183+
* @throws NoSuchEntityException
184+
*/
185+
private function getProduct(int $productId): ProductInterface
186+
{
187+
return $this->productRepository->getById($productId);
188+
}
189+
190+
/**
191+
* Retrieve child items from a quote item
192+
*
193+
* @param Item $quoteItem
194+
* @return Item[]
195+
*/
196+
private function retrieveChildItems(Item $quoteItem): array
197+
{
198+
$childItems = [];
199+
foreach ($quoteItem->getChildren() as $childItem) {
200+
$childItems[$childItem->getProduct()->getSku()] = $childItem;
201+
}
202+
return $childItems;
203+
}
204+
205+
/**
206+
* Safely delete a cart item by ID
207+
*
208+
* @param int $cartId
209+
* @param int $itemId
210+
* @return void
211+
*/
212+
private function safeDeleteCartItem(int $cartId, int $itemId): void
213+
{
214+
try {
215+
$this->cartItemRepository->deleteById($cartId, $itemId);
216+
} catch (NoSuchEntityException|CouldNotSaveException $e) {
217+
$this->logger->error($e);
218+
}
219+
}
76220
}

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)