Skip to main content

payrail_core/
currency.rs

1use crate::PaymentError;
2
3/// ISO 4217 currency code.
4#[derive(Debug, Clone, PartialEq, Eq, Hash)]
5pub struct CurrencyCode(String);
6
7impl CurrencyCode {
8    /// Parses and validates an ISO 4217-style currency code.
9    ///
10    /// # Errors
11    ///
12    /// Returns an error when the code is not exactly three ASCII letters.
13    pub fn new(code: impl AsRef<str>) -> Result<Self, PaymentError> {
14        let code = code.as_ref().trim();
15        if code.len() != 3 || !code.bytes().all(|byte| byte.is_ascii_alphabetic()) {
16            return Err(PaymentError::InvalidCurrencyCode(code.to_owned()));
17        }
18
19        Ok(Self(code.to_ascii_uppercase()))
20    }
21
22    /// Returns the normalized currency code.
23    #[inline]
24    #[must_use]
25    pub fn as_str(&self) -> &str {
26        &self.0
27    }
28
29    /// Returns the ISO 4217 minor-unit exponent used by common providers.
30    #[inline]
31    #[must_use]
32    pub fn minor_unit_exponent(&self) -> u8 {
33        match self.0.as_str() {
34            "BIF" | "CLP" | "DJF" | "GNF" | "JPY" | "KMF" | "KRW" | "MGA" | "PYG" | "RWF"
35            | "UGX" | "VND" | "VUV" | "XAF" | "XOF" | "XPF" => 0,
36            "BHD" | "IQD" | "JOD" | "KWD" | "LYD" | "OMR" | "TND" => 3,
37            "CLF" | "UYW" => 4,
38            _ => 2,
39        }
40    }
41
42    /// Returns the multiplier between major and minor units.
43    #[inline]
44    #[must_use]
45    pub fn minor_unit_factor(&self) -> i64 {
46        10_i64.pow(u32::from(self.minor_unit_exponent()))
47    }
48
49    /// Formats a minor-unit amount as a provider decimal amount string.
50    #[must_use]
51    pub fn format_minor_units(&self, minor: i64) -> String {
52        let exponent = self.minor_unit_exponent();
53        if exponent == 0 {
54            return minor.to_string();
55        }
56
57        let factor = self.minor_unit_factor() as u64;
58        let sign = if minor < 0 { "-" } else { "" };
59        let absolute = minor.unsigned_abs();
60        let major = absolute / factor;
61        let fractional = absolute % factor;
62        let width = usize::from(exponent);
63        format!("{sign}{major}.{fractional:0width$}")
64    }
65
66    /// Converts a minor-unit amount into an integer major-unit amount.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error when the minor amount cannot be represented as a whole major unit.
71    pub fn minor_units_to_major_integer(&self, minor: i64) -> Result<i64, PaymentError> {
72        let factor = self.minor_unit_factor();
73        if minor % factor != 0 {
74            return Err(PaymentError::InvalidAmount(minor));
75        }
76
77        Ok(minor / factor)
78    }
79
80    /// Converts an integer major-unit amount into minor units.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error when the multiplication overflows.
85    pub fn major_integer_to_minor_units(&self, major: i64) -> Result<i64, PaymentError> {
86        major
87            .checked_mul(self.minor_unit_factor())
88            .ok_or(PaymentError::InvalidAmount(major))
89    }
90}
91
92impl AsRef<str> for CurrencyCode {
93    #[inline]
94    fn as_ref(&self) -> &str {
95        self.as_str()
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn new_uppercases_valid_code() {
105        let code = CurrencyCode::new("usd").expect("currency should be valid");
106
107        assert_eq!(code.as_str(), "USD");
108    }
109
110    #[test]
111    fn new_rejects_invalid_code() {
112        assert!(matches!(
113            CurrencyCode::new("US1"),
114            Err(PaymentError::InvalidCurrencyCode(_))
115        ));
116    }
117
118    #[test]
119    fn formats_minor_units_using_currency_exponent() {
120        let usd = CurrencyCode::new("USD").expect("currency should be valid");
121        let jpy = CurrencyCode::new("JPY").expect("currency should be valid");
122        let kwd = CurrencyCode::new("KWD").expect("currency should be valid");
123
124        assert_eq!(usd.format_minor_units(1234), "12.34");
125        assert_eq!(usd.format_minor_units(-1234), "-12.34");
126        assert_eq!(jpy.format_minor_units(1234), "1234");
127        assert_eq!(kwd.format_minor_units(1234), "1.234");
128    }
129
130    #[test]
131    fn converts_major_and_minor_units_without_truncation() {
132        let zmw = CurrencyCode::new("ZMW").expect("currency should be valid");
133
134        assert_eq!(
135            zmw.minor_units_to_major_integer(5_000)
136                .expect("minor amount should convert"),
137            50
138        );
139        assert!(matches!(
140            zmw.minor_units_to_major_integer(5_050),
141            Err(PaymentError::InvalidAmount(5_050))
142        ));
143        assert_eq!(
144            zmw.major_integer_to_minor_units(50)
145                .expect("major amount should convert"),
146            5_000
147        );
148    }
149}