Skip to content

Cart API mixes base-currency and quote-currency values in one response #1238

Description

@hirale

Maho 26.9.0 (26.7.0-70-g17f27dbcd), REST v2 and GraphQL, MySQL 8.4, PHP 8.5.

On a store whose display currency is not the website base currency, some money fields in the cart response carry base-currency numbers and the fields next to them carry quote-currency numbers. There is one currency field for the whole response, so a client has no way to tell which is which.

Setup for everything below: base USD, store display currency EUR, rate 0.7067, one simple product priced 29.99 (base), qty 2.

Item price

GET /api/rest/v2/guest-carts/{maskedId}:

{
  "currency": "EUR",
  "items": [
    { "sku": "apitest-ms-both", "qty": 2, "price": 29.99, "priceInclTax": 21.19, "rowTotal": 42.38 }
  ],
  "prices": { "subtotal": 42.38, "baseSubtotal": 59.98, "grandTotal": 42.38, "baseGrandTotal": 59.98 }
}

price * qty is 59.98 while rowTotal is 42.38. price is USD: 29.99 is the stored base value (catalog_product_entity_decimal holds 29.9900, website base currency USD), and 29.99 * 0.7067 = 21.19 is the priceInclTax next to it, with 2 * 21.19 = 42.38 as rowTotal. Render a unit price from one field and a line total from the other and the cart shows two currencies under a label that says EUR.

Shipping

Same cart with a US shipping address and flat rate at 5.00 per item:

availableShippingMethods: [{ "code": "flatrate_flatrate", "price": 10 }]
selectedShippingMethod.price: 7.07
prices.shippingAmount: 7.07     prices.baseShippingAmount: 10

The same method costs 10 in the list and 7.07 once it is selected.

Applied gift cards

A card with a 50 balance issued on the USD website, applied to the EUR cart:

appliedGiftcards[0]: { "balance": 50, "appliedAmount": 50 }
prices.giftcardAmount: 35.335
prices.subtotal: 42.38    prices.grandTotal: 7.05

The cart total drops by 35.34, which is the 50 USD balance expressed in EUR, but both numbers on the gift card entry stay at 50.

Where the numbers come from

CartMapper.php:160 reads $item->getPrice(), and that accessor is documented as base currency in Mage/Sales/Model/Quote/Item/Abstract.php:634 ("Item price currency is website base currency"). The quote-currency values live in getConvertedPrice() (:660) and getCalculationPrice() (:481). rowTotal comes from calcRowTotal() (:463), which multiplies the converted price, and priceInclTax comes from the tax collector, which writes price_incl_tax and base_price_incl_tax as a pair (Mage/Tax/Model/Sales/Total/Quote/Subtotal.php:320-323). That leaves price as the only item field still in base.

For shipping, CartMapper.php:370 reads $rate->getPrice(). Mage/Sales/Model/Quote/Address/Total/Shipping.php:142-144 converts that same value into shipping_amount and keeps the raw one as the base amount, and selectedShippingMethod.price (CartMapper.php:402) reads the already-converted getShippingAmount().

The gift card entry pulls from two sources. balance comes from $giftcard->getBalance() with no currency argument (CartMapper.php:130), which returns the stored balance in the card's own currency, the website base currency (Maho/Giftcard/Model/Giftcard.php:134, :142-159). appliedAmount comes from the giftcard_codes column, and the total collector writes base amounts into it (Maho/Giftcard/Model/Total/Quote.php:106-122) while the converted amount goes to giftcard_amount (:111, :135), which is what prices.giftcardAmount reports (CartMapper.php:138-141). One side effect: CartService.php:630-634 snapshots the balance in quote currency when a card is applied, then the collector overwrites that snapshot with the base amount on the next collectTotals(), so the comment there no longer describes what ends up stored.

GraphQL returns the same numbers: the cart mutations build their payload from CartMapper::mapQuoteToCart() (Mage/Checkout/Api/GraphQL/CartMutationHandler.php:407), and CartProcessor does the same for REST writes (:155).

Reproducing

  1. Enable REST v2 (apiplatform/protocols/rest_v2 = 1).
  2. On the store, set currency/options/allow to USD,EUR and currency/options/default to EUR. The USD/EUR rate is seeded at install.
  3. POST /api/rest/v2/guest-carts, then POST /api/rest/v2/guest-carts/{maskedId}/items with {"sku": "<simple sku>", "qty": 2}.
  4. GET /api/rest/v2/guest-carts/{maskedId} and compare items[0].price * qty with items[0].rowTotal.
  5. For shipping, set a shipping address, read availableShippingMethods[].price, select that method, then compare with prices.shippingAmount.
  6. For the gift card, create an active card on the website, apply it, then compare appliedGiftcards[0] with prices.giftcardAmount.
  7. For the products endpoint, GET /api/rest/v2/products?sku=<sku>, then flip currency/options/default back to USD, flush the cache and repeat.

The products endpoint never converts either

GET /api/rest/v2/products?sku=apitest-ms-both, same product, same store, only the store's display currency changed between the two calls:

display EUR: "price": 29.99, "finalPrice": 29.99, "minimalPrice": 29.99, "currency": "EUR"
display USD: "price": 29.99, "finalPrice": 29.99, "currency": "USD"
storefront on the EUR store: 21.19

The number never moves, only the label does. 29.99 is USD: it is the stored base value (catalog_product_entity_decimal holds 29.9900 and catalog_product_index_price holds 29.9900 for website 1, whose base currency is USD), and 29.99 * 0.7067 = 21.19 is what the storefront shows for the same product on the EUR store.

The storefront converts before display: catalog/product/price.phtml:36 and :51 wrap getFinalPrice() and getPrice() in $_store->convertPrice(). The API does not. Product::afterLoad() sets currency from getCurrentCurrencyCode() (Mage/Catalog/Api/Product.php:508) while price, finalPrice and minimalPrice keep the raw attribute and index values. The only convertPrice() call in this resource is for downloadable link prices (Mage/Catalog/Api/ProductProvider.php:863).

The provider already partitions its cache per display currency (ProductProvider.php:138-139, :256), and the docblock on resolveCurrencyCode() reads "Display currency the cached prices are converted to" (:69). The cache is split per currency for values that are never converted.

One consequence to weigh before converting the output: price filters run against the index, which holds base values, so filters and output currently agree. On the EUR store, ?priceMin=25&priceMax=35 returns the 29.99 products (5 items) and ?priceMin=20&priceMax=22, the EUR value of the same products, returns 0. Converting the output alone would split those two apart.

Checked, not reporting these

A cart stays on the store it was created on (Mage/Checkout/Api/CartService.php:48/54) and Quote::getStore() resolves that store (Mage/Sales/Model/Quote.php:258-261), so a different ?store= or X-Store-Code on a GET does not reprice it. Repricing a live cart on read would be worse.

prices.base* is base currency by name.

There is also no way to ask for a currency per request. A non-base currency is reachable only through a store view whose default display currency differs, because an API request carries no session or cookie currency: Store::getCurrentCurrencyCode() (Mage/Core/Model/Store.php:710-728) falls back to currency/options/default, and Quote::_beforeSave() (Mage/Sales/Model/Quote.php:317-336) freezes it into quote_currency_code. That is a missing feature, not a defect. Quote::_beforeSave() already honours forced_currency (:320-321) if you ever want to expose it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions