Functional Weave
Code in Rust

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 EUR
  • convert_money(£12.34, base GBP, quote EUR, rate …, half-up) → €14.48 12.34 GBP at 1.1734 is 14.479756 EUR, rounded half-up
  • convert_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
amountMoneymust be in the rate's base currency
rateExchangeRatethe rate to apply; the caller chooses it for the right date
modeRoundingModehow to round to the quote currency's minor unit
returnsMoney

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(…)
impl/rust.rs · 51 lines · open · raw

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
Download for Rust money.convert-1.0.0-rust.fune · 14,643 bytes sha256 25fbc108c0ee9375d041eb676d9b69f1f704aaecd5d6d6040f1112036a419fa7

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md2,012
impl/python.py1,303
impl/rust.rs2,026
impl/typescript.ts1,398
vectors.json7,186