use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Currency {
Try,
Usd,
Eur,
Gbp,
}
impl Currency {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Try => "TRY",
Self::Usd => "USD",
Self::Eur => "EUR",
Self::Gbp => "GBP",
}
}
#[must_use]
pub const fn exponent(self) -> u32 {
match self {
Self::Try | Self::Usd | Self::Eur | Self::Gbp => 2,
}
}
}
impl fmt::Display for Currency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.code())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unsupported currency code: {0}")]
pub struct UnknownCurrency(pub String);
impl FromStr for Currency {
type Err = UnknownCurrency;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_uppercase().as_str() {
"TRY" => Ok(Self::Try),
"USD" => Ok(Self::Usd),
"EUR" => Ok(Self::Eur),
"GBP" => Ok(Self::Gbp),
_ => Err(UnknownCurrency(s.to_owned())),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Money {
minor_units: i64,
currency: Currency,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MoneyError {
#[error("`{0}` is not a decimal amount")]
NotDecimal(String),
#[error("`{value}` has more than {exponent} decimal places for {currency}")]
TooPrecise {
value: String,
currency: Currency,
exponent: u32,
},
#[error("`{0}` does not fit in 64 bits of minor units")]
Overflow(String),
#[error("amount must be positive, got {0}")]
NotPositive(i64),
}
impl Money {
#[must_use]
pub const fn from_minor_units(minor_units: i64, currency: Currency) -> Self {
Self {
minor_units,
currency,
}
}
pub fn parse(value: &str, currency: Currency) -> Result<Self, MoneyError> {
let text = value.trim();
let (sign, digits) = match text.strip_prefix('-') {
Some(rest) => (-1i64, rest),
None => (1i64, text.strip_prefix('+').unwrap_or(text)),
};
let (whole, frac) = match digits.split_once('.') {
Some((w, f)) => (w, f),
None => (digits, ""),
};
let numeric = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
if !numeric(whole) || (!frac.is_empty() && !numeric(frac)) {
return Err(MoneyError::NotDecimal(value.to_owned()));
}
let exponent = currency.exponent();
let places = u32::try_from(frac.len()).unwrap_or(u32::MAX);
if places > exponent {
return Err(MoneyError::TooPrecise {
value: value.to_owned(),
currency,
exponent,
});
}
let mut padded = String::with_capacity(whole.len() + frac.len() + 1);
padded.push_str(whole);
padded.push_str(frac);
for _ in 0..(exponent - places) {
padded.push('0');
}
let minor_units: i64 = padded
.parse()
.map_err(|_| MoneyError::Overflow(value.to_owned()))?;
Ok(Self {
minor_units: sign * minor_units,
currency,
})
}
#[must_use]
pub const fn minor_units(self) -> i64 {
self.minor_units
}
#[must_use]
pub const fn currency(self) -> Currency {
self.currency
}
pub fn require_positive(self) -> Result<Self, MoneyError> {
if self.minor_units > 0 {
Ok(self)
} else {
Err(MoneyError::NotPositive(self.minor_units))
}
}
#[must_use]
pub fn to_decimal_string(self) -> String {
let exponent = self.currency.exponent();
let scale = 10u64.pow(exponent);
let sign = if self.minor_units < 0 { "-" } else { "" };
let magnitude = self.minor_units.unsigned_abs();
if exponent == 0 {
return format!("{sign}{magnitude}");
}
format!(
"{sign}{}.{:0>width$}",
magnitude / scale,
magnitude % scale,
width = usize::try_from(exponent).unwrap_or(usize::MAX)
)
}
}
impl fmt::Display for Money {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.to_decimal_string(), self.currency)
}
}
#[cfg(test)]
mod tests {
use super::{Currency, Money, MoneyError};
#[test]
fn parses_and_renders_a_two_place_amount() {
let money = Money::parse("10.50", Currency::Try).expect("valid amount");
assert_eq!(money.minor_units(), 1050);
assert_eq!(money.to_decimal_string(), "10.50");
}
#[test]
fn pads_a_missing_fractional_part() {
assert_eq!(
Money::parse("7", Currency::Usd)
.expect("valid")
.minor_units(),
700
);
assert_eq!(
Money::parse("7.5", Currency::Usd)
.expect("valid")
.minor_units(),
750
);
}
#[test]
fn renders_amounts_below_one_with_a_leading_zero() {
let money = Money::from_minor_units(5, Currency::Try);
assert_eq!(money.to_decimal_string(), "0.05");
}
#[test]
fn rejects_more_precision_than_the_currency_has() {
let err = Money::parse("10.505", Currency::Try).expect_err("too precise");
assert!(matches!(err, MoneyError::TooPrecise { .. }));
}
#[test]
fn rejects_text_that_is_not_a_number() {
assert!(matches!(
Money::parse("ten", Currency::Try),
Err(MoneyError::NotDecimal(_))
));
assert!(matches!(
Money::parse("", Currency::Try),
Err(MoneyError::NotDecimal(_))
));
assert!(matches!(
Money::parse("1.2.3", Currency::Try),
Err(MoneyError::NotDecimal(_))
));
}
#[test]
fn round_trips_through_its_decimal_form() {
for minor in [1i64, 5, 99, 100, 101, 1050, 123_456_789] {
let money = Money::from_minor_units(minor, Currency::Eur);
let back = Money::parse(&money.to_decimal_string(), Currency::Eur).expect("valid");
assert_eq!(back, money);
}
}
#[test]
fn require_positive_rejects_zero() {
let zero = Money::from_minor_units(0, Currency::Try);
assert!(matches!(
zero.require_positive(),
Err(MoneyError::NotPositive(0))
));
}
}