Currencies
In Hantera, currency is usually a property of a bounded context — not a dimension of individual values.
An order is a single-currency context. Every value attached to it (delivery shipping prices, order line unit prices, returns, calculated discounts, the invoice it eventually produces, the payment that captures it) is in the same currency.
This is a different shape from channel and locale (see Dimensions). Channels and locales are dimensions: a single product carries many translated names, one per locale. Currencies do not work that way. A monetary value has exactly one currency — the currency of the context it lives in. There is no per-locale or per-channel variant of a price stored alongside the value.
Why bounded contexts and not dimensions?
The bounded-context model is what keeps the math trivial:
- No mixed-currency arithmetic anywhere in the platform. When
orderLineTotal + shippingTotalruns, both sides are guaranteed to be in the order's currency. There is no possibility of accidentally summing a SEK total with a EUR total. - No need to pin a rate to long-lived entities. An order can sit pending for months. If we recorded an exchange rate at the moment the order was created, a refund issued months later at a drifted rate would leave a net order total ≠ 0 from rate movement alone. Hantera sidesteps that entirely by not pinning a rate to orders at all.
- No implicit conversions. The platform never auto-converts a value from one currency to another. If you want a value in a different currency, you do the conversion explicitly, where the choice of rate is part of the question you're asking.
Cross-currency aggregation isn't a problem the platform tries to solve. It's a problem reporting solves — see Reporting across currencies below.
Configuring currencies
Currencies are configured in the registry under the currencies/ namespace. Each entry is keyed by a currency code, typically (but not necessarily) an ISO 4217 code.
uri: /registry/currencies/SEK
spec:
value:
label: 'Swedish Krona'
decimals: 2
exchangeRate: 1.0
---
uri: /registry/currencies/EUR
spec:
value:
label: 'Euro'
decimals: 2
exchangeRate: 11.5
---
uri: /registry/currencies/USD
spec:
value:
label: 'United States Dollar'
decimals: 2
exchangeRate: 10.6Custom currencies
Codes are arbitrary identifiers (^[A-Za-z0-9_]{1,16}$). You're not limited to ISO 4217 — define whatever your business needs:
uri: /registry/currencies/BTC
spec:
value:
label: 'Bitcoin'
decimals: 8
exchangeRate: 800000.0
---
uri: /registry/currencies/LOYALTY_POINTS
spec:
value:
label: 'Loyalty Points'
decimals: 0
exchangeRate: 0.01Fields
| Field | Type | Required | Description |
|---|---|---|---|
label | string | yes | Display name |
decimals | number | no | Display precision; defaults to Intl behavior or 2 |
exchangeRate | number | no | Current conversion factor; defaults to 1 |
There is no symbol field. Symbol placement and formatting are presentation concerns and are derived from the browser's Intl.NumberFormat for ISO codes.
Exchange rates
The exchangeRate is a normalized scalar, not a pair. It expresses the value of one unit of the currency in the implicit base — so to convert to base you multiply:
amountInBase = amount * rate(currency)The rate from currency A to currency B is
rate(A) / rate(B).
There is no system-defined base currency. Whatever currency happens to be at rate 1 is the implicit base — but this is incidental. What matters is that the rates on currency entries are mutually consistent. In the sample above, EUR = 11.5 and SEK = 1.0 means one euro is worth 11.5 kronor; SEK is the implicit base purely because it's the one sitting at 1.
Hantera uses exchangeRate for exactly two purposes:
- Snapshotting onto invoices at invoice creation time. This is the only place the platform itself reads the registry rate.
- As a value that reports may consume when they need to normalize across currencies.
The platform does not consult the rate during order processing, promotion calculation, payment processing, or any other in-flow operation. Those flows stay inside their bounded context's currency.
Updating rates
Rates can be updated:
- Manually in the Settings → Currencies panel in the portal.
- Via the registry API,
PUT /registry/currencies/<code>. - By a custom job that periodically pulls from a feed (ECB, Open Exchange Rates, etc.).
Hantera stores only the current rate. It does not maintain a time-series history of rates.
Why orders don't store a rate
This deserves explicit mention because it's where most ERPs go wrong.
An order is a long-lived entity. It can be created today, modified next week, partially fulfilled next month, and refunded six months later. If we recorded an exchange rate when the order was created, that rate would represent "the conversion factor at order time" — a value that becomes increasingly stale and increasingly meaningless as the order ages.
Worse, if a refund were issued months later and converted at the drifted rate, the order's net total in the normalized currency wouldn't sum to zero — even though, in the order's actual currency, sales and refunds cancel out cleanly. The "net non-zero" would be entirely a rate-drift artifact, not a real business event.
Hantera avoids this by not pinning a rate to orders at all. Inside the order's currency, math is exact. Cross-currency analysis is something a report decides to do, with a rate the report explicitly chooses.
Why invoices snapshot the rate
Invoices are the boundary where Hantera commits to an accounting-relevant value. They feed into financial systems, ledgers, and tax reporting — all of which need a pinned, point-in-time rate to work correctly.
When an invoice is created, the order's currency rate is read from the registry once and snapshotted onto the invoice as a system field, exchangeRate. Once written, it never changes. Even if the registry rate is updated tomorrow, the invoice's exchangeRate continues to reflect the rate that was in effect at invoicing time.
invoice {
invoiceId
invoiceTotal # in the order's currency
exchangeRate # snapshot of the registry rate at invoice creation
}If the source currency wasn't registered, or had no exchangeRate, the snapshot defaults to 1.
Reporting across currencies
When a report needs to compare or aggregate amounts across currencies, the conversion happens in the report, not in the platform.
The report decides:
- Which rate to use (current registry rate, period-end rate from an external feed, the snapshot on each invoice, an average, etc.).
- How to apply it (per row, per period, per aggregation bucket).
- What "normalized" means for that report.
The platform deliberately does not pre-calculate normalized values, because there is no single right answer. A daily sales dashboard might use the current registry rate. A year-end financial report should use the invoice snapshots. A finance-month-end revaluation might use a date-specific feed. Different reports, different choices.
A simple Filtrera example of a report computing a normalized sales total against the current registry rates:
import 'iterators'
// Pattern-match the registry value so missing entries (or entries without
// an exchangeRate) safely fall back to 1.
let rateOf (currency: text) |>
(registry->$'currencies/{currency}') match
({ exchangeRate: number }) |> exchangeRate
|> 1
// Implicit base is whatever rate = 1 in the registry; multiplying converts to it.
let normalize (amount: number, currency: text) |> amount * rateOf(currency)
from
orders
select o => (o, normalize(o.orderTotal, o.currencyCode))For invoiced amounts, prefer the invoice's own exchangeRate over the current registry rate. The invoice snapshot is what was committed to accounting.
Portal: managing currencies
The Settings → Currencies panel in the portal lets users:
- Add ISO 4217 currencies from a built-in list (with prefilled label/decimals).
- Add custom codes for non-standard currencies.
- Edit labels, decimals, and exchange rates inline.
- Delete currencies they no longer need.
Permissions follow the registry pattern: registry/currencies:read and registry/currencies:write.
Portal: capturing values in multiple currencies
Some configuration is currency-scoped — for example, a price list that needs to express the same conceptual price in several currencies. For these cases, use the global <CurrencyInput> component:
<template>
<CurrencyInput v-model="prices" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Decimal } from 'decimal.js'
const prices = ref<Record<string, Decimal>>({
SEK: new Decimal(100),
EUR: new Decimal(8.7),
})
</script>The component loads the registered currencies from the registry and presents one row per currency in the value, with an "Add currency" picker for additional ones. Each row's value is independent — there is no automatic conversion between rows.
Note: this component is for configuration data that genuinely has a value per currency. It is not a way of storing a converted version of a single value. Monetary values on entities (orders, invoices, payments) carry their currency as part of the entity's bounded context, not as a key in a per-currency map.