- Rust 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .woodpecker | ||
| src | ||
| .editorconfig | ||
| .gitignore | ||
| build.rs | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| isodata.tsv | ||
| LICENSE | ||
| mise.toml | ||
| README.md | ||
| release.toml | ||
| renovate.json | ||
lucre
An ergonomic Rust library for handling money.
Represent money in your applications without fussing over generics or lifetimes, while still being safe and fast. ISO 4217 currency definitions are built in.
Install
Add lucre to your Cargo.toml.
[dependencies]
lucre = "0.10.0"
Usage
The Money struct is the primary interface, with the Currency struct supporting it.
Currency contains constants for all current ISO 4217 currencies.
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 is available as either checked or panicking operations
let _unchecked = subtotal + tax;
let total = subtotal.checked_add(&tax)?;
// The display format has sensible defaults...
assert_eq!(total.to_string(), "104.75 USD");
// ...which can be overridden
let format = Format::default().symbol();
assert_eq!(total.format_with(&format).to_string(), "$104.75");
Ok(())
}
Several currencies at once
Money refuses to mix currencies — + panics and comparisons answer None.
When amounts in different currencies have to travel together, put them in a
MoneyBag, which keeps each currency on its own books.
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 wallet has never held answers zero, not nothing
assert_eq!(
wallet.balance(&Currency::JPY),
Money::from_major(0, &Currency::JPY)
);
// Both sums are available over the same iterator, so the type asked for is
// the decision: `Option<Money>` insists the currencies match, a bag keeps
// them apart
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");
// Once the currencies disagree, only the bag still answers
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 are live data rather than a standard, so lucre quotes none of
its own. State a rate you already have and it handles the arithmetic; an
Exchange keeps a set of them to look up by currency pair.
use std::error::Error;
use lucre::{Currency, Exchange, ExchangeRate, Money};
use rust_decimal::dec;
fn main() -> Result<(), Box<dyn Error>> {
let mut desk = Exchange::new();
desk.set_rate(&ExchangeRate::new(Currency::USD, Currency::EUR, dec!(0.9))?);
desk.set_rate(&ExchangeRate::new(Currency::EUR, Currency::JPY, dec!(160))?);
let usd_eur = desk
.rate(&Currency::USD, &Currency::EUR)
.ok_or("the desk quotes USD against EUR")?;
assert_eq!(
usd_eur.convert(&Money::from_major(100, &Currency::USD))?,
Money::from_major(90, &Currency::EUR)
);
// A pair the desk does not quote can be crossed through one it does
let eur_jpy = desk
.rate(&Currency::EUR, &Currency::JPY)
.ok_or("the desk quotes EUR against JPY")?;
let usd_jpy = usd_eur.cross_with(&eur_jpy)?;
assert_eq!(usd_jpy.rate(), dec!(144.0));
// A conversion keeps every digit multiplying reached; round when it is due
let converted = usd_jpy.convert(&Money::from_minor(2599, &Currency::USD))?;
assert_eq!(converted.amount(), dec!(3742.5600));
Ok(())
}
Rates are held in one direction at a time, so quoting dollars against euros
says nothing about euros against dollars. Iterating a MoneyBag yields each
currency's balance in ISO alphabetic order, which is enough to price a whole
bag against a rate apiece.
Features
serde
Off by default. Turning it on gives Money, MoneyBag, ExchangeRate,
Exchange, Currency, IsoAlphabeticCode, IsoNumericCode, and
RoundingMode a Serialize and a Deserialize impl.
[dependencies]
lucre = { version = "0.10.0", features = ["serde"] }
Amounts and rates travel as text, which carries a fraction exactly and keeps the scale the figure was built with. Numbers are read as well, floats included, but only text makes the round trip untouched.
{ "amount": "104.75", "currency": "USD" }
A bag is a balance per currency, keyed by ISO alphabetic code. Reading one totals whatever the document says, rather than insisting it already be in the shape a bag would have written: a zero balance leaves no currency behind, and a currency named twice is summed.
{ "EUR": "10.00", "USD": "30.00" }
A quote states the pair it spans and the multiplier between them, and a rate of
zero or less is refused on the way in, as ExchangeRate::new refuses it.
{ "from": "USD", "to": "EUR", "rate": "0.9" }
An Exchange is a rate per pair, keyed the way a rate board names one. Each
direction is an entry of its own, and a pair named twice keeps the rate given
last.
{ "USD/EUR": "0.9", "EUR/USD": "1.1" }
The smaller types are single values rather than objects:
| Type | Shape | Accepts |
|---|---|---|
Currency |
"USD" |
the three-letter code, unassigned codes refused |
IsoAlphabeticCode |
"ZZZ" |
three capitals, assigned or not |
IsoNumericCode |
840 |
an integer of at most three digits |
RoundingMode |
"half-up" |
or "half-down", or "half-even" |
JSON, TOML, YAML and their kin work. Formats that carry no type tags, such as bincode and postcard, do not.
Maintainer
This project is maintained by Rosa Richter. For ways to contact her, see her contact page.
Contributing
Questions and contributions are absolutely welcome! Please create an issue for bugs, feature requests, or questions.
License
BSD-2-Clause-Patent © Rosa Richter