- Rust 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .claude | ||
| .out-of-scope | ||
| .woodpecker | ||
| docs | ||
| src | ||
| tests | ||
| .editorconfig | ||
| .gitignore | ||
| build.rs | ||
| Cargo.lock | ||
| Cargo.toml | ||
| clippy.toml | ||
| CODING_STANDARDS.md | ||
| CONTEXT.md | ||
| isodata.tsv | ||
| LICENSE | ||
| mise.toml | ||
| README.md | ||
| release.toml | ||
| renovate.json | ||
lucre
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