Skip to content
V
For developers
Architecture, conventions, the core and security
Core / Prices, currencies and amount format

Prices, currencies and amount format#

How an amount gets from a number to text: in which currency, with which separators, with which unit and in which language. This chapter is for anyone printing a price on the website, in an e-mail or on a document, or reading a price from a user.

Website currency = payment region#

The catalogue, the cart and the order show prices in the currency of the payment region, not in the localization currency. DisplayRegionResolver::resolve() picks the region in this order:

Order Region
1 the region the customer chose in the order (checkout, cart session)
2 the localization region (/en/ → region "EU" → EUR)
3 the first usable default region (is_default)
4 the first usable region at all

Usable = an active region with an active currency that is not internal. A region with a disabled or internal currency (credits) would show prices that cannot be paid, so it is skipped.

The front end sets the region in CurrencyInitTrait::initializeDisplayRegion() (the catalogue always by the localization, without step 1); the cart and checkout may switch it by the customer's choice (CheckoutRegionTrait). The template gets the currency as $currency and the function pageCurrency().

The front end ignores ?curr= in the URL

The resolver is the only source of the currency. Anyone adding a currency switcher through a URL parameter changes nothing, and the customer keeps seeing prices in the region currency.

A region other than the localization one = a message for the customer

DisplayRegionResolver::differsFromLocalization() tells that the order is computed in a different region than the catalogue. The cart is then recalculated (another currency or VAT) and the customer must get a notice about it.

Amount format: localization before currency#

The priceFormat filter (PriceLatteExtension) builds the format in PriceFormat::resolve() from two sources, field by field: the localization (cms_system_localizations) wins in the fields it has filled in (null = "not handled here"), the currency is the permanent fallback. The same euro is "12,10 €" on /cs/ and "€12.10" on /en/.

Field What it controls
separator, decimal_separator, precision thousands, decimal comma/dot, number of places
currency_symbol_position a after the amount ("12 500 €"), b before it ("€12.10")
negative_symbol_position b minus before the number, a after it
show_suffix, suffix suffix of a whole amount (,-); credits have it off → "100 krd"

Space between symbol and number: always after the amount; before the amount only for a letter abbreviation, a sign (€, $) sticks to the number.

The ,- suffix and decimal places#

,- means "and zero cents", so it excludes printed decimals (PriceHelper::convertByLocalization()):

Amount Result
whole (50) decimals disappear, the suffix stays: 50,-
fractional (1234.56) decimals stay, the suffix disappears: 1 234,56
suffix off (currency, or \|priceFormat:$currency, '') always precision decimal places

Unit after the amount#

The amount is followed by the translated and pluralized currency label (cms_system_currency_texts.unit_label), otherwise by the currency abbreviation. The label is a Symfony interval pattern:

{0}credits|[1,1]credit|[2,Inf]credits

CurrencyUnitFormatter expands it through IdentityTranslator by the amount value; explicit intervals do not depend on the language. Without a translation for the language it returns null and the abbreviation is used.

Do not pass the pattern through the application translator

The Contributte translator has returnOriginalMessage and returns an unknown "key" unchanged, so the whole pattern {0}credits|[1,1]… would appear on the page.

The symbol format lives in two places

Front JS (formatPrice() in assets/js/components/i18n.js, its own copy in every front theme) builds the amount the same way as PriceFormat::attachSymbol(). If you change the rule only in PHP, or only in some themes, a price from PHP and a price computed in JS differ on the same page ("12,10 €" next to "€12,10").

Documents and e-mails: the customer's format, not the request's#

Neither a document nor an e-mail may be formatted by whoever generates it right now (an admin in Czech, a cron without a language). Otherwise an admin with a switched localization would regenerate a three-year-old invoice and its numbers would differ from the original PDF.

The document therefore stores its localization and language at creation, and the generator puts them into PriceFormatContext for the duration of the render. priceFormat, approxPrice and the date filters read from here first and only then the request localization. PDF templates live in the database and stay unchanged.

Method When Extra
overrideForDocument($localization, $languageId) document PDF (InvoicePdfGenerator) accounting document mode: fixed decimal places and no ,- suffix on any amount
runWith($localization, $languageId, $render) order e-mail (OrderEmailGenerator) after the render it restores the previous state (not reset()), so an outer override survives
override(…) + reset() others localization and language only

Document mode applies to all types (order, invoice, proforma, cancellation, credit note) — they share one generator. The suffix would mix up a column on a document: 1.000,- above 210,00.

DocumentLocale builds the texts and numbers of a document for the customer:

What By
texts (NEON, translator locale) the order/document language; without one the localization; otherwise cs_CZ
date short_date_format of the localization; empty = d.m.Y
number (quantity, fee) separators of the localization and currency; a whole number without decimals, a decimal without trailing zeros (Quantity, filter \|quantity)
missing translation Czech — a bare //document… key never reaches the document

Procedure: rendering a document or an e-mail for the customer#

  1. Load the localization and language from the order or document, not from the request.
  2. Wrap the render in overrideForDocument() (document) or runWith() (e-mail).
  3. With override…() restore the state in finally:

    $this->priceFormatContext->overrideForDocument($localization, $invoice->getLanguageId());
    try {
        $abs = $this->pdfGenerator->generate(...);
    } finally {
        $this->priceFormatContext->reset();
    }
    
  4. Format through PriceLatteExtension (priceFormat), not through your own helper — only the extension knows the override and the document mode.

Without finally the format leaks into the next document

The service is one per DI container. An exception in the middle of a render leaves the override set, and bulk generation formats the second and later documents by the first one. On the web the container ends with the request; in cron and CLI it lives on.

Approximate conversion: approxPrice#

ApproximatePrice converts an amount by the manual rate cms_system_currencies.exchange_rate ("100 000 Kč (≈ €4,100)"). It is only an indicative addition: no price is stored or filtered by it.

The rate means foreign currency per 1 CZK (CZK = 1, EUR = 0.041) — the same as in Store and the SMS gateway. From currency A to B it is amount / rateA × rateB. When printed, the result is rounded to whole units from 10 units up (WHOLE_UNITS_FROM), to hundredths below.

The conversion returns null (and the filter an empty string) when:

  • the amount is missing or not positive,
  • it is the same currency,
  • either side is not a monetary currency (monetary = 0, credits); an internal monetary currency is converted,
  • the rate is missing or zero.

An inverted rate inverts every converted price

Entering the rate as "CZK per 1 €" (24.4 instead of 0.041) makes every converted price roughly six hundred times larger. Nothing fails and nothing is logged; the error shows up only to the visitor looking at the price.

A number from the user: Decimal#

Decimal::fromInput() is the single place that says what a "number from a user" looks like: decimal comma or dot, thousand separators including non-breaking spaces (U+00A0, U+202F). It returns a numeric-string in dot form, or null.

Input Result
1 234,5 '1234.5'
'', only spaces null
1 000 Kč null

Procedure: adding a decimal field to a form#

  1. Put a mask on the field (Form::PATTERN), so the user sees non-numeric input as an error.
  2. Convert the value with Decimal::fromInput(), not your own str_replace — copies drift apart and the same input then ends up differently in each form.
  3. Decide the default yourself: ?? '1' for a quantity, ?? '0' for a price.
  4. Do not round in the helper. The number of places is a property of the column (decimal(10,3) for a price, decimal(8,2) for an invoicing document quantity, decimal(10,3) for a stock quantity), not of the input. Rounding to the column, half-up (away from zero), is done by whoever writes it (ColumnValue, the warehouse DocumentService).

null is not zero

If null were turned into 0, the difference between an empty and a zero field would disappear — and without the mask non-numeric input would silently be saved as the default.

Where to look#

I want Where
website region and currency selection app/Core/Utils/Helpers/DisplayRegionResolver.php, app/Core/Traits/Front/Presenters/CurrencyInitTrait.php
format resolution app/Core/Utils/ValueObjects/PriceFormat.php
suffix and decimal places app/Core/Utils/Helpers/PriceHelper.php
the priceFormat filter app/Core/Latte/Extensions/PriceLatteExtension.php
unit after the amount app/Core/Utils/Helpers/CurrencyUnitFormatter.php
document and e-mail format app/Core/Utils/Contexts/PriceFormatContext.php, app/Core/Utils/Helpers/DocumentLocale.php
approximate conversion app/Core/Utils/Helpers/ApproximatePrice.php, app/Core/Latte/Extensions/ApproxPriceLatteExtension.php
a number from the user app/Core/Utils/Helpers/Decimal.php

Related chapters: Latte templates · E-shop · Invoicing · Sending e-mails · Forms

Cookie settings