An ergonomic Rust library for handling money.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
renovate-bot 69bc244eaf
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
ci/woodpecker/push/test Pipeline was successful
chore(deps): update rust crate rust_decimal to v1.43.0
2026-09-09 04:12:38 +00:00
.claude chore: Format on edit and lint on stop with mise-provided tools 2026-08-18 09:13:14 -06:00
.out-of-scope docs: Record crossing a route as out of scope 2026-08-22 09:45:06 -06:00
.woodpecker ci: Add CI 2026-08-11 17:15:14 -06:00
docs feat: Price an amount at the rate a moment was quoted at 2026-08-22 08:26:56 -06:00
src feat: Check and round amounts against a currency's minor units 2026-08-22 10:06:57 -06:00
tests feat: Check and round amounts against a currency's minor units 2026-08-22 10:06:57 -06:00
.editorconfig feat: Initial commit 2026-08-11 11:29:25 -06:00
.gitignore feat: Initial commit 2026-08-11 11:29:25 -06:00
build.rs chore: Deny thirty-one restriction lints 2026-08-20 12:11:48 -06:00
Cargo.lock chore(deps): update rust crate rust_decimal to v1.43.0 2026-09-09 04:12:38 +00:00
Cargo.toml feat: Price an amount at the rate a moment was quoted at 2026-08-22 08:26:56 -06:00
clippy.toml feat: Check and round amounts against a currency's minor units 2026-08-22 10:06:57 -06:00
CODING_STANDARDS.md docs: Move task instructions out of CLAUDE.md 2026-08-17 21:37:42 -06:00
CONTEXT.md docs: Drop the route and hop vocabulary 2026-08-22 09:45:26 -06:00
isodata.tsv fix: Name the five index-unit codes in English 2026-08-17 22:24:13 -06:00
LICENSE doc: flesh out README.md and add LICENSE 2026-08-11 13:29:45 -06:00
mise.toml chore: Format on edit and lint on stop with mise-provided tools 2026-08-18 09:13:14 -06:00
README.md refactor: Read a history's entries through one pair of readers 2026-08-22 08:35:14 -06:00
release.toml feat!: Register custom currencies behind a cargo feature 2026-08-17 08:26:35 -06:00
renovate.json Add renovate.json 2026-08-11 20:05:19 +00:00

lucre

status-badge Crates.io Version docs.rs

An ergonomic Rust library for handling money.

Represent money without generics or lifetimes, and without giving up safety or speed. ISO 4217 currency definitions are built in.

Install

Add lucre to your Cargo.toml.

[dependencies]
lucre = "0.13.0"

Usage

Money is the main type. Currency supports it, and holds a constant for every current ISO 4217 currency.

use lucre::{Money, Currency, Format, MoneyError};

fn main() -> Result<(), MoneyError> {
    // Create money from major or minor units
    let subtotal = Money::from_major(100, Currency::USD);
    let tax = Money::from_minor(475, Currency::USD);

    // Arithmetic comes in a checked form and a panicking one
    let _unchecked = subtotal + tax;
    let total = subtotal.checked_add(tax)?;

    // Money displays with the code by default
    assert_eq!(total.to_string(), "104.75 USD");

    // A `Format` chooses something else
    let format = Format::default().symbol();
    assert_eq!(total.format_with(format).to_string(), "$104.75");

    Ok(())
}

Several currencies at once

Money will not mix currencies: + panics and comparisons return None. To carry amounts in more than one currency, use a MoneyBag, which holds a separate balance for each.

use lucre::{Currency, Money, MoneyBag};

let mut wallet = MoneyBag::new();
wallet += Money::from_major(25, Currency::USD);
wallet += Money::from_major(10, Currency::EUR);

assert_eq!(
    wallet.balance(Currency::USD),
    Money::from_major(25, Currency::USD)
);

// A currency the bag has never held has a balance of zero
assert_eq!(
    wallet.balance(Currency::JPY),
    Money::from_major(0, Currency::JPY)
);

// The same iterator sums either way. The type you ask for decides:
// `Option<Money>` requires one currency, a bag allows several.
let refunds = [
    Money::from_minor(1999, Currency::USD),
    Money::from_minor(1250, Currency::USD),
];

assert_eq!(
    refunds.iter().sum::<Option<Money>>(),
    Some(Money::from_minor(3249, Currency::USD))
);
assert_eq!(refunds.iter().sum::<MoneyBag>().to_string(), "32.49 USD");

// If the currencies differ, only the bag sums
let mixed = [
    Money::from_minor(1999, Currency::USD),
    Money::from_minor(1250, Currency::EUR),
];

assert_eq!(
    mixed.iter().sum::<MoneyBag>().to_string(),
    "12.50 EUR, 19.99 USD"
);

Converting between currencies

Exchange rates change constantly, so lucre ships none of its own. Supply a price you already have and lucre does the arithmetic. A Quotation prices one direction of a currency Pair or both of them, and an Exchange holds one quotation per pair and converts amounts against them.

use std::error::Error;

use lucre::{
    Currency, Money,
    exchange::{Exchange, Quotation},
};
use rust_decimal::dec;

fn main() -> Result<(), Box<dyn Error>> {
    let mut desk = Exchange::new();
    desk.set(Quotation::one_way((Currency::USD, Currency::EUR), dec!(0.9))?);
    desk.set(Quotation::two_way((Currency::EUR, Currency::JPY), dec!(160), dec!(161))?);

    assert_eq!(
        desk.convert(Money::from_major(100, Currency::USD), Currency::EUR)?,
        Money::from_major(90, Currency::EUR)
    );

    // A two-way quotation prices the way back. A one-way one does not.
    assert!(desk.rate((Currency::JPY, Currency::EUR)).is_some());
    assert!(desk.rate((Currency::EUR, Currency::USD)).is_none());

    Ok(())
}

Iterating a MoneyBag yields each currency's balance in ISO alphabetic order, so you can convert a whole bag one balance at a time. The exchange module documents the rest: a History of every quotation a table has held, which reads at a moment as an Exchange and restates a month-old invoice at the price in effect then; crossing a pair nobody prices directly; inverting a quotation; the spread a two-way quotation keeps on a round trip; and the bounds every price is held to.

Features

serde

Off by default. Turning it on lets amounts, bags, prices, and the tables and histories of them be read from and written to a document. The shapes are below.

[dependencies]
lucre = { version = "0.13.0", features = ["serde"] }

Amounts and rates are written as text. Text keeps the fraction exact and keeps the scale the figure was built with. Numbers are read too, floats included, but only text survives a round trip unchanged.

{ "amount": "104.75", "currency": "USD" }

A bag is one balance per currency, keyed by ISO alphabetic code. Reading adds up whatever the document says, rather than requiring it to match what a bag would have written. A balance of zero leaves no currency behind, and a currency named twice is summed.

{ "EUR": "10.00", "USD": "30.00" }

A quotation states its pair and its prices. The base is the currency being priced and the quote is the currency it is priced in. One direction is a rate field; both directions are a low and a high. A document is held to the same checks as Quotation::one_way and Quotation::two_way.

{ "base": "USD", "quote": "EUR", "rate": "0.9" }
{ "base": "USD", "quote": "EUR", "low": "0.75", "high": "0.8" }

An Exchange is one quotation per pair, keyed as BASE/QUOTE, with the pair left out of the value. An entry stating a base or a quote is refused. Both directions of a pair share one entry, so a pair named twice keeps the quotation given last whichever way round each was written.

{ "USD/EUR": { "rate": "0.9" }, "EUR/JPY": { "low": "159", "high": "160" } }

A History is a list rather than a map, because a pair appears once per moment it was quoted at. Each entry states the moment as RFC 3339 alongside the pair and its prices, which is the shape feeds ship. Reading accepts any offset and files the entry under the same moment in UTC; writing always ends in Z.

[{ "as_of": "2026-08-14T09:00:00Z", "base": "USD", "quote": "EUR", "rate": "0.9" }]

A Format is one field per option, so a program can read its rendering conventions from a configuration file. A field the document leaves out keeps the value Format::new starts with, and the three options that may be unset — position, spaced, and precision — are left out when they are.

{
  "identifier": "symbol",
  "position": "prefix",
  "spaced": false,
  "negative": "parentheses",
  "precision": { "digits": 2, "rounding": "midpoint-away-from-zero" },
  "grouping": { "first": 3, "repeat": 2 },
  "group_separator": ",",
  "decimal_separator": "."
}

The smaller types are single values rather than objects:

Type Shape Accepts
Currency "USD" the code of a known currency, unassigned codes rejected
Pair "USD/EUR" two codes split by a slash
CurrencyCode "ZZZ" one to eight capitals, assigned or not
IsoNumericCode 840 an integer of at most three digits
RoundingMode "midpoint-away-from-zero" or "midpoint-toward-zero", "midpoint-nearest-even", "away-from-zero", "to-zero", "to-positive-infinity", "to-negative-infinity"

Self-describing formats such as JSON, TOML, and YAML work. Formats without type information, such as bincode and postcard, do not.

custom-currencies

Off by default. Turning it on gives Currency::custom, which defines a currency outside ISO 4217, and register_currency!, which makes it reachable by code lookup: FromStr, Parser, Pair, and deserialization all find it.

[dependencies]
lucre = { version = "0.13.0", features = ["custom-currencies"] }
use lucre::{Currency, register_currency};

const BTC: Currency = Currency::custom("BTC", 8, "₿", "Bitcoin");
register_currency!(BTC);

assert_eq!("BTC".parse(), Ok(BTC));

Registration is compile-time only; currencies loaded from configuration or a database at runtime are out of scope. An unregistered custom currency still works for arithmetic and formatting — registration is only what makes its code reachable from text.

A registered code that shadows an ISO 4217 code, or one code registered twice with different data, is a bug in the program: the first lookup panics, naming the code. Identical duplicate registrations are tolerated.

Registration runs before main, through the inventory crate. On the platforms inventory supports (Linux, macOS, Windows, FreeBSD, Android, iOS, WebAssembly) this is transparent. On an unsupported platform the collection is silently empty: custom codes fail to parse or deserialize there, but nothing corrupts.

Maintainer

This project is maintained by Rosa Richter. For ways to contact her, see her contact page.

Contributing

Questions and contributions are welcome. Please create an issue for bugs, feature requests, or questions.

License

BSD-2-Clause-Patent © Rosa Richter