Search documentation

Search for a command to run...

Currency conversion

Money only does arithmetic within one currency. Converting a price between currencies goes through CurrencyConverter, which is central to the package and shared by every provider, so no provider decides on its own what an amount means.

Why it exists

A gateway that settles in a single currency — SISP charges in escudos and sends 132 as its numeric currency code — receives whatever minor units it is handed and labels them with its own currency. An unconverted EUR 25.00 becomes 25 CVE on the payment form, about one hundredth of the price.

Rates

An ExchangeRate carries the pair and the exact decimal text of the rate, plus the bigint fraction derived from it. The rate is always a decimal string: 110.265 has no exact binary representation as a number, and a number in exponential notation (7.3e-9) cannot be distinguished from an invalid one by decimal-pattern matching. ExchangeRate.of accepts string only.

import { ExchangeRate } from '@akira-io/payable';

const rate = ExchangeRate.of('EUR', 'CVE', '110.265');
rate.toJSON(); // { from: 'EUR', to: 'CVE', rate: '110.265' }

The decimal text is capped at 40 characters, well past any real-world parity, so a provider that takes a rate from an external feed cannot hand ExchangeRate.of an unbounded string.

Rates come from an ExchangeRateProvider. The one shipped today reads a configured parity table, which is legitimate for currencies pegged to each other and not for floating pairs. Pairs are explicit: configuring EUR/CVE does not create CVE/EUR, because an inverted rounded rate does not return the original amount.

import { CurrencyConverter, FixedExchangeRateProvider } from '@akira-io/payable';

const converter = new CurrencyConverter(
  new FixedExchangeRateProvider({ 'EUR/CVE': '110.265' }),
);

Converting

const conversion = await converter.convert(Money.of(2500, 'EUR'), 'CVE');

conversion.source.amount(); // 2500
conversion.converted.amount(); // 275663
conversion.rate.rate; // '110.265'

The result carries the source amount, the converted amount, the pair and the applied rate. Store all of them: a conversion that leaves no trace cannot be reconciled later.

Converting to the same currency is a transparent pass-through — the same Money instance, an identity rate, no rounding and no loss. A pair with no configured rate throws ExchangeRateNotFoundError; the converter never returns the unconverted amount. convert also checks that the rate returned by the provider is for the pair it asked for — a provider bug that returns a rate for a different pair throws ExchangeRatePairMismatchError rather than being trusted silently.

Rounding

The rate is applied in bigint arithmetic against each side’s own minor units per major unit (CurrencyManager.minorUnitsPerMajor, not a fixed power of ten — MGA and MRU are base 5), so the result is rounded exactly once, half up away from zero — the same rule Money.divide and Money.percentage use. The final integer is validated against the safe integer range.

Where to convert

Convert when the checkout is created, and store the canonical amount, the charged amount and the rate together. Under a fixed parity the moment makes no difference; under a variable rate, converting at checkout is what keeps the price the customer saw and the amount they were charged the same number.