Skip to main content

iran_pay/
amount.rs

1//! Iranian currency [`Amount`].
2//!
3//! Iranian retail prices are quoted in **Tomans** but the official currency
4//! and every payment gateway's API expects **Rials** (1 Toman = 10 Rials).
5//! Mixing the two by accident is the single most common bug in Iranian
6//! payment integrations.
7//!
8//! [`Amount`] forces you to be explicit at construction time and stores
9//! everything internally in Rials, so the API surface to gateways is
10//! always correct.
11
12use std::fmt;
13
14use serde::{Deserialize, Serialize};
15
16/// The two Iranian currency units.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub enum Currency {
19    /// تومان — what merchants quote prices in.  1 Toman = 10 Rials.
20    Toman,
21    /// ریال — the official unit and what every gateway API expects.
22    Rial,
23}
24
25impl fmt::Display for Currency {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        f.write_str(match self {
28            Currency::Toman => "Toman",
29            Currency::Rial => "Rial",
30        })
31    }
32}
33
34/// A monetary amount, stored internally in Rials.
35///
36/// Construct with [`Amount::toman`] or [`Amount::rial`].  The gateway drivers
37/// use [`Amount::as_rials`] to send the request body, so accidental
38/// unit mix-ups are impossible.
39///
40/// ```
41/// use iran_pay::Amount;
42///
43/// let price = Amount::toman(50_000);
44/// assert_eq!(price.as_rials(),  500_000);
45/// assert_eq!(price.as_tomans(), 50_000);
46/// ```
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
48pub struct Amount {
49    rials: i64,
50}
51
52impl Amount {
53    /// Construct from a Toman amount (multiplied by 10 internally).
54    #[must_use]
55    pub const fn toman(value: i64) -> Self {
56        Self {
57            rials: value.saturating_mul(10),
58        }
59    }
60
61    /// Construct from a Rial amount.
62    #[must_use]
63    pub const fn rial(value: i64) -> Self {
64        Self { rials: value }
65    }
66
67    /// Construct from a value paired with a [`Currency`].
68    #[must_use]
69    pub const fn new(value: i64, currency: Currency) -> Self {
70        match currency {
71            Currency::Toman => Self::toman(value),
72            Currency::Rial => Self::rial(value),
73        }
74    }
75
76    /// The amount expressed in Rials (always exact).
77    #[must_use]
78    pub const fn as_rials(&self) -> i64 {
79        self.rials
80    }
81
82    /// The amount expressed in Tomans (truncated toward zero if not divisible
83    /// by 10 — but Iranian gateways always operate on multiples of 10 Rials,
84    /// so in practice this is exact).
85    #[must_use]
86    pub const fn as_tomans(&self) -> i64 {
87        self.rials / 10
88    }
89
90    /// Returns `true` if the amount is exactly zero Rials.
91    #[must_use]
92    pub const fn is_zero(&self) -> bool {
93        self.rials == 0
94    }
95}
96
97impl fmt::Display for Amount {
98    /// `123,000 Rial` (with thousand separators).
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        let mut s = String::new();
101        let abs = self.rials.unsigned_abs().to_string();
102        let bytes = abs.as_bytes();
103        if self.rials < 0 {
104            s.push('-');
105        }
106        for (i, b) in bytes.iter().enumerate() {
107            if i > 0 && (bytes.len() - i).is_multiple_of(3) {
108                s.push(',');
109            }
110            s.push(*b as char);
111        }
112        write!(f, "{s} Rial")
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn unit_conversion() {
122        assert_eq!(Amount::toman(50_000).as_rials(), 500_000);
123        assert_eq!(Amount::rial(500_000).as_tomans(), 50_000);
124    }
125
126    #[test]
127    fn comparison() {
128        assert!(Amount::toman(100) < Amount::toman(200));
129        assert_eq!(Amount::toman(100), Amount::rial(1_000));
130    }
131
132    #[test]
133    fn display_with_separators() {
134        assert_eq!(Amount::rial(1_234_567).to_string(), "1,234,567 Rial");
135        assert_eq!(Amount::rial(0).to_string(), "0 Rial");
136    }
137
138    #[test]
139    fn negative_amount_displays() {
140        assert_eq!(Amount::rial(-500).to_string(), "-500 Rial");
141    }
142
143    #[test]
144    fn const_constructors() {
145        const FEE: Amount = Amount::toman(1_000);
146        assert_eq!(FEE.as_rials(), 10_000);
147    }
148}