lucre 0.13.0

An ergonomic library for handling money.
Documentation
# lucre

[![status-badge](https://ci.cosmicrose.dev/api/badges/43/status.svg)](https://ci.cosmicrose.dev/repos/43)
[![Crates.io Version](https://img.shields.io/crates/v/lucre)](https://crates.io/crates/lucre)
[![docs.rs](https://img.shields.io/docsrs/lucre)](https://docs.rs/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.

[ISO 4217]: https://en.wikipedia.org/wiki/ISO_4217

## Install

Add `lucre` to your `Cargo.toml`.

```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.

```rust
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.

```rust
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.

```rust
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.

```rust
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.

```toml
[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.

```json
{ "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.

```json
{ "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`.

```json
{ "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.

```json
{ "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.

```json
{
  "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](https://cosmicrose.dev).
For ways to contact her, see her [contact page](https://cosmicrose.dev/contact/).

## Contributing

Questions and contributions are welcome. Please
[create an issue](https://code.cosmicrose.dev/rosa/lucre/issues/new) for bugs,
feature requests, or questions.

## License

[BSD-2-Clause-Patent](https://spdx.org/licenses/BSD-2-Clause-Patent.html) © Rosa Richter