Skip to main content

webylib/
amount.rs

1//! Amount type with 8-decimal precision for Webcash
2//!
3//! Webcash amounts are stored as integers with 8 decimal places of precision.
4//! For example, 1.00000000 webcash is stored as 100000000.
5//!
6//! The smallest unit is called a "wat" (equivalent to Bitcoin's satoshi).
7
8use std::fmt;
9use std::str::FromStr;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14
15/// UTF-8 byte length of the ₩ symbol
16const WEBCASH_SYMBOL_BYTES: usize = 3;
17
18/// Amount type representing webcash values with 8 decimal places
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
20pub struct Amount {
21    /// Amount in wats (smallest unit, 1e-8 webcash)
22    pub wats: i64,
23}
24
25impl Amount {
26    /// Number of decimal places for webcash amounts
27    pub const DECIMALS: u32 = 8;
28
29    /// The smallest unit (1e-8 webcash)
30    pub const UNIT: i64 = 10_i64.pow(Self::DECIMALS);
31
32    /// Zero amount
33    pub const ZERO: Amount = Amount { wats: 0 };
34
35    /// Create a new Amount from wats (smallest unit)
36    pub const fn from_wats(wats: i64) -> Self {
37        Amount { wats }
38    }
39
40    /// Create a new Amount from wats (smallest unit) - deprecated, use from_wats
41    pub const fn from_sats(wats: i64) -> Self {
42        Amount { wats }
43    }
44
45    /// Parse scientific notation (e.g., "1E-8" -> "0.00000001")
46    /// This is a helper method for the FromStr trait implementation
47    fn parse_scientific_notation(s: &str) -> Result<Self> {
48        let parts: Vec<&str> = s.split(&['E', 'e'][..]).collect();
49        if parts.len() != 2 {
50            return Err(Error::amount("invalid scientific notation format"));
51        }
52
53        let coefficient: f64 = parts[0]
54            .parse()
55            .map_err(|_| Error::amount("invalid coefficient in scientific notation"))?;
56        let exponent: i32 = parts[1]
57            .parse()
58            .map_err(|_| Error::amount("invalid exponent in scientific notation"))?;
59
60        let result = if exponent >= 0 {
61            // Positive exponent: multiply by 10^exponent
62            coefficient * 10_f64.powi(exponent)
63        } else {
64            // Negative exponent: divide by 10^|exponent|
65            coefficient / 10_f64.powi(-exponent)
66        };
67
68        Self::from_webcash(result)
69    }
70
71    /// Create Amount from webcash float value
72    pub fn from_webcash(webcash: f64) -> Result<Self> {
73        if webcash < 0.0 {
74            return Err(Error::amount("negative amounts not allowed"));
75        }
76
77        // Convert to wats with proper rounding
78        let wats = (webcash * Self::UNIT as f64).round() as i64;
79
80        // Check for overflow
81        if wats < 0 {
82            return Err(Error::amount("amount too large"));
83        }
84
85        Ok(Amount { wats })
86    }
87
88    /// Convert to decimal string representation with default precision
89    /// Use Display trait for standard formatting: `format!("{}", amount)`
90    pub fn to_decimal_string(&self) -> String {
91        self.to_string_with_decimals(Self::DECIMALS)
92    }
93
94    /// Convert to decimal string with specified decimal places
95    pub fn to_string_with_decimals(&self, decimals: u32) -> String {
96        if self.wats == 0 {
97            return "0".to_string();
98        }
99
100        let divisor = 10_i64.pow(decimals);
101        let integer_part = self.wats / divisor;
102        let fractional_part = (self.wats % divisor).abs();
103
104        if fractional_part == 0 {
105            format!("{}", integer_part)
106        } else {
107            let fractional_str = format!("{:0width$}", fractional_part, width = decimals as usize);
108            let trimmed = fractional_str.trim_end_matches('0');
109            if trimmed.is_empty() {
110                format!("{}", integer_part)
111            } else {
112                format!("{}.{}", integer_part, trimmed)
113            }
114        }
115    }
116
117    /// Get the amount in webcash units (divide by 10^8)
118    pub fn to_webcash(&self) -> f64 {
119        self.wats as f64 / Self::UNIT as f64
120    }
121
122    /// Convert to wats string representation (for webcash string format)
123    /// Webcash strings use wats format: e10000:secret:... (not decimal format)
124    pub fn to_wats_string(&self) -> String {
125        self.wats.to_string()
126    }
127
128    /// Check if amount is valid (non-negative)
129    pub fn is_valid(&self) -> bool {
130        self.wats >= 0
131    }
132
133    /// Check if amount is zero
134    pub fn is_zero(&self) -> bool {
135        self.wats == 0
136    }
137
138    /// Check if amount is positive
139    pub fn is_positive(&self) -> bool {
140        self.wats > 0
141    }
142
143    /// Check if amount is negative
144    pub fn is_negative(&self) -> bool {
145        self.wats < 0
146    }
147
148    /// Get absolute value
149    pub fn abs(&self) -> Self {
150        Amount {
151            wats: self.wats.abs(),
152        }
153    }
154
155    /// Saturating addition
156    pub fn saturating_add(&self, other: &Amount) -> Amount {
157        Amount {
158            wats: self.wats.saturating_add(other.wats),
159        }
160    }
161
162    /// Saturating subtraction
163    pub fn saturating_sub(&self, other: &Amount) -> Amount {
164        Amount {
165            wats: self.wats.saturating_sub(other.wats),
166        }
167    }
168
169    /// Checked addition
170    pub fn checked_add(&self, other: &Amount) -> Option<Amount> {
171        self.wats
172            .checked_add(other.wats)
173            .map(|wats| Amount { wats })
174    }
175
176    /// Checked subtraction
177    pub fn checked_sub(&self, other: &Amount) -> Option<Amount> {
178        self.wats
179            .checked_sub(other.wats)
180            .map(|wats| Amount { wats })
181    }
182
183    /// Checked multiplication
184    pub fn checked_mul(&self, other: i64) -> Option<Amount> {
185        self.wats.checked_mul(other).map(|wats| Amount { wats })
186    }
187
188    /// Checked division
189    pub fn checked_div(&self, other: i64) -> Option<Amount> {
190        self.wats.checked_div(other).map(|wats| Amount { wats })
191    }
192}
193
194impl Default for Amount {
195    fn default() -> Self {
196        Amount::ZERO
197    }
198}
199
200impl fmt::Display for Amount {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        write!(f, "{}", self.to_decimal_string())
203    }
204}
205
206impl FromStr for Amount {
207    type Err = Error;
208
209    fn from_str(s: &str) -> Result<Self> {
210        // Handle empty strings
211        if s.is_empty() {
212            return Err(Error::amount("empty string"));
213        }
214
215        // Handle scientific notation first (before stripping prefixes)
216        if s.contains('E') || s.contains('e') {
217            return Self::parse_scientific_notation(s);
218        }
219
220        // Handle strings that start with 'e' (webcash format)
221        let s = if let Some(stripped) = s.strip_prefix('e') {
222            stripped
223        } else if s.starts_with('₩') {
224            &s[WEBCASH_SYMBOL_BYTES..]
225        } else {
226            s
227        };
228
229        // Handle special case of zero
230        if s == "0" {
231            return Ok(Amount::ZERO);
232        }
233
234        // Split into integer and fractional parts
235        let parts: Vec<&str> = s.split('.').collect();
236        if parts.len() > 2 {
237            return Err(Error::amount("too many decimal points"));
238        }
239
240        let integer_part = parts[0];
241        let fractional_part = if parts.len() == 2 { parts[1] } else { "" };
242
243        // Validate integer part
244        if integer_part.is_empty() && !fractional_part.is_empty() {
245            return Err(Error::amount("missing integer part"));
246        }
247
248        // Parse integer part
249        let mut wats = if integer_part.is_empty() {
250            0
251        } else {
252            integer_part
253                .parse::<i64>()
254                .map_err(|_| Error::amount("invalid integer part"))?
255        };
256
257        // Handle fractional part
258        if !fractional_part.is_empty() {
259            if fractional_part.len() > Amount::DECIMALS as usize {
260                return Err(Error::amount("too many decimal places"));
261            }
262
263            // Parse fractional part
264            let frac_value = fractional_part
265                .parse::<i64>()
266                .map_err(|_| Error::amount("invalid fractional part"))?;
267
268            // Calculate the multiplier for the fractional part
269            let multiplier = 10_i64.pow(Amount::DECIMALS - fractional_part.len() as u32);
270            let fractional_sats = frac_value * multiplier;
271
272            // Add fractional part to wats
273            wats = wats
274                .checked_mul(Amount::UNIT)
275                .and_then(|s| s.checked_add(fractional_sats))
276                .ok_or_else(|| Error::amount("amount too large"))?;
277        } else {
278            // No fractional part, multiply by UNIT
279            wats = wats
280                .checked_mul(Amount::UNIT)
281                .ok_or_else(|| Error::amount("amount too large"))?;
282        }
283
284        // Check for negative amounts (not allowed in webcash)
285        if wats < 0 {
286            return Err(Error::amount("negative amounts not allowed"));
287        }
288
289        Ok(Amount { wats })
290    }
291}
292
293impl std::ops::Add for Amount {
294    type Output = Amount;
295
296    fn add(self, other: Amount) -> Amount {
297        Amount {
298            wats: self.wats.saturating_add(other.wats),
299        }
300    }
301}
302
303impl std::ops::Sub for Amount {
304    type Output = Amount;
305
306    fn sub(self, other: Amount) -> Amount {
307        Amount {
308            wats: self.wats.saturating_sub(other.wats),
309        }
310    }
311}
312
313impl std::ops::Mul<i64> for Amount {
314    type Output = Amount;
315
316    fn mul(self, rhs: i64) -> Amount {
317        Amount {
318            wats: self.wats.saturating_mul(rhs),
319        }
320    }
321}
322
323impl std::ops::Div<i64> for Amount {
324    type Output = Amount;
325
326    /// Division operator for Amount
327    ///
328    /// # Panics
329    /// Panics if divisor is zero, following Rust's standard integer division behavior
330    fn div(self, rhs: i64) -> Amount {
331        Amount {
332            wats: self.wats / rhs, // Let Rust handle division by zero with standard behavior
333        }
334    }
335}
336
337impl std::ops::AddAssign for Amount {
338    fn add_assign(&mut self, other: Amount) {
339        self.wats = self.wats.saturating_add(other.wats);
340    }
341}
342
343impl std::ops::SubAssign for Amount {
344    fn sub_assign(&mut self, other: Amount) {
345        self.wats = self.wats.saturating_sub(other.wats);
346    }
347}