Skip to content

Commit cda86ed

Browse files
committed
LYNX-889 [AC-2.4.9] Merging guest and customer cart logic using Admin Configuration
1 parent 625b01e commit cda86ed

File tree

10 files changed

+1156
-40
lines changed

10 files changed

+1156
-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: 172 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,44 @@
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;
1720

1821
class CartQuantityValidator implements CartQuantityValidatorInterface
1922
{
2023
/**
21-
* @var CartItemRepositoryInterface
22-
*/
23-
private $cartItemRepository;
24-
25-
/**
26-
* @var StockRegistryInterface
24+
* @var array
2725
*/
28-
private $stockRegistry;
26+
private array $cumulativeQty = [];
2927

3028
/**
29+
* CartQuantityValidator Constructor
30+
*
3131
* @param CartItemRepositoryInterface $cartItemRepository
3232
* @param StockRegistryInterface $stockRegistry
33+
* @param Config $config
34+
* @param ProductRepositoryInterface $productRepository
3335
*/
3436
public function __construct(
35-
CartItemRepositoryInterface $cartItemRepository,
36-
StockRegistryInterface $stockRegistry
37+
private readonly CartItemRepositoryInterface $cartItemRepository,
38+
private readonly StockRegistryInterface $stockRegistry,
39+
private readonly Config $config,
40+
private readonly ProductRepositoryInterface $productRepository
3741
) {
38-
$this->cartItemRepository = $cartItemRepository;
39-
$this->stockRegistry = $stockRegistry;
4042
}
4143

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

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)