Guide 08 · Developer docs

Commerce

A Core dna store is a set of records and an API that moves a purchase from cart to order: products described in every language and priced in every currency the store sells in, a cart, a checkout that becomes an order, invoices, payments, tax and shipping. The site builds the storefront on top: the cart and checkout pages, their scripts, and the customer emails.

L2–L3
Unit 01 · Products, prices and catalogue pages

Prices and currencies

Every product and variant has one price row per currency, and the price a shopper pays is the lower of its base and special price. What the rows hold, how the cart picks a currency, and how to print a price correctly in either engine.

Who this is for. The theme developer printing prices on product cards and pages, and the commerce implementer deciding how a store's prices and currencies are set up. Afterwards you can render a price that matches what the cart will charge, in the right currency.

A price row per currency

Prices are not fields on the product. Each product, and each variant, has a list of price rows, one per currency, and each row holds:

FieldRequiredUsed for
Base price (basePrice)Yes, zero or moreThe normal price
Special price (specialPrice)NoA sale price, charged when it is lower than the base price
RRP (rrp)NoDisplay only, for a "was" or "RRP" label
Cost price (costPrice)NoReporting only

A product must have at least one row, and a second row in the same currency is refused with "A pricing for this currency already exists". The cart charges from the base and special prices only; the RRP and cost price are never used in a calculation.

A variant has its own rows and does not fall back to its product's. A variant with no row in a currency has no price in it, even when its product has one. Price every variant.

A price that depends on who buys, or on how many, is a discount on these rows: a trade price is a discount for a customer group, and a price for buying ten is a discount whose product condition sets a minimum quantity. See Group pricing and approval before ordering.

The price a shopper pays

getFinalPrice() returns the price the cart will charge before discounts and tax: the base price, or the special price when one is set and it is lower. It takes the currency to price in, uses the default currency when given none, and returns the price as a decimal string with four places, such as "10.0000", or null when the record has no row in that currency. The cart calls the same method for every item it prices, so a product page that prints getFinalPrice() shows what the cart will charge.

The API exposes the same value as finalPrice. On this documentation site's test product, the product has a sale and its variant does not:

A product's price row, its final price, and its variant's final price
GET /cdna-api/products/product/1?properties={code,finalPrice,pricings{basePrice,specialPrice,rrp,currency{shortName}},variants{code,finalPrice}} HTTP/1.1
Host: hub.coredna.com
Authorization: Bearer YOUR_ACCESS_TOKEN
Accept: application/json
Response 200 OK
{
  "result": "success",
  "data": {
    "code": "API-SANDBOX-MUG",
    "finalPrice": "10.0000",
    "pricings": [
      {
        "basePrice": "12.0000",
        "specialPrice": "10.0000",
        "rrp": null,
        "currency": {"shortName": "USD"}
      }
    ],
    "variants": [
      {"code": "API-SANDBOX-MUG-BLUE", "finalPrice": "14.0000"}
    ]
  }
}
Real response from this documentation site, on its API sandbox product
A special price of zero makes the product free

An empty special price is ignored, but a special price of 0 is a price: getFinalPrice() then returns "0.0000", and the cart charges nothing for the item. To end a sale, clear the field rather than typing 0. A base price of 0 is allowed too, and is equally charged as nothing.

Discounts never change getFinalPrice(). They are worked out in the cart and appear there as a discount on each line, so a product page shows the undiscounted price unless the template works the discount out itself.

Currencies

Three settings in the Products module decide which currencies prices can be in:

  • Default currency (Products.defaultCurrency, US Dollar until changed). The currency a product is priced in first, and the one getFinalPrice() uses when given none. Set it before anyone enters a price.
  • Supported pricing currencies (Products.supportedCurrencies). Further currencies prices may be given in; the default is always included.
  • Use multi currency for products (Products.multiCurrency, off). Shows a price row per currency in the product editor. It changes only the form: switching it off removes no prices.

The visitor does not choose a currency, and nor does the page: the cart does. A cart keeps its currency once it has one, and an empty cart takes it from the first item added, in this order:

  1. The currency sent with that request, a three-letter code such as EUR. A malformed code is answered with HTTP 400.
  2. Without one, the currency of the item's default price row, the row getDefaultPricing() returns.
  3. When the item has no default price row, the default currency.

Steps 2 and 3 both resolve to the store's default currency, whichever item is added, so the only way to lock a cart to a different currency is to send it: send the shopper's own currency with the first add. Once the cart has a currency, the field no longer changes it. Every later item must have a price row in that currency, or it is refused with "Item could not be added to cart as it has no price in the selected currency". The currency is cleared only when the cart is emptied, so a currency switcher has to empty the cart (or let the shopper keep their first choice) and send the new currency with the next add. Changing the cart has the requests.

Printing a price

Format each price with its currency's own decimal places, symbol and symbol position (left or right). Find the currency once, above any loop, then format every price from it:

Template code
{* once, above the loop: the default currency *}
{$currency = $_query->findOneBy('Common\Entities\Currency', ['id' => $_config->get('Products.defaultCurrency')])}

{* per product or variant *}
{$price = $product->getFinalPrice($currency)}
{if is_null($price)}
  <span class="price price--none">Price on request</span>
{else}
  {$amount = number_format($price, $currency->getDecimalPlaces())}
  <span class="price">{if $currency->getSymbolLocation() == 'right'}{$amount}&nbsp;{$currency->getSymbol()}{else}{$currency->getSymbol()}{$amount}{/if}</span>
{/if}
{# once, above the loop: the default currency #}
{% set currency = _query.findOneBy('Common\\Entities\\Currency', {'id': _config.get('Products.defaultCurrency')}) %}

{# per product or variant #}
{% set price = product.getFinalPrice(currency) %}
{% if price is null %}
  <span class="price price--none">Price on request</span>
{% else %}
  {% set amount = price|number_format(currency.getDecimalPlaces()) %}
  <span class="price">{% if currency.getSymbolLocation() == 'right' %}{{ amount }}&nbsp;{{ currency.getSymbol() }}{% else %}{{ currency.getSymbol() }}{{ amount }}{% endif %}</span>
{% endif %}

On a multi-currency site, price in the currency the shopper's cart uses. Do not read it from $_cart on a page the page cache can store: the cart belongs to one visitor, and reading it saves a cart record for every visitor that has none. Print the default-currency price in the cached page and replace it in the browser from the cart's currency, as The cart count on a cached page does for the count.

Amounts on carts, checkouts and orders are different: they have …AsMoney() getters, such as getTotalAsMoney(), that return a money value which prints formatted with its currency, for example $10.00.

To print the currency itself on a cart, checkout or order page, read it from the record. A cart always has a currency (the default one until the first item is added), so {$_cart->getCart()->currency->shortName} in Smarty or {{ _cart.getCart().currency.shortName }} in Twig prints its code, such as USD; on an order, {$order->currency->shortName} or {{ order.currency.shortName }}. This is the usual split on a multi-currency store: cached product cards and related-product blocks print the default-currency price, and the visitor's own cart, checkout and orders print the currency of their record.

Prices and tax

Whether the stored prices include tax is one setting, Pricing includes tax (Taxes.pricingIncludesTax), which tells the tax provider how to read them; tax itself is worked out at checkout from the shopper's address. Decide once, per store, whether catalogue prices are shown and stored with tax or without, and label them that way ("inc. GST", "ex. VAT"). Prices including tax explains what the setting changes and what to test before turning it on.

Go deeper

Was this page helpful?

Your answer goes to the docs team.