rustlavel_payment/
money.rs1use rustlavel_core::{Error, Json, Result};
10use std::fmt;
11
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub struct Money {
14 pub minor: i64,
16 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 pub fn idr(rupiah: i64) -> Money {
27 Money::new(rupiah, "IDR")
28 }
29
30 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 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 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}