use std::collections::BTreeMap;
use thiserror::Error;
use crate::{Currency, Decimal, Money, MoneyError};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct ExchangeRate {
from: Currency,
to: Currency,
rate: Decimal,
}
impl ExchangeRate {
pub fn new<R: Into<Decimal>>(
from: Currency,
to: Currency,
rate: R,
) -> Result<Self, InvalidRateError> {
let rate = rate.into();
if rate <= Decimal::ZERO {
return Err(InvalidRateError);
}
Ok(Self { from, to, rate })
}
#[must_use]
pub fn identity(currency: Currency) -> Self {
Self {
from: currency,
to: currency,
rate: Decimal::ONE,
}
}
#[must_use]
pub fn from(&self) -> Currency {
self.from
}
#[must_use]
pub fn to(&self) -> Currency {
self.to
}
#[must_use]
pub fn rate(&self) -> Decimal {
self.rate
}
pub fn convert(&self, money: &Money) -> Result<Money, MoneyError> {
if money.currency() != self.from {
return Err(MoneyError::CurrencyMismatch);
}
let amount = money
.amount()
.checked_mul(self.rate)
.ok_or(MoneyError::Overflow)?;
Ok(Money::from_decimal(amount, &self.to))
}
pub fn cross_with(&self, other: &Self) -> Result<Self, MoneyError> {
if self.to != other.from {
return Err(MoneyError::CurrencyMismatch);
}
let rate = self
.rate
.checked_mul(other.rate)
.filter(Decimal::is_sign_positive)
.filter(|rate| !rate.is_zero())
.ok_or(MoneyError::Overflow)?;
Ok(Self {
from: self.from,
to: other.to,
rate,
})
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Exchange {
rates: BTreeMap<(Currency, Currency), Decimal>,
}
impl Exchange {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn set_rate(&mut self, rate: &ExchangeRate) {
self.rates.insert((rate.from, rate.to), rate.rate);
}
#[must_use]
pub fn rate(&self, from: &Currency, to: &Currency) -> Option<ExchangeRate> {
self.rates
.get(&(*from, *to))
.map(|&rate| ExchangeRate {
from: *from,
to: *to,
rate,
})
.or_else(|| (from == to).then(|| ExchangeRate::identity(*from)))
}
pub fn quotes(&self) -> impl ExactSizeIterator<Item = ExchangeRate> {
self.rates
.iter()
.map(|(&(from, to), &rate)| ExchangeRate { from, to, rate })
}
}
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
#[error("an exchange rate must be a positive, nonzero multiplier")]
pub struct InvalidRateError;
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::prelude::*;
fn usd_eur() -> ExchangeRate {
ExchangeRate::new(Currency::USD, Currency::EUR, dec!(0.9)).unwrap()
}
#[test]
fn rate_refuses_a_zero_multiplier_test() {
assert_eq!(
ExchangeRate::new(Currency::USD, Currency::EUR, dec!(0)),
Err(InvalidRateError)
);
}
#[test]
fn rate_refuses_a_negative_multiplier_test() {
assert_eq!(
ExchangeRate::new(Currency::USD, Currency::EUR, dec!(-0.9)),
Err(InvalidRateError)
);
}
#[test]
fn rate_quotes_a_currency_against_itself_test() {
let rate = ExchangeRate::new(Currency::USD, Currency::USD, dec!(1)).unwrap();
assert_eq!(rate, ExchangeRate::identity(Currency::USD));
}
#[test]
fn identity_leaves_an_amount_alone_test() {
let fare = Money::from_minor(275, &Currency::USD);
assert_eq!(
ExchangeRate::identity(Currency::USD).convert(&fare),
Ok(fare)
);
}
#[test]
fn convert_multiplies_the_amount_test() {
assert_eq!(
usd_eur().convert(&Money::from_major(100, &Currency::USD)),
Ok(Money::from_major(90, &Currency::EUR))
);
}
#[test]
fn convert_keeps_the_scale_multiplying_reached_test() {
let converted = usd_eur()
.convert(&Money::from_minor(2550, &Currency::USD))
.unwrap();
assert_eq!(converted.amount(), dec!(22.950));
assert_eq!(converted.amount().scale(), 3);
}
#[test]
fn convert_refuses_another_currency_test() {
assert_eq!(
usd_eur().convert(&Money::from_major(10, &Currency::GBP)),
Err(MoneyError::CurrencyMismatch)
);
}
#[test]
fn convert_reports_an_unrepresentable_product_test() {
let steep = ExchangeRate::new(Currency::USD, Currency::EUR, Decimal::MAX).unwrap();
assert_eq!(
steep.convert(&Money::from_decimal(Decimal::MAX, &Currency::USD)),
Err(MoneyError::Overflow)
);
}
#[test]
fn cross_spans_both_legs_test() {
let eur_jpy = ExchangeRate::new(Currency::EUR, Currency::JPY, dec!(160)).unwrap();
let usd_jpy = usd_eur().cross_with(&eur_jpy).unwrap();
assert_eq!(usd_jpy.from(), Currency::USD);
assert_eq!(usd_jpy.to(), Currency::JPY);
assert_eq!(usd_jpy.rate(), dec!(144));
}
#[test]
fn cross_refuses_rates_that_do_not_meet_test() {
let gbp_jpy = ExchangeRate::new(Currency::GBP, Currency::JPY, dec!(190)).unwrap();
assert_eq!(
usd_eur().cross_with(&gbp_jpy),
Err(MoneyError::CurrencyMismatch)
);
}
#[test]
fn cross_reports_a_product_too_large_to_hold_test() {
let steep = ExchangeRate::new(Currency::EUR, Currency::JPY, Decimal::MAX).unwrap();
let steeper = ExchangeRate::new(Currency::USD, Currency::EUR, Decimal::MAX).unwrap();
assert_eq!(steeper.cross_with(&steep), Err(MoneyError::Overflow));
}
#[test]
fn cross_reports_a_product_too_small_to_hold_test() {
let slight = Decimal::new(1, 28);
let usd_eur = ExchangeRate::new(Currency::USD, Currency::EUR, slight).unwrap();
let eur_jpy = ExchangeRate::new(Currency::EUR, Currency::JPY, slight).unwrap();
assert_eq!(usd_eur.cross_with(&eur_jpy), Err(MoneyError::Overflow));
}
#[test]
fn exchange_answers_a_rate_it_was_given_test() {
let mut desk = Exchange::new();
desk.set_rate(&usd_eur());
assert_eq!(desk.rate(&Currency::USD, &Currency::EUR), Some(usd_eur()));
}
#[test]
fn exchange_keeps_the_directions_of_a_pair_apart_test() {
let mut desk = Exchange::new();
desk.set_rate(&usd_eur());
assert_eq!(desk.rate(&Currency::EUR, &Currency::USD), None);
}
#[test]
fn exchange_displaces_an_earlier_quote_test() {
let revised = ExchangeRate::new(Currency::USD, Currency::EUR, dec!(0.92)).unwrap();
let mut desk = Exchange::new();
desk.set_rate(&usd_eur());
desk.set_rate(&revised);
assert_eq!(desk.rate(&Currency::USD, &Currency::EUR), Some(revised));
}
#[test]
fn exchange_answers_par_for_a_currency_against_itself_test() {
assert_eq!(
Exchange::new().rate(&Currency::JPY, &Currency::JPY),
Some(ExchangeRate::identity(Currency::JPY))
);
}
#[test]
fn exchange_lets_a_quoted_pair_outrank_par_test() {
let discounted = ExchangeRate::new(Currency::USD, Currency::USD, dec!(0.99)).unwrap();
let mut desk = Exchange::new();
desk.set_rate(&discounted);
assert_eq!(desk.rate(&Currency::USD, &Currency::USD), Some(discounted));
}
#[test]
fn exchange_holds_nothing_to_begin_with_test() {
assert_eq!(Exchange::new().rate(&Currency::USD, &Currency::EUR), None);
}
}