Skip to main content

rustlavel_payment/
money.rs

1//! An amount of money.
2//!
3//! **An integer of minor units, never a float.** A float can hold 0.1 only
4//! approximately, and an application that adds ten of them does not get 1.0.
5//! Rupiah has no minor unit in practice but the rule is the same: the amount
6//! is the whole number of the smallest unit the currency has, and arithmetic
7//! on it is exact.
8
9use rustlavel_core::{Error, Json, Result};
10use std::fmt;
11
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub struct Money {
14    /// Minor units: cents, sen. For IDR the minor unit is the rupiah itself.
15    pub minor: i64,
16    /// ISO 4217, upper case.
17    pub currency: String,
18}
19
20impl Money {
21    pub fn new(minor: i64, currency: impl Into<String>) -> Money {
22        Money { minor, currency: currency.into().to_ascii_uppercase() }
23    }
24
25    /// Rupiah, which is what the first driver deals in.
26    pub fn idr(rupiah: i64) -> Money {
27        Money::new(rupiah, "IDR")
28    }
29
30    /// A charge for nothing or for less than nothing is a mistake upstream,
31    /// and a gateway asked for one answers with an error that names nothing.
32    pub fn positive(&self) -> Result<()> {
33        if self.minor <= 0 {
34            return Err(Error::msg(format!(
35                "an amount must be above zero; {} {} is not",
36                self.minor, self.currency
37            )));
38        }
39        Ok(())
40    }
41
42    pub fn is_zero(&self) -> bool {
43        self.minor == 0
44    }
45
46    /// Add, refusing to mix currencies. Silently adding 100 IDR to 100 USD
47    /// produces a number that is neither.
48    pub fn plus(&self, other: &Money) -> Result<Money> {
49        self.same_currency(other)?;
50        self.minor
51            .checked_add(other.minor)
52            .map(|minor| Money::new(minor, &self.currency))
53            .ok_or_else(|| Error::msg("the amount overflowed"))
54    }
55
56    pub fn minus(&self, other: &Money) -> Result<Money> {
57        self.same_currency(other)?;
58        self.minor
59            .checked_sub(other.minor)
60            .map(|minor| Money::new(minor, &self.currency))
61            .ok_or_else(|| Error::msg("the amount overflowed"))
62    }
63
64    fn same_currency(&self, other: &Money) -> Result<()> {
65        if self.currency != other.currency {
66            return Err(Error::msg(format!(
67                "cannot combine {} with {}: different currencies",
68                self.currency, other.currency
69            )));
70        }
71        Ok(())
72    }
73
74    pub fn to_json(&self) -> Json {
75        Json::object([
76            ("minor", Json::from(self.minor)),
77            ("currency", Json::from(self.currency.as_str())),
78        ])
79    }
80
81    pub fn from_json(json: &Json) -> Option<Money> {
82        let minor = json.get("minor").and_then(Json::as_i64)?;
83        let currency = json.get("currency").and_then(Json::as_str)?;
84        Some(Money::new(minor, currency))
85    }
86}
87
88impl fmt::Display for Money {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        // "IDR 150000" — the currency first, so a column of amounts lines up
91        // and a reader never has to guess which one they are looking at.
92        write!(f, "{} {}", self.currency, self.minor)
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn arithmetic_is_exact_and_refuses_to_mix_currencies() {
102        let a = Money::idr(150_000);
103        let b = Money::idr(25_000);
104        assert_eq!(a.plus(&b).unwrap(), Money::idr(175_000));
105        assert_eq!(a.minus(&b).unwrap(), Money::idr(125_000));
106        assert!(a.plus(&Money::new(1, "USD")).is_err(), "currencies were mixed");
107    }
108
109    #[test]
110    fn a_charge_for_nothing_is_refused_before_it_reaches_a_gateway() {
111        assert!(Money::idr(0).positive().is_err());
112        assert!(Money::idr(-1).positive().is_err());
113        assert!(Money::idr(1).positive().is_ok());
114    }
115
116    #[test]
117    fn overflow_is_an_error_rather_than_a_wrap() {
118        assert!(Money::idr(i64::MAX).plus(&Money::idr(1)).is_err());
119    }
120
121    #[test]
122    fn survives_json() {
123        let money = Money::idr(99_000);
124        assert_eq!(Money::from_json(&money.to_json()), Some(money));
125        assert_eq!(Money::new(5, "usd").currency, "USD");
126    }
127}