0014. Keep every amount in its own currency, and convert only with recorded rates
- Status: accepted
- Date: 2026-10-26
Context
Section titled “Context”ADR 0007 makes money an integer of ISO 4217 minor units. This ADR decides how GuestCraft handles several currencies at once. The facts come from the research report i18n-research.md.
- Channex.
- A rate plan has its own
currency, and a property may have plans in different currencies. - A booking revision has its own
currencyand decimal amounts. - Nothing promises that the booking, the plan, a virtual card and the settlement share one currency.
- A rate plan has its own
- Precision. ISO 4217 and CLDR disagree on the decimals of some currencies:
- HUF, COP, IDR and others have two decimals in ISO 4217 and none in CLDR; IQD has three in ISO 4217 and none in CLDR.
- CLDR also has cash rounding: CHF to 0.05, CAD to 0.05, DKK to 0.50, HUF to 5 forints. Whether a cash rounding applies, and to what, is the law of each country.
- Rate sources.
- The ECB publishes euro reference rates for about 29 currencies, around 16:00 CET on TARGET business days. They are “for information only” and reusable with attribution.
- Frankfurter, MIT and self-hostable, now mixes several providers unless asked for one.
- Payment providers apply their own rates and fees.
Decision
Section titled “Decision”Precision.
- The minor units of ISO 4217 are the only authority for storage and arithmetic. CLDR’s decimals serve display only.
- An amount is never rounded for display: more significant decimals are always shown (ADR 0012).
One currency per thing, never mixed.
| Thing | Currency |
|---|---|
| Property | Its base currency, chosen explicitly at onboarding. Changing it is a commercial migration, not a setting. |
| Channex rate plan | Its own. In v1 GuestCraft manages plans in the property’s currency only: a GuestCraft restriction, not a Channex one. A plan found in another currency is kept as it is. |
| Booking | The currency of the OTA’s revision, with its amounts and their origin, whatever the property’s currency. A booking in another currency is always imported. If GuestCraft cannot account for it yet, it opens an explicit task instead of changing or dropping the booking. |
| Folio | One currency per folio. A conversion adds explicit lines, with its record. |
| Payment | The currency actually charged or refunded. Settlement, provider rates and fees are recorded separately. |
| Organization reporting | A reporting currency, with dated conversions shown next to the original amounts, which never change. |
Rates and conversions (core/shared/exchange.ts).
-
Rates. An
ExchangeRateis kept exactly as published: a decimal string, the day it refers to and its source. -
Conversions. A
Conversionis an exact fraction. It comes from one rate, in either direction, or from two rates with the same base, source and day, such as the ECB’s euro rates; no intermediate result is rounded. -
Rounding.
convertrounds once, to the minor units of the target, with a mode the caller must give. Like every operation on money, it refuses a result outside the safe integer range. -
Records. A conversion that GuestCraft books keeps:
- the input and output amounts;
- the rates and the method;
- the rounding mode;
- the purpose (
quote,payment,fiscal,reporting); - when the rates were retrieved.
Issued documents, quotes and recorded payments are never recomputed at a later rate. A revaluation is a separate, dated operation.
-
Missing rates. A missing or stale rate is an error. It is never 1, and never zero.
Rate sources.
- A core port
ExchangeRates, implemented by adapters that the deployer chooses: ECB, Frankfurter with a pinned provider, a central bank, a payment provider, or manual entry. - A manual rate records its author, reason, day and purpose.
- Adapters read rates from XML or CSV, or from JSON without passing through a binary number, so that the published digits are kept. They store the attribution their source asks for. No adapter calls a service run by the project.
Cash rounding.
roundToIncrementin the core rounds to an increment of minor units.- The country pack says which increment applies, to which payments and from when.
- The difference is booked as its own line, never spread over the others or over the OTA’s price.
Display.
- Every amount shows its own currency.
- Views that mix currencies show ISO codes.
- A converted amount is shown with the date and source of its rate.
- Whether a price includes taxes and fees is a policy of the country pack for the market and the operation. It never depends on a language or a locale: an English interface in Italy shows Italian totals.
Stored rates (1 November 2026)
Section titled “Stored rates (1 November 2026)”- An organization stores its rates in
exchange_rates: the rate as published, its day, source, author and reason. Every active workspace of the organization reads them; members holdingexchange_rates.manageadd or delete them from a browser session (ADR 0031). - For now only manual rates exist (source
manual), and they serve reporting conversions only: consolidated reports convert currency subtotals with them when they are read, and store no conversion record because nothing is booked. TheExchangeRatesport and its adapters (ECB, Frankfurter, a provider) remain planned, as do booked conversions of payments and documents. - A report uses the latest rate dated on or before its last night and at most seven days older,
direct, inverse or crossed through a currency both are quoted against (one source and day),
and refuses a currency without one (
exchange_rate.missing).
Consequences
Section titled “Consequences”- No sum mixes currencies anywhere: the core refuses it.
- Multi-currency properties, cash drawers in several currencies and revaluations remain possible without changing the model; v1 does not offer them.
- A conversion can always be reproduced from its record, and its rounding difference explained.
- Each installation chooses its rate source, and pays for it if it is commercial.
Alternatives considered
Section titled “Alternatives considered”- Convert every booking to the property’s currency on import. Rejected: the OTA’s amounts are facts, and the rate of the day of import is arbitrary.
- Rates as floats, or as decimals rounded to 6 places. Rejected: conversions would not be reproducible to the cent, and cross rates would compound errors.
- One global rate service run by the project. Rejected by the repository-only model: whoever installs runs everything.