lucre 0.13.0

An ergonomic library for handling money.
Documentation

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 rate you already have and lucre does the arithmetic. An Exchange holds a set of rates and converts amounts with them.

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 fare = Money::from_major(100, Currency::USD);

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

    // A pair with no rate can be crossed from two rates sharing a currency
    let usd_eur = desk
        .rate((Currency::USD, Currency::EUR))
        .ok_or("no rate for USD/EUR")?;
    let eur_jpy = desk
        .rate((Currency::EUR, Currency::JPY))
        .ok_or("no rate for EUR/JPY")?;
    let usd_jpy = usd_eur.cross_with(eur_jpy)?;

    assert_eq!(usd_jpy.rate(), dec!(144.0));

    // A conversion keeps every digit of the result. Round when you need to.
    let converted = usd_jpy.convert(Money::from_minor(2599, Currency::USD))?;

    assert_eq!(converted.amount(), dec!(3742.5600));

    Ok(())
}

Each rate applies in one direction. A rate for USD against EUR says nothing about EUR against USD. Iterating a MoneyBag yields each currency's balance in ISO alphabetic order, so you can convert a whole bag one balance at a time.

A Pair is the two currencies without a rate.

use std::error::Error;

use lucre::{Currency, Pair};

fn main() -> Result<(), Box<dyn Error>> {
    let watched: Pair = "USD/JPY".parse()?;

    assert_eq!(watched.base(), Currency::USD);
    assert_eq!(watched.quote(), Currency::JPY);
    assert_eq!(watched.to_string(), "USD/JPY");

    Ok(())
}

Features

serde

Off by default. Turning it on gives Money, MoneyBag, ExchangeRate, Exchange, Pair, Currency, Format, IsoAlphabeticCode, IsoNumericCode, and RoundingMode a Serialize and a Deserialize impl.

[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 rate states its pair and the multiplier between them. The base is the currency being priced and the quote is the currency it is priced in. A rate of zero or less is rejected, as in ExchangeRate::new.

{ "base": "USD", "quote": "EUR", "rate": "0.9" }

An Exchange is one rate per pair, keyed as BASE/QUOTE. Each direction is its own entry, and a pair named twice keeps the rate given last.

{ "USD/EUR": "0.9", "EUR/USD": "1.1" }

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": "half-up" },
  "grouping": { "first": 3, "repeat": 2 },
  "group_separator": ",",
  "decimal_separator": "."
}

The smaller types are single values rather than objects:

Type Shape Accepts
Currency "USD" the three-letter code, unassigned codes rejected
Pair "USD/EUR" two codes split by a slash
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"

Self-describing formats such as JSON, TOML, and YAML work. Formats without type information, 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 welcome. Please create an issue for bugs, feature requests, or questions.

License

BSD-2-Clause-Patent © Rosa Richter