money.convert
Convert an amount to another currency at a supplied exact rate, with an explicit rounding mode.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 19 tests, run in TypeScript, Python and Rust.
What it does
Converts a `Money` into another currency at a rate the caller supplies. It does not look rates up: which rate applies (the day's ECB reference rate, the rate on the invoice date, a contract rate) is the caller's decision, and this takes it as an argument, so the same inputs always give the same answer.
**The rate is an exact fraction**, a `math.rational` `Rational`, in major units, the way rates are quoted: GBP to EUR at 1.1734 is `{ base: "GBP", quote: "EUR", rate: { numerator: 11734, denominator: 10000 } }`. A decimal quote becomes a fraction by moving the point (`math.basis-points` style), and the inverse rate is the same fraction upside down, exactly, so converting back uses precisely the reciprocal rather than 1/1.1734 cut off at some number of places. A fraction was chosen over "rate in micro-units" because micro-units cannot hold a reciprocal and cap every rate at six decimal places.
For example
convert_money(£100.00, base GBP, quote EUR, rate …, half-up)→ €117.34 100.00 GBP at 1.1734 is 117.34 EURconvert_money(£12.34, base GBP, quote EUR, rate …, half-up)→ €14.48 12.34 GBP at 1.1734 is 14.479756 EUR, rounded half-upconvert_money(£12.34, base GBP, quote EUR, rate …, down)→ €14.47 the same rounded down
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn convert_money(amount: &Money, rate: &ExchangeRate, mode: &str) -> Money
| amount | Money | must be in the rate's base currency |
| rate | ExchangeRate | the rate to apply; the caller chooses it for the right date |
| mode | RoundingMode | how to round to the quote currency's minor unit |
| returns | Money |
The type it declares, generated into your project
/// A quoted rate: one unit of base buys rate units of quote, both in major units.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExchangeRate {
/// ISO 4217 code converted from
pub base: String,
/// ISO 4217 code converted to
pub quote: String,
/// exact, e.g. 1.1734 is {numerator 11734, denominator 10000}
pub rate: Rational,
}
Your code names it in one line, in the file that uses it
fune!(money.convert@^1); // then call convert_money(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_rational::{multiply_rational, rational, rational_from_value, rational_to_integer}; ← from math.rational ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value, Money}; ← from money.amount ^1.0.0 · built alongside by fune
use super::money_currency_digits::currency_digits; ← from money.currency-digits ^1.0.0 · built alongside by fune
/// Convert `amount` into the rate's quote currency.
///
/// Exact up to one final rounding into the quote currency's minor unit; the
/// rate is a fraction in major units and each currency's decimal places are
/// looked up rather than assumed to be two.
///
/// # Panics
/// Panics if the amount is not in the rate's base currency, the rate is not
/// positive, a currency has no ISO 4217 minor unit, or the exact value
/// overflows `math.rational`'s range.
pub fn convert_money(amount: &Money, rate: &ExchangeRate, mode: &str) -> Money {
if amount.currency != rate.base {
panic!(
"exchange rate converts {} to {}, not {}",
rate.base, rate.quote, amount.currency
);
}
let r = rational(rate.rate.numerator, rate.rate.denominator);
if r.numerator <= 0 {
panic!("exchange rate must be positive");
}
let from_digits = currency_digits(&rate.base) as u32;
let to_digits = currency_digits(&rate.quote) as u32;
// minor units of quote per minor unit of base: rate x 10^to_digits / 10^from_digits
let scale = rational(10_i64.pow(to_digits), 10_i64.pow(from_digits));
let per_minor = multiply_rational(&r, &scale);
let exact = multiply_rational(&rational(amount.minor, 1), &per_minor);
money(rational_to_integer(&exact, mode), &rate.quote)
}
pub fn exchange_rate_from_value(v: &Value) -> ExchangeRate {
ExchangeRate {
base: v.get("base").as_str().to_string(),
quote: v.get("quote").as_str().to_string(),
rate: rational_from_value(v.get("rate")),
}
}
pub fn fune_vector(args: &[Value]) -> Value {
money_to_value(&convert_money(
&money_from_value(&args[0]),
&exchange_rate_from_value(&args[1]),
args[2].as_str(),
))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add money.convert
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./money.convert-1.0.0-rust.fune, or fetch it from a terminal with fune pull money.convert@1.0.0:rust.
The whole function, every language, is one file too: money.convert-1.0.0.fune, 17,457 bytes, sha256 5f212485febe18b862f284258bf82a2443c493df5d26c3d20f0303bc6fdf5691. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before money.convert
after — your function gets the result and the arguments, and returns the final result.
// fune: after money.convert
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace math.rational in money.convert
// fune: replace math.round-div in money.convert
// fune: replace money.amount in money.convert
// fune: replace money.currency-digits in money.convert
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show money.convert --steps.
// fune: step money.convert after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| 100.00 GBP at 1.1734 is 117.34 EUR | £100.00, base GBP, quote EUR, rate …, half-up | → | €117.34 |
| 12.34 GBP at 1.1734 is 14.479756 EUR, rounded half-up | £12.34, base GBP, quote EUR, rate …, half-up | → | €14.48 |
| the same rounded down | £12.34, base GBP, quote EUR, rate …, down | → | €14.47 |
| a refund converts symmetrically | -£12.34, base GBP, quote EUR, rate …, half-up | → | -€14.48 |
| pounds to yen: 10.00 at 190.12 is 1,901 yen, not 190,120 | £10.00, base GBP, quote JPY, rate …, half-up | → | ¥1,901 |
| yen back to pounds at the exact reciprocal | ¥1,901, base JPY, quote GBP, rate …, half-up | → | £10.00 |
| pounds to Kuwaiti dinar, three decimal places | £100.00, base GBP, quote KWD, rate …, half-up | → | 38.120 KWD |
| 1.005 exactly rounds up where a float gives 100.49999999999999 | €1.00, base EUR, quote USD, rate …, half-up | → | $1.01 |
| half a cent rounds up under half-up | £0.05, base GBP, quote EUR, rate …, half-up | → | €0.03 |
| half a cent rounds to even under half-even | £0.05, base GBP, quote EUR, rate …, half-even | → | €0.02 |
Show the other 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| an unreduced rate is read as its value | £100.00, base GBP, quote EUR, rate …, half-up | → | €117.34 |
| zero converts to zero | £0.00, base GBP, quote EUR, rate …, half-up | → | €0.00 |
| a same-currency rate of one is the identity | £43.21, base GBP, quote GBP, rate …, half-up | → | £43.21 |
| a GBP to EUR rate on a USD amount is an error | $10.00, base GBP, quote EUR, rate …, half-up | → | error: exchange rate converts GBP to EUR, not USD |
| a zero rate is an error | £10.00, base GBP, quote EUR, rate …, half-up | → | error: exchange rate must be positive |
| a negative rate is an error | £10.00, base GBP, quote EUR, rate …, half-up | → | error: exchange rate must be positive |
| a zero denominator is an error | £10.00, base GBP, quote EUR, rate …, half-up | → | error: denominator must not be zero |
| gold has no minor unit to convert into | £10.00, base GBP, quote XAU, rate …, half-up | → | error: no ISO 4217 minor unit |
| a product too large to hold exactly is refused | £90,071,992,547,409.91, base GBP, quote EUR, rate …, half-up | → | error: rational overflow |
More from the author
**Minor units are handled for you.** The rate is in major units, so the amount is scaled by each currency's ISO 4217 decimal places (from `money.currency-digits`): 10.00 GBP at 190.12 is 1,901 JPY, not 190,120.
**One rounding**, at the end, to the quote currency's minor unit, with the `math.round-div` mode you pass. Everything before it is exact. 1.00 EUR at 1.005 is exactly 1.005 USD; `half-up` gives 1.01, where a float computes 100 × 1.005 as 100.49999999999999 and rounds it to 1.00.
**The rate carries its currencies.** Applying a GBP-to-EUR rate to a USD amount is an error, not a conversion. To go the other way, swap `base` and `quote` and turn the fraction over; this never inverts a rate on its own.
Edges: the rate must be positive; zero converts to zero; negative amounts (refunds) convert symmetrically under `half-up` and `half-even`. Intermediate values follow `math.rational`'s limits (numerator and denominator within 2^53 - 1 after reducing); an amount and rate so large that the exact product does not fit is refused with "rational overflow" rather than rounded.
Files
| Path | Bytes |
|---|---|
| README.md | 2,012 |
| impl/python.py | 1,303 |
| impl/rust.rs | 2,026 |
| impl/typescript.ts | 1,398 |
| vectors.json | 7,186 |