use std::collections::BTreeMap;
use std::iter::Sum;
use std::ops::{Add, AddAssign};
pub use iso_currency::Currency;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Money {
minor_units: u64,
currency: Currency,
}
impl Money {
#[must_use]
pub const fn from_minor_units(minor_units: u64, currency: Currency) -> Self {
Money {
minor_units,
currency,
}
}
#[must_use]
pub const fn minor_units(&self) -> u64 {
self.minor_units
}
#[must_use]
pub const fn currency(&self) -> Currency {
self.currency
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct MultiCurrencyAmount(BTreeMap<Currency, u64>);
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for MultiCurrencyAmount {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = BTreeMap::<Currency, u64>::deserialize(deserializer)?;
Ok(raw
.into_iter()
.map(|(currency, amount)| Money::from_minor_units(amount, currency))
.collect())
}
}
impl MultiCurrencyAmount {
#[must_use]
pub const fn new() -> Self {
MultiCurrencyAmount(BTreeMap::new())
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn in_currency(&self, currency: Currency) -> Option<Money> {
self.0
.get(¤cy)
.map(|&amount| Money::from_minor_units(amount, currency))
}
pub fn iter(&self) -> impl Iterator<Item = Money> + '_ {
self.into_iter()
}
fn add_money(&mut self, money: Money) {
if money.minor_units() == 0 {
return;
}
let slot = self.0.entry(money.currency()).or_insert(0);
*slot = slot.saturating_add(money.minor_units());
}
}
impl Extend<Money> for MultiCurrencyAmount {
fn extend<I: IntoIterator<Item = Money>>(&mut self, iter: I) {
for money in iter {
self.add_money(money);
}
}
}
impl FromIterator<Money> for MultiCurrencyAmount {
fn from_iter<I: IntoIterator<Item = Money>>(iter: I) -> Self {
let mut total = Self::new();
total.extend(iter);
total
}
}
impl From<Money> for MultiCurrencyAmount {
fn from(money: Money) -> Self {
let mut total = Self::new();
total += money;
total
}
}
impl Add for Money {
type Output = MultiCurrencyAmount;
fn add(self, rhs: Self) -> MultiCurrencyAmount {
let mut total = MultiCurrencyAmount::new();
total += self;
total += rhs;
total
}
}
impl Add<Money> for MultiCurrencyAmount {
type Output = Self;
fn add(mut self, rhs: Money) -> Self {
self += rhs;
self
}
}
impl Add<MultiCurrencyAmount> for Money {
type Output = MultiCurrencyAmount;
fn add(self, mut rhs: MultiCurrencyAmount) -> MultiCurrencyAmount {
rhs += self;
rhs
}
}
impl AddAssign<Money> for MultiCurrencyAmount {
fn add_assign(&mut self, money: Money) {
self.add_money(money);
}
}
impl AddAssign for MultiCurrencyAmount {
fn add_assign(&mut self, rhs: Self) {
for (currency, amount) in rhs.0 {
self.add_money(Money::from_minor_units(amount, currency));
}
}
}
impl Add for MultiCurrencyAmount {
type Output = Self;
fn add(mut self, rhs: Self) -> Self {
self += rhs;
self
}
}
impl Sum for MultiCurrencyAmount {
fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
iter.fold(Self::new(), Add::add)
}
}
impl Sum<Money> for MultiCurrencyAmount {
fn sum<I: Iterator<Item = Money>>(iter: I) -> Self {
Self::from_iter(iter)
}
}
impl<'a> IntoIterator for &'a MultiCurrencyAmount {
type Item = Money;
type IntoIter = std::iter::Map<
std::collections::btree_map::Iter<'a, Currency, u64>,
fn((&Currency, &u64)) -> Money,
>;
fn into_iter(self) -> Self::IntoIter {
self.0
.iter()
.map(|(¤cy, &amount)| Money::from_minor_units(amount, currency))
}
}
#[cfg(test)]
pub mod test_utils {
use proptest::prelude::*;
use super::{Currency, Money};
pub const CURRENCIES: [Currency; 4] =
[Currency::EUR, Currency::USD, Currency::GBP, Currency::JPY];
pub fn currency_strategy() -> impl Strategy<Value = Currency> {
prop::sample::select(CURRENCIES.to_vec())
}
pub fn money_strategy(amount: impl Strategy<Value = u64>) -> impl Strategy<Value = Money> {
(currency_strategy(), amount).prop_map(|(c, a)| Money::from_minor_units(a, c))
}
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::test_utils::{CURRENCIES, currency_strategy, money_strategy};
use super::{Currency, Money, MultiCurrencyAmount};
proptest! {
#[test]
fn same_currency_amounts_merge(c in currency_strategy(), a in 0u64..1_000_000, b in 0u64..1_000_000) {
let total = Money::from_minor_units(a, c) + Money::from_minor_units(b, c);
let expected = (a + b != 0).then(|| Money::from_minor_units(a + b, c));
prop_assert_eq!(total.in_currency(c), expected);
}
#[test]
fn different_currencies_stay_separate(
c1 in currency_strategy(), c2 in currency_strategy(),
a in 1u64..1_000_000, b in 1u64..1_000_000,
) {
prop_assume!(c1 != c2);
let total = Money::from_minor_units(a, c1) + Money::from_minor_units(b, c2);
prop_assert_eq!(total.iter().count(), 2);
prop_assert_eq!(total.in_currency(c1), Some(Money::from_minor_units(a, c1)));
prop_assert_eq!(total.in_currency(c2), Some(Money::from_minor_units(b, c2)));
}
#[test]
fn addition_saturates_instead_of_overflowing(c in currency_strategy(), a in any::<u64>(), b in any::<u64>()) {
let total = Money::from_minor_units(a, c) + Money::from_minor_units(b, c);
let sum = a.saturating_add(b);
let expected = (sum != 0).then(|| Money::from_minor_units(sum, c));
prop_assert_eq!(total.in_currency(c), expected);
}
#[test]
fn sum_merges_by_currency(amounts in prop::collection::vec(money_strategy(0u64..100_000), 0..12)) {
let total: MultiCurrencyAmount = amounts.iter().copied().sum();
for c in CURRENCIES {
let manual: u64 = amounts
.iter()
.filter(|m| m.currency() == c)
.map(Money::minor_units)
.sum();
prop_assert_eq!(total.in_currency(c).map_or(0, |m| m.minor_units()), manual);
}
}
#[test]
fn from_money_round_trips(c in currency_strategy(), a in 0u64..1_000_000) {
let m = Money::from_minor_units(a, c);
let expected = (a != 0).then_some(m);
prop_assert_eq!(MultiCurrencyAmount::from(m).in_currency(c), expected);
}
#[test]
fn zero_is_the_additive_identity(c in currency_strategy(), a in 1u64..1_000_000) {
let base = MultiCurrencyAmount::from(Money::from_minor_units(a, c));
let mut with_zero = base.clone();
with_zero += Money::from_minor_units(0, c);
prop_assert_eq!(base, with_zero);
}
}
#[test]
fn new_amount_is_empty() {
assert!(MultiCurrencyAmount::new().is_empty());
}
#[test]
fn adding_only_zeros_stays_empty() {
let total =
Money::from_minor_units(0, Currency::EUR) + Money::from_minor_units(0, Currency::USD);
assert!(total.is_empty());
assert_eq!(total.in_currency(Currency::EUR), None);
}
#[test]
fn the_addition_operators_compose() {
let total = Money::from_minor_units(1, Currency::GBP)
+ (Money::from_minor_units(880, Currency::EUR)
+ Money::from_minor_units(300, Currency::USD))
+ Money::from_minor_units(9, Currency::GBP);
assert_eq!(
total.in_currency(Currency::GBP),
Some(Money::from_minor_units(10, Currency::GBP))
);
assert_eq!(
total.in_currency(Currency::EUR),
Some(Money::from_minor_units(880, Currency::EUR))
);
}
}
#[cfg(all(test, feature = "serde"))]
mod serde_tests {
use proptest::prelude::*;
use super::test_utils::money_strategy;
use super::{Money, MultiCurrencyAmount};
proptest! {
#[test]
fn money_serde_roundtrip(money in money_strategy(any::<u64>())) {
let json = serde_json::to_string(&money).unwrap();
prop_assert_eq!(serde_json::from_str::<Money>(&json).unwrap(), money);
}
#[test]
fn multi_currency_amount_serde_roundtrip(
amounts in prop::collection::vec(money_strategy(any::<u64>()), 0..8),
) {
let bag: MultiCurrencyAmount = amounts.into_iter().sum();
let json = serde_json::to_string(&bag).unwrap();
prop_assert_eq!(serde_json::from_str::<MultiCurrencyAmount>(&json).unwrap(), bag);
}
}
#[test]
fn deserializing_a_zero_entry_drops_it() {
let json = r#"{"EUR":0,"USD":500}"#;
let amount: MultiCurrencyAmount = serde_json::from_str(json).unwrap();
assert_eq!(amount.in_currency(super::Currency::EUR), None);
assert_eq!(
amount.in_currency(super::Currency::USD),
Some(Money::from_minor_units(500, super::Currency::USD))
);
}
}