lucre 0.10.0

An ergonomic library for handling money.
# 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 in your applications without fussing over generics or
lifetimes, while still being safe and fast. [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.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.

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

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

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

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

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

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

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

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

## Contributing

Questions and contributions are absolutely 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