use rust_decimal::Decimal;
use crate::errors::ValidationError;
use crate::traits::ValueObject;
use super::currency_code::CurrencyCode;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MoneyInput {
pub amount: Decimal,
pub currency: CurrencyCode,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
pub struct Money {
amount: Decimal,
currency: CurrencyCode,
canonical: String,
}
impl ValueObject for Money {
type Input = MoneyInput;
type Error = ValidationError;
fn new(value: Self::Input) -> Result<Self, Self::Error> {
let canonical = format!("{:.2} {}", value.amount, value.currency);
Ok(Self {
amount: value.amount,
currency: value.currency,
canonical,
})
}
fn into_inner(self) -> Self::Input {
MoneyInput {
amount: self.amount,
currency: self.currency,
}
}
}
impl Money {
pub fn value(&self) -> &str {
&self.canonical
}
pub fn amount(&self) -> &Decimal {
&self.amount
}
pub fn currency(&self) -> &CurrencyCode {
&self.currency
}
pub fn add(&self, other: &Money) -> Result<Money, ValidationError> {
if self.currency != other.currency {
return Err(ValidationError::invalid(
"Money",
&format!("cannot add {} and {}", self.currency, other.currency),
));
}
let sum = self.amount + other.amount;
let canonical = format!("{:.2} {}", sum, self.currency);
Ok(Money {
amount: sum,
currency: self.currency.clone(),
canonical,
})
}
pub fn sub(&self, other: &Money) -> Result<Money, ValidationError> {
if self.currency != other.currency {
return Err(ValidationError::invalid(
"Money",
&format!("cannot subtract {} and {}", self.currency, other.currency),
));
}
let diff = self.amount - other.amount;
let canonical = format!("{:.2} {}", diff, self.currency);
Ok(Money {
amount: diff,
currency: self.currency.clone(),
canonical,
})
}
pub fn neg(&self) -> Money {
let negated = -self.amount;
let canonical = format!("{:.2} {}", negated, self.currency);
Money {
amount: negated,
currency: self.currency.clone(),
canonical,
}
}
}
impl TryFrom<&str> for Money {
type Error = ValidationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let err = || ValidationError::invalid("Money", value);
let (amount_str, currency_str) = value.trim().rsplit_once(' ').ok_or_else(err)?;
let amount: rust_decimal::Decimal = amount_str.trim().parse().map_err(|_| err())?;
let currency = CurrencyCode::new(currency_str.trim().to_owned()).map_err(|_| err())?;
Self::new(MoneyInput { amount, currency })
}
}
#[cfg(feature = "serde")]
impl From<Money> for String {
fn from(v: Money) -> String {
v.canonical
}
}
impl TryFrom<String> for Money {
type Error = ValidationError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::try_from(s.as_str())
}
}
impl std::fmt::Display for Money {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.canonical)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::{PrimitiveValue, ValueObject};
fn eur() -> CurrencyCode {
CurrencyCode::new("EUR".into()).unwrap()
}
fn usd() -> CurrencyCode {
CurrencyCode::new("USD".into()).unwrap()
}
#[test]
fn constructs_valid_money() {
let m = Money::new(MoneyInput {
amount: "10.50".parse().unwrap(),
currency: eur(),
})
.unwrap();
assert_eq!(m.value(), "10.50 EUR");
}
#[test]
fn formats_with_two_decimal_places() {
let m = Money::new(MoneyInput {
amount: "100".parse().unwrap(),
currency: usd(),
})
.unwrap();
assert_eq!(m.value(), "100.00 USD");
}
#[test]
fn allows_negative_amount() {
let m = Money::new(MoneyInput {
amount: "-5.00".parse().unwrap(),
currency: eur(),
})
.unwrap();
assert_eq!(m.value(), "-5.00 EUR");
}
#[test]
fn allows_zero_amount() {
let m = Money::new(MoneyInput {
amount: Decimal::ZERO,
currency: eur(),
})
.unwrap();
assert_eq!(m.value(), "0.00 EUR");
}
#[test]
fn amount_accessor() {
let m = Money::new(MoneyInput {
amount: "42.00".parse().unwrap(),
currency: eur(),
})
.unwrap();
assert_eq!(m.amount(), &"42.00".parse::<Decimal>().unwrap());
}
#[test]
fn currency_accessor() {
let m = Money::new(MoneyInput {
amount: Decimal::ZERO,
currency: eur(),
})
.unwrap();
assert_eq!(m.currency().value(), "EUR");
}
#[test]
fn display_matches_value() {
let m = Money::new(MoneyInput {
amount: "9.99".parse().unwrap(),
currency: usd(),
})
.unwrap();
assert_eq!(m.to_string(), m.value().to_owned());
}
#[test]
fn add_same_currency() {
let a = Money::new(MoneyInput {
amount: "10.00".parse().unwrap(),
currency: eur(),
})
.unwrap();
let b = Money::new(MoneyInput {
amount: "5.50".parse().unwrap(),
currency: eur(),
})
.unwrap();
let result = a.add(&b).unwrap();
assert_eq!(result.value(), "15.50 EUR");
}
#[test]
fn add_different_currencies_fails() {
let a = Money::new(MoneyInput {
amount: "10.00".parse().unwrap(),
currency: eur(),
})
.unwrap();
let b = Money::new(MoneyInput {
amount: "5.00".parse().unwrap(),
currency: usd(),
})
.unwrap();
assert!(a.add(&b).is_err());
}
#[test]
fn sub_same_currency() {
let a = Money::new(MoneyInput {
amount: "10.00".parse().unwrap(),
currency: eur(),
})
.unwrap();
let b = Money::new(MoneyInput {
amount: "3.00".parse().unwrap(),
currency: eur(),
})
.unwrap();
let result = a.sub(&b).unwrap();
assert_eq!(result.value(), "7.00 EUR");
}
#[test]
fn neg_returns_negated_amount() {
let m = Money::new(MoneyInput {
amount: "10.00".parse().unwrap(),
currency: eur(),
})
.unwrap();
assert_eq!(m.neg().value(), "-10.00 EUR");
}
#[test]
fn into_inner_roundtrip() {
let input = MoneyInput {
amount: "1.00".parse().unwrap(),
currency: eur(),
};
let m = Money::new(input.clone()).unwrap();
assert_eq!(m.into_inner(), input);
}
#[test]
fn try_from_parses_valid() {
let m = Money::try_from("10.50 EUR").unwrap();
assert_eq!(m.value(), "10.50 EUR");
}
#[test]
fn try_from_rejects_no_space() {
assert!(Money::try_from("10.50EUR").is_err());
}
#[test]
fn try_from_rejects_invalid_currency() {
assert!(Money::try_from("10.50 INVALID").is_err());
}
#[cfg(feature = "serde")]
#[test]
fn serde_roundtrip() {
let v = Money::try_from("10.50 EUR").unwrap();
let json = serde_json::to_string(&v).unwrap();
let back: Money = serde_json::from_str(&json).unwrap();
assert_eq!(v.value(), back.value());
}
#[cfg(feature = "serde")]
#[test]
fn serde_serializes_as_canonical_string() {
let v = Money::try_from("10.50 EUR").unwrap();
let json = serde_json::to_string(&v).unwrap();
assert!(json.contains("10.50 EUR"));
}
}