Money and currency¶
Amounts are stored as whole numbers of minor units (pence, cents): Product.price,
Price.total, delivery prices, gift card balances, refunds. A shop sells in one currency, its
siteCurrencyCode setting (payment-config, default gbp). That one setting decides both the
amount a shopper is charged and how every price on the site is written. A second setting,
currency-symbol-style, decides how the symbol is written (below).
Shopper-facing amounts are formatted from the shop's currency and the shopper's locale together:
| Currency | Locale | 123456 minor units |
|---|---|---|
| GBP | en_GB | £1,234.56 |
| USD | en_US | $1,234.56 |
| USD | en_GB | US$1,234.56 |
| EUR | de_DE | 1.234,56 € |
| JPY | en_GB | ¥123,456 (no minor unit) |
The locale only changes how the amount is written (separators, where the symbol goes). It never changes the currency.
The symbol¶
currency-symbol-style chooses how the symbol is written. The values match the browser's
Intl.NumberFormat currencyDisplay, which the settings preview mirrors:
| Style | US dollars in a UK-language shop | Use it when |
|---|---|---|
symbol (default) |
US$12.50 | The language's own symbol, which adds the country when the sign alone could mean another currency. |
narrow |
$12.50 | The shop sells in one currency, so $ can't be misread. |
code |
USD 12.50 | International or business buyers. |
For pounds in a UK shop, or dollars in a US-language shop, symbol and narrow look the same.
Shop owners set both under Settings > Checkout > Payments: the currency is a searchable list
of every ISO currency (common ones first) with a live preview of how prices will look, formatted
by the server in the shop's locale (GET /admin/api/config/currency-preview?currency=&style=), and
a warning while the choice differs from the saved one, because changing currency converts
nothing. The symbol style is a choice of three cards, each showing a price written that way.
In templates: #shopMoney¶
Every Thymeleaf engine (storefront, admin, emails) has a #shopMoney expression object.
| Call | Result | Use |
|---|---|---|
#shopMoney.format(order.price.total) |
£12.50 |
Any price a person reads. |
#shopMoney.format(refund.amount, refund.currency) |
€12.50 |
An amount in a currency of its own (an order's, a refund's, a Stripe price's). |
#shopMoney.prefix() + #shopMoney.amount(price) + #shopMoney.suffix() |
£ + 12.50 + `` |
Markup that styles the symbol apart from the number, or whose number a script rewrites. One of prefix and suffix is empty, so 12,50 € comes out right too. |
#shopMoney.symbol() |
£ |
The symbol alone, for a label beside an amount input (£ [ 25 ]). Never in front of a written amount. |
#shopMoney.code() |
GBP |
itemprop="priceCurrency", payment and analytics payloads. |
#shopMoney.decimal(product.price) |
12.50 |
Machine-readable values: itemprop="price", analytics. Always a point, never grouped. |
#shopMoney.fractionDigits() |
2 |
Scripts that turn minor units into an amount. |
#shopMoney.wholeSuffix() |
.00 |
After a whole-amount input ("£ [25] .00"): ,00 in German, nothing for yen. Pair it with th:if="${#shopMoney.fractionDigits() > 0}". |
<span th:text="${#shopMoney.format(lineItem.price.total)}">£12.50</span>
<span itemprop="priceCurrency" th:content="${#shopMoney.code()}"
th:text="${#shopMoney.prefix()}">£</span><span id="itemPrice" itemprop="price"
th:content="${#shopMoney.decimal(product.price)}"
th:text="${#shopMoney.amount(product.price)}">12.50</span><span
th:text="${#shopMoney.suffix()}"></span>
<span th:utext="#{basket.each(${#shopMoney.format(lineItem.itemPrice)})}">£12.99 each</span>
A null amount formats as nothing. An amount may be any number of minor units (Integer, Long,
or a Double such as amount * 0.25).
Prefer format where nothing needs the parts apart. format is always prefix + amount +
suffix with any minus sign in front (-£5.00, -5,00 €), so split and whole amounts agree. A
message that shows an amount takes it as one argument; older messages that take the symbol and
the number separately get prefix() and amount + suffix(). For the same reason, a message that shows an amount
takes one formatted argument rather than the symbol and the number as two.
Don't format money with the currency / currency.ISO messages or with
#numbers.formatDecimal(x/100d, …), and don't write a symbol into the markup (£<span …>,
<span>£</span>). The messages followed the locale rather than the shop's setting,
formatDecimal assumes two decimals and UK separators, and a written symbol is only right for a
shop that sells in pounds. TemplatesUseShopMoneyTest fails the build on any of them (Gift Aid
templates may write £: Gift Aid is always in pounds). Form inputs that post an amount back (th:value with 'NONE'
grouping) are not display text and are left as they are.
Admin templates can also use #bx.money(amount, currency) and #bx.moneyMinor. With no
currency they now format in the shop's currency rather than GBP. Gift Aid amounts are always
pounds, so Gift Aid screens pass 'GBP' explicitly.
The basket's repricing placeholders (.currency-symbol) draw the symbol with CSS from
data-currency (::before, set from prefix()) and data-currency-after (::after, from
suffix()); don't add per-symbol CSS rules.
Where the currency comes from¶
Templates never look the setting up while they render. ShopCurrencyInterceptor works out the
site's currency once per request, before the controller runs, and leaves it on the request as
shopCurrency, with the symbol style as shopCurrencySymbol. #shopMoney and #bx read them. Emails are rendered on any node, so
EmailTemplateRenderer sets shopCurrency (and the style, shopCurrencySymbol) itself: the order's own currency when the email has
an order, otherwise the shop's. A render with no shopCurrency (a test, a background job)
reads the current site's setting once for the template, and failing that uses GBP.
In Java¶
ShopMoney.of(currencyCode, locale[, style])(incommon) is the formatter behind#shopMoney. It isn't thread-safe; make one per call or render, which is cheap. Where the locale puts the symbol is worked out once per locale and cached.ShopCurrencyService.currencyCode(siteContext)is the site's ISO code, upper case, andsymbolStyle(siteContext)its symbol style;money(siteContext, locale)gives a formatter with both. Use it rather than readingsiteCurrencyCodeyourself: it normalises the case and falls back to GBP when the setting isn't a currency code.MoneyDisplay.format(minor, currency, siteContext, config)formats in the current locale and the site's symbol style, for amounts written into emails and page text from Java.- Don't build amounts with
String.format("£%.2f", pence / 100.0): it fixes the symbol and two decimals. Machine values (Open Graphproduct:price:amount, feeds) useShopMoney.decimal.
In scripts¶
Every theme's head carries the shop's currency:
Theme scripts that write prices (basket, checkout, payment, variant picker, search bar) read it
with a small shopCurrency() / shopAmount(minor) pair instead of (x / 100).toFixed(2), so a
currency with no minor unit, or a locale that writes 1.234,56, shows the same in a script as on
the server. A page without the tag (the admin) falls back to GBP and two decimals.
Orders keep their currency¶
The pricing pipeline (ItemPricer, OrderPricer) prices a basket in the site's current
currency. Once an order is submitted it keeps the currency it was charged in, even if the
shop's setting changes later and the order is priced again.
Gift cards¶
A gift card holds the currency of the order that bought it: top-ups, virtual cards and cards
activated on the confirmation page all take order.price.currencyCode.
Selling in more than one currency¶
A site sells in one currency. To sell in two, run a site per currency (for example a UK site in
pounds and a US site in dollars, each with its own locale), and link each to the other for
shoppers. The platform doesn't yet write hreflang alternates between such sites. Price lists,
several currencies on one site, are planned.
Tax¶
A shop's tax-mode (Settings > Checkout > Tax) is off by default, which records no tax. With
inclusive, shelf prices include VAT, and once an order is priced TaxService works out how much
of each line and of delivery is VAT, for the country the order is taxed in:
- Where: the collection store's country for click and collect, else the delivery address's
country, else (nothing delivered, or no address yet)
tax-home-country(blank:siteCountryCode). An in-person sale is taxed where the till is. Where the shop isn't listed intax-registrations(empty: just the home country) the order is an export: no tax, and the shopper pays the shelf price, or withtax-outside-registration=remove-taxthe shelf price less the home country's VAT. - Rates:
TaxCodeon the product, or the variant (standard,reduced,second-reduced,zero,exempt,outside-scope; null:tax-default-code), looked up in the bundledtax/tax-rates.json(GB and the EU-27, with the date each rate started). Delivery with notaxCodeof its own follows the goods, split across their rates by value. - Rounding: half-up per line.
- On the order: each line's and delivery's
Price.taxwith ataxSourcesentry per rate (percentage,code, the taxed amount asbasis), the order'sPrice.taxand a breakdown by rate,Price.taxIncluded = true, andOrder.taxCountry. A submitted order keeps its tax country and line codes and is taxed at the rates of the day it was submitted.
Shown (only when VAT was recorded: Price.includedTax() is null for tax off or an export):
- an "Includes VAT" row under the total in the basket summary card (Nova, Brock, bootstrap: row
.taxRow, amount.orderTaxPrice, refreshed bybasket-v2.jsfrom/rest/order/pricesincludedTaxand by unified checkout fromtotals.taxMinorwhentotals.taxIncluded), and on the order confirmation; - in the order email: the VAT, a line per rate when there are several, and the shop's legal name,
registered address and VAT number (
seller, anEmailSellerthatMailtrapEmailServiceputs on every email's model) as a VAT receipt; - in the admin order view: the VAT, a line per rate and country with the amount it was worked out on, or "None: export to US";
- "Prices include VAT" beside the price on product pages (Atelier, Brock, Depot, Pop, Spec) when
the shop has no
*.pdp.priceNoteof its own:ShopCurrencyInterceptorputsshopPricesIncludeTaxon the request.
Codes are set in the admin product and variant editors (a "Tax rate" select posting default
to clear; ProductAdminRenderer.taxCodeFromForm leaves the code alone when a form has no select),
the delivery option editor, MCP (taxCode on manageProducts, manageVariants,
manageDeliveryOptions) and REST (taxCode on Product). OrderService.vatTotals sums the
order breakdowns by country and rate in Mongo for Reports > VAT.
It runs at the end of OrderPricer (so every priced order, whatever a site's pricing pipeline
holds) and in PosServiceImpl, never while a page renders. price.net keeps its meaning (the
line before discounts), so baskets' struck-through prices are unaffected. Design and later steps
(showing it, editing codes, reports, Stripe Tax for tax-exclusive prices):
docs/implementation-plan/tax-at-checkout.md.
Changing a shop's currency¶
Changing siteCurrencyCode changes the currency every price is shown and charged in. It doesn't
convert any amounts: product, delivery and promotion prices are numbers in whatever currency the
shop sells in, so they need re-entering in the new currency. Baskets follow the new currency the
next time they are priced. Submitted orders don't change.