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:
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#
- Load the localization and language from the order or document, not from the request.
- Wrap the render in
overrideForDocument()(document) orrunWith()(e-mail). -
With
override…()restore the state infinally: -
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#
- Put a mask on the field (
Form::PATTERN), so the user sees non-numeric input as an error. - Convert the value with
Decimal::fromInput(), not your ownstr_replace— copies drift apart and the same input then ends up differently in each form. - Decide the default yourself:
?? '1'for a quantity,?? '0'for a price. - 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 warehouseDocumentService).
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