Money
A product has a selling price. A cart has a subtotal, a shipping cost, and a discount amount. A pricing plan has a monthly fee. The shape connectors and editors return for all of these is the same Money record: an integer amount paired with an ISO 4217 currency code. The storefront's $money formatter turns the object into a localized string based on the active language.
import type { Money } from '@laioutr-core/core-types/common';
interface Money {
amount: number; // in the smallest unit of the currency
currency: string; // ISO 4217 code, e.g. 'USD', 'EUR', 'CHF'
}
Unlike Link and Media, Money is not a discriminated union. There is no MoneyRange, no MoneyDiscounted, no tax-included variant. A price is just an amount and a currency; everything else (sale indicators, savings percentages, unit pricing) lives on the entity component that wraps the Money value. This keeps arithmetic simple and matches Martin Fowler's Money pattern, which the type is modeled on.
Fields
amount
The integer count of the smallest unit of the currency. Cents for USD/EUR, pence for GBP, yen for JPY (which has no minor unit). Storing prices as integers avoids the floating-point rounding errors you get from working in decimal directly.
| Currency | Amount | Renders as |
|---|---|---|
USD | 10012 | $100.12 |
EUR | 14900 | €149.00 (or 149,00 € in de-DE) |
GBP | 1995 | £19.95 |
JPY | 10000 | ¥10,000 (no decimals; JPY has zero fraction digits) |
BHD | 1500 | BHD 1.500 (three fraction digits) |
Negative amounts are valid and represent refunds, discounts applied as negative values, or any other inverse total. The $money formatter renders them with the locale's negative-number convention.
currency
An ISO 4217 currency code. The renderer relies on this to pick the right symbol, decimal separator, and fraction-digit count. Stick to the standard codes; arbitrary strings will trip up Intl.NumberFormat and @screeny05/ts-money (the underlying arithmetic library).
A Money object is self-describing: the currency travels with the amount. The renderer does not consult useCurrency() when formatting; it reads the value's own currency field. This is why mixing currencies inside a single response (a cart with one line item in EUR and another in USD) is structurally possible but semantically invalid. Laioutr enforces one currency per market, so a well-formed response uses one currency throughout.
Formatting with $money
A Money object is just data. Turning it into a localized string the user can read is the job of $money, a global helper the UI Kit registers as a Nuxt plugin and that is auto-available in templates and setup():
<template>
<span>{{ $money({ amount: 14900, currency: 'CHF' }) }}</span>
<!-- Renders 'CHF 149.00' in en-US, '149,00 CHF' in fr-CH, etc. -->
</template>
Outside templates, access it through the Nuxt app:
const { $money } = useNuxtApp();
const formatted = $money(product.price);
The signature is:
$money(money: Money, options?: Intl.NumberFormatOptions): string
options are forwarded to Intl.NumberFormat on top of { style: 'currency', currency: money.currency }. Common overrides:
$money({ amount: 14900, currency: 'CHF' }, { currencyDisplay: 'code' }); // 'CHF 149.00'
$money({ amount: 14900, currency: 'CHF' }, { currencyDisplay: 'symbol' }); // 'CHF 149.00' (CHF has no distinct symbol)
$money({ amount: 14999, currency: 'EUR' }, { maximumFractionDigits: 0 }); // '€150'
Locale resolution prefers vue-i18n's $n (so the storefront's i18n setup decides the formatting locale). When $n is unavailable, $money falls back to Intl.NumberFormat with the locale from useLocale(). Either way, the locale is the active language, not the active market, so the same CHF amount renders as CHF 149.00 in a German Swiss locale and 149,00 CHF in a French Swiss locale.
$money is resilient to undefined input: it falls back to a zero-EUR placeholder and renders without throwing. This exists so partial server responses do not crash the page during SSR, but it can mask connector bugs that drop the price entirely. Treat €0.00 showing up where a real price was expected as a connector data issue, not a UI issue.Working with amounts
For arithmetic (summing line items, computing discounts, comparing prices), use @screeny05/ts-money. It handles the minor-unit accounting and zero-decimal currencies for you:
import { Money } from '@screeny05/ts-money';
const subtotal = Money.fromDecimal(149.0, 'CHF'); // { amount: 14900, currency: 'CHF' }
const shipping = Money.fromDecimal(9.5, 'CHF'); // { amount: 950, currency: 'CHF' }
const total = subtotal.add(shipping); // { amount: 15850, currency: 'CHF' }
const formatted = $money(total); // 'CHF 158.50'
ts-money's Money class is structurally compatible with the Money interface from @laioutr-core/core-types/common, so you can pass a ts-money instance to $money directly.
Currency resolution
When a Money value is in your hand, the currency is on the value. When you are constructing one (a custom price calculator, a tip widget, a manually-entered amount), pull the currency from the active market:
const currency = useCurrency(); // ComputedRef<string>, e.g. 'CHF'
const amount = computed(() => userInput.value * 100); // cents
const tip: ComputedRef<Money> = computed(() => ({ amount: amount.value, currency: currency.value }));
useCurrency() is a shorthand for useMarket().value.currency. Each market has exactly one currency, and switching currency means switching market. See Currencies for the developer-facing rationale and the market/currency switcher pattern.
Related types
UnitPrice
UnitPrice is the type behind labels like "€13.99 / 100g" or "$0.99 / fl oz" that sit next to the headline price on grocery items, drinks, and household goods. It pairs a Money value with two Measurement values: a quantity (the variant's actual content) and a reference (the unit the price is normalized to). It shows up on Product.prices.unitPrice and ProductVariant.prices.unitPrice. See the UnitPrice reference page for the full type, rendering pattern, and connector mapping.
Money in filter ranges
Faceted price filters use Money for min and max:
const priceFilter = {
type: 'range',
id: 'price',
label: 'Price',
wellKnownName: 'price',
min: { amount: 0, currency: 'EUR' },
max: { amount: 99900, currency: 'EUR' },
};
The user's selection arrives back as plain numbers (in the smallest unit), so { min: 1000, max: 5000 } against an EUR price filter means 10.00 EUR to 50.00 EUR. See Filters for the request/response shape.
For connector authors
This is the section that prevents the most common bug in connector pricing code: emitting prices in the wrong unit. The rule is short:
When a query handler returns a price, return a
Moneyobject withamountin the smallest unit of the currency andcurrencyas a valid ISO 4217 code. Never emit a decimal float, a string, or a bare number.
Wrong
// Custom commerce connector, product prices handler
return {
price: 149.99, // ❌ bare number
strikethroughPrice: '199.99 EUR', // ❌ formatted string
shippingRate: { amount: 9.5, currency: 'EUR' }, // ❌ decimal where integer was expected
};
This breaks for three independent reasons:
price: 149.99is not aMoney. Components type-check againstMoney, so this fails the moment the entity component is consumed.strikethroughPrice: '199.99 EUR'strips the structure. The frontend cannot do arithmetic, cannot pick a different locale, and cannot apply consistent formatting alongside other prices on the page.{ amount: 9.5, currency: 'EUR' }is a decimal where the smallest unit was expected.9.5interpreted as cents is €0.10, not €9.50. The price renders as€0.10and nobody notices until a customer complains.
Right
import { Money } from '@screeny05/ts-money';
const toMoney = (decimal: number, currency: string) => Money.fromDecimal(decimal, currency);
// In your prices resolver
return {
price: toMoney(149.99, 'EUR'), // { amount: 14999, currency: 'EUR' }
strikethroughPrice: toMoney(199.99, 'EUR'), // { amount: 19999, currency: 'EUR' }
shippingRate: toMoney(9.5, 'EUR'), // { amount: 950, currency: 'EUR' }
isOnSale: true,
};
The connector says "this product costs 149.99 EUR." The frontend's $money formatter decides how to render it for the active locale. Multi-language formatting, market-specific currency, and arithmetic across line items all stay where they belong: in the storefront.
Picking the right amount for zero-decimal currencies
Money.fromDecimal consults @screeny05/ts-money's currency table for the right number of decimal places. JPY and KRW have zero, BHD and KWD have three; the rest are typically two. Use fromDecimal and let the library do the math, instead of computing amount yourself:
Money.fromDecimal(1000, 'JPY'); // { amount: 1000, currency: 'JPY' }, yen has no minor unit
Money.fromDecimal(149.0, 'EUR'); // { amount: 14900, currency: 'EUR' }, two-decimal currency
Money.fromDecimal(1.5, 'BHD'); // { amount: 1500, currency: 'BHD' }, three-decimal currency
Hard-coding amount = decimal * 100 in your handler will silently break any zero-decimal or three-decimal currency the moment a customer in Japan or Bahrain visits the storefront.
Tax-inclusive vs tax-exclusive
Money carries no tax flag. Pick one convention per connector (typically tax-inclusive for B2C storefronts, tax-exclusive for B2B), document it on the connector, and stick to it across every price the connector returns. If a storefront needs both views, surface the tax-status decision on the entity component that wraps Money (a priceIncludesTax: boolean field on the prices component, for example) rather than splitting Money into new variants. This keeps Money flat and pushes the policy into the component that uses the value.
Related
UnitPrice: the per-unit pricing type that wraps aMoneyvalue with quantity and reference measurements.- Currencies: why currency is per-market, the currency switcher pattern, and the developer-facing summary of
$money. - Locale-aware formatting: the full set of UI Kit formatters (
$money,$measurement,$timespan,$duration). - Filters: how
Moneyshows up in faceted price ranges. - Product and ProductVariant: the entities whose
pricescomponents carryMoneyandUnitPricevalues. @screeny05/ts-money: the arithmetic library Laioutr uses to build, add, and convertMoneyvalues.