Preconditions and environment
- Magento Open Source 2.4.9,
magento/module-quote 101.2.9, PHP 8.4. Also reproduced on 2.4.7-p10.
- Vanilla install, sample data, no third-party extensions.
- Any simple product that has custom options. Sample product
24-WB04 works.
Steps to reproduce
A quote item that carries any quote-item option beyond info_buyRequest / option_ids / option_N is deleted and re-created on an ordinary CartRepositoryInterface::save(), losing custom_price. Extensions that store per-item data (a comment, a lock, a reference id) attach such options routinely.
- Create a cart containing a simple product with custom options.
- Set
custom_price and original_custom_price on the item.
- Attach one additional quote-item option to it. The code and the value do not matter.
- In a separate request, do what every checkout does: load the cart with
CartRepositoryInterface::getActive(), call CartItemRepositoryInterface::getList() (the order summary lists the items), then call CartRepositoryInterface::save() (assigning a billing address or setting a payment method does this).
- Inspect the quote item row.
The separate request matters: QuoteRepository caches quotes by id, so a quote you just saved comes back from getActive() without LoadHandler running. getItems() is then null and SaveHandler skips CartItemPersister entirely, so a single-pass test appears to pass and proves nothing.
Script, run from the Magento root as php repro.php <product_id> <option_id>=<value> ...:
<?php
use Magento\Framework\App\Bootstrap;
require __DIR__ . '/app/bootstrap.php';
$om = Bootstrap::create(BP, $_SERVER)->getObjectManager();
$om->get(\Magento\Framework\App\State::class)->setAreaCode('frontend');
$productRepo = $om->get(\Magento\Catalog\Api\ProductRepositoryInterface::class);
$cartRepo = $om->get(\Magento\Quote\Api\CartRepositoryInterface::class);
$itemRepo = $om->get(\Magento\Quote\Api\CartItemRepositoryInterface::class);
$quoteFactory = $om->get(\Magento\Quote\Model\QuoteFactory::class);
$res = $om->get(\Magento\Framework\App\ResourceConnection::class);
$conn = $res->getConnection();
$productId = (int)$argv[1];
$options = [];
foreach (array_slice($argv, 2) as $s) { [$k, $v] = explode('=', $s, 2); $options[$k] = $v; }
foreach ([false, true] as $withExtraOption) {
$product = $productRepo->getById($productId, false, 1);
$product->setSkipCheckRequiredOption(true);
$quote = $quoteFactory->create();
$quote->setStoreId(1)->setIsActive(true)->setIsMultiShipping(false)
->setCustomerIsGuest(true)->setCheckoutMethod('guest')
->setCustomerEmail('repro@example.com');
$item = $quote->addProduct($product, new \Magento\Framework\DataObject([
'qty' => 1, 'options' => $options, 'custom_price' => 8.00,
]));
$item->setCustomPrice(8.00)->setOriginalCustomPrice(8.00);
if ($withExtraOption) {
$item->addOption([
'product' => $item->getProduct(),
'code' => 'some_extension_option',
'value' => '1',
'product_id' => $item->getProduct()->getId(),
]);
}
$quote->setTotalsCollectedFlag(false)->collectTotals();
$cartRepo->save($quote);
$quoteId = (int)$quote->getId();
$itemIdBefore = $item->getId();
// a later request
$loaded = $cartRepo->getActive($quoteId);
$itemRepo->getList($quoteId);
$cartRepo->save($loaded);
$row = $conn->fetchRow("SELECT item_id, custom_price, row_total FROM "
. $res->getTableName('quote_item') . " WHERE quote_id = ?", [$quoteId]);
$buyRequest = $conn->fetchOne("SELECT value FROM "
. $res->getTableName('quote_item_option')
. " WHERE item_id = ? AND code = 'info_buyRequest'", [$row['item_id']]);
printf("%-22s item %s -> %s %-8s | custom_price %-9s | row_total %s\n %s\n",
$withExtraOption ? 'WITH extra option' : 'WITHOUT extra option',
$itemIdBefore, $row['item_id'],
$itemIdBefore === $row['item_id'] ? 'kept' : 'REPLACED',
var_export($row['custom_price'], true), $row['row_total'], $buyRequest);
$conn->delete($res->getTableName('quote'), ['entity_id = ?' => $quoteId]);
}
Expected result
The quote item row is untouched. A save that was not asked to change the item does not change it, and custom_price is preserved.
Actual result
The item is deleted and a new row inserted, without custom_price, and the item silently re-prices to the catalog price. info_buyRequest is rebuilt from the custom options alone, so everything else it carried is gone and reset_count: true appears.
WITHOUT extra option item 238 -> 238 kept | custom_price '8.0000' | row_total 8.0000
{"qty":1,"options":{"29":"89","30":"91"},"custom_price":8}
WITH extra option item 239 -> 240 REPLACED | custom_price NULL | row_total 55.0000
{"options":{"29":"89","30":"91"},"qty":1,"reset_count":true}
No exception, no message, no log entry. On a live store this surfaced as a customer being charged the full catalog price instead of an agreed price, on an order that was otherwise placed and captured normally.
Additional information
The chain, all in magento/module-quote unless noted:
Quote\Item\Repository::getList() line 103 calls cartItemOptionsProcessor->addProductOptions() on the live quote-item objects rather than on copies. Because QuoteRepository caches quotes by id, these are the same objects the checkout session holds, so a read mutates session state.
QuoteRepository\SaveHandler::save() then reaches Quote\Item\CartItemPersister::save() line 79, which calls Quote::updateItem() for any item whose buy request is an object.
Magento\Catalog\Model\CustomOptions\CustomOptionProcessor::convertToBuyRequest() builds that buy request from the custom options only, dropping everything else the original carried.
Quote::updateItem() line 1811 sets resetCount at line 1833 and re-adds the product. When the rebuilt item does not compare equal to the existing one, the old item is removed and a new one inserted. custom_price is a column on the removed row.
Whether step 4 destroys the item comes down to Quote\Item::$_notRepresentOptions, line 146, which is ['info_buyRequest']. That is the only option code excluded from the comparison, so every other quote-item option has to be present and equal on both sides. A plain addProduct() rebuild cannot recreate an option that was attached programmatically, so the comparison always fails and the item is always replaced.
The guard that used to prevent this was if ($currentItem->getQty() !== $buyRequestData->getQty()) in CartItemPersister::save(), which meant updateItem() ran only when the quantity had actually changed. It was removed by ACSD-46869 ("Configurable products not updating using REST API at checkout"), shipped in 2.4.6. That patch addressed an explicit PUT /V1/carts/mine/items/{itemId} whose purpose is to reconfigure an item. After the change the same path runs on every CartRepositoryInterface::save(), including saves that have nothing to do with items, and for any product type rather than configurables only, since CustomOptionProcessor is registered under the generic custom_options key.
#41039 reports the same regression with a different symptom (bundle quantities doubling) and was closed in August as not reproducible through the UI. It is hard to reproduce by clicking, because it needs a save to land after a getList() within one request. Whether that happens depends on what is still unsaved at that moment: a first pass through checkout triggers it, a repeat on a cart that already has an address and a shipping method does not. The script above avoids that entirely.
Suggested fix: restoring the old quantity comparison would reintroduce the ACSD-46869 bug. Comparing the rebuilt buy request against the item's current configuration and calling updateItem() only when they genuinely differ satisfies both: the REST case still updates because the options changed, and an unrelated save leaves the item alone. Excluding item options that a rebuild cannot reproduce from compareOptions(), or re-applying custom_price and original_custom_price after a replacement, would each narrow the damage but leave the underlying "every save is an implicit reconfigure" behavior in place.
Preconditions and environment
magento/module-quote101.2.9, PHP 8.4. Also reproduced on 2.4.7-p10.24-WB04works.Steps to reproduce
A quote item that carries any quote-item option beyond
info_buyRequest/option_ids/option_Nis deleted and re-created on an ordinaryCartRepositoryInterface::save(), losingcustom_price. Extensions that store per-item data (a comment, a lock, a reference id) attach such options routinely.custom_priceandoriginal_custom_priceon the item.CartRepositoryInterface::getActive(), callCartItemRepositoryInterface::getList()(the order summary lists the items), then callCartRepositoryInterface::save()(assigning a billing address or setting a payment method does this).The separate request matters:
QuoteRepositorycaches quotes by id, so a quote you just saved comes back fromgetActive()withoutLoadHandlerrunning.getItems()is then null andSaveHandlerskipsCartItemPersisterentirely, so a single-pass test appears to pass and proves nothing.Script, run from the Magento root as
php repro.php <product_id> <option_id>=<value> ...:Expected result
The quote item row is untouched. A save that was not asked to change the item does not change it, and
custom_priceis preserved.Actual result
The item is deleted and a new row inserted, without
custom_price, and the item silently re-prices to the catalog price.info_buyRequestis rebuilt from the custom options alone, so everything else it carried is gone andreset_count: trueappears.No exception, no message, no log entry. On a live store this surfaced as a customer being charged the full catalog price instead of an agreed price, on an order that was otherwise placed and captured normally.
Additional information
The chain, all in
magento/module-quoteunless noted:Quote\Item\Repository::getList()line 103 callscartItemOptionsProcessor->addProductOptions()on the live quote-item objects rather than on copies. BecauseQuoteRepositorycaches quotes by id, these are the same objects the checkout session holds, so a read mutates session state.QuoteRepository\SaveHandler::save()then reachesQuote\Item\CartItemPersister::save()line 79, which callsQuote::updateItem()for any item whose buy request is an object.Magento\Catalog\Model\CustomOptions\CustomOptionProcessor::convertToBuyRequest()builds that buy request from the custom options only, dropping everything else the original carried.Quote::updateItem()line 1811 setsresetCountat line 1833 and re-adds the product. When the rebuilt item does not compare equal to the existing one, the old item is removed and a new one inserted.custom_priceis a column on the removed row.Whether step 4 destroys the item comes down to
Quote\Item::$_notRepresentOptions, line 146, which is['info_buyRequest']. That is the only option code excluded from the comparison, so every other quote-item option has to be present and equal on both sides. A plainaddProduct()rebuild cannot recreate an option that was attached programmatically, so the comparison always fails and the item is always replaced.The guard that used to prevent this was
if ($currentItem->getQty() !== $buyRequestData->getQty())inCartItemPersister::save(), which meantupdateItem()ran only when the quantity had actually changed. It was removed by ACSD-46869 ("Configurable products not updating using REST API at checkout"), shipped in 2.4.6. That patch addressed an explicitPUT /V1/carts/mine/items/{itemId}whose purpose is to reconfigure an item. After the change the same path runs on everyCartRepositoryInterface::save(), including saves that have nothing to do with items, and for any product type rather than configurables only, sinceCustomOptionProcessoris registered under the genericcustom_optionskey.#41039 reports the same regression with a different symptom (bundle quantities doubling) and was closed in August as not reproducible through the UI. It is hard to reproduce by clicking, because it needs a save to land after a
getList()within one request. Whether that happens depends on what is still unsaved at that moment: a first pass through checkout triggers it, a repeat on a cart that already has an address and a shipping method does not. The script above avoids that entirely.Suggested fix: restoring the old quantity comparison would reintroduce the ACSD-46869 bug. Comparing the rebuilt buy request against the item's current configuration and calling
updateItem()only when they genuinely differ satisfies both: the REST case still updates because the options changed, and an unrelated save leaves the item alone. Excluding item options that a rebuild cannot reproduce fromcompareOptions(), or re-applyingcustom_priceandoriginal_custom_priceafter a replacement, would each narrow the damage but leave the underlying "every save is an implicit reconfigure" behavior in place.