typed_money/amount/
precision.rs

1//! Precision control and detection for Amount.
2
3use super::type_def::Amount;
4use crate::{Currency, MoneyError, MoneyResult};
5
6impl<C: Currency> Amount<C> {
7    /// Checks if this amount has more decimal places than the currency supports.
8    ///
9    /// Returns `true` if the amount has excess precision that would be lost
10    /// when converting to minor units or displaying.
11    ///
12    /// This is useful for detecting when rounding might be needed.
13    ///
14    /// # Examples
15    ///
16    /// ```
17    /// use typed_money::{Amount, USD, JPY};
18    ///
19    /// // USD has 2 decimals
20    /// let precise = Amount::<USD>::from_major(100) / 3; // 33.333...
21    /// assert!(precise.has_excess_precision());
22    ///
23    /// let rounded = Amount::<USD>::from_minor(3333); // 33.33
24    /// assert!(!rounded.has_excess_precision());
25    ///
26    /// // JPY has 0 decimals
27    /// let jpy = Amount::<JPY>::from_major(100) / 3; // 33.333...
28    /// assert!(jpy.has_excess_precision());
29    /// ```
30    #[cfg(all(feature = "use_rust_decimal", not(feature = "use_bigdecimal")))]
31    pub fn has_excess_precision(&self) -> bool {
32        let scale = self.value.scale();
33        scale > u32::from(C::DECIMALS)
34    }
35
36    #[cfg(all(feature = "use_bigdecimal", not(feature = "use_rust_decimal")))]
37    pub fn has_excess_precision(&self) -> bool {
38        use bigdecimal::ToPrimitive;
39
40        let (_, scale) = self.value.as_bigint_and_exponent();
41        scale > i64::from(C::DECIMALS)
42    }
43
44    /// Returns the number of decimal places in this amount.
45    ///
46    /// This can be more than the currency's `DECIMALS` if the amount
47    /// came from arithmetic operations.
48    ///
49    /// # Examples
50    ///
51    /// ```
52    /// use typed_money::{Amount, USD};
53    ///
54    /// let amount = Amount::<USD>::from_minor(1234); // 12.34
55    /// assert_eq!(amount.precision(), 2);
56    ///
57    /// let divided = amount / 3; // 4.113333...
58    /// assert!(divided.precision() > 2);
59    /// ```
60    #[cfg(all(feature = "use_rust_decimal", not(feature = "use_bigdecimal")))]
61    pub fn precision(&self) -> u32 {
62        self.value.scale()
63    }
64
65    #[cfg(all(feature = "use_bigdecimal", not(feature = "use_rust_decimal")))]
66    pub fn precision(&self) -> i64 {
67        let (_, scale) = self.value.as_bigint_and_exponent();
68        scale
69    }
70
71    /// Returns the currency's expected decimal precision.
72    ///
73    /// This is a convenience method that returns `C::DECIMALS`.
74    ///
75    /// # Examples
76    ///
77    /// ```
78    /// use typed_money::{Amount, USD, JPY, BTC};
79    ///
80    /// assert_eq!(Amount::<USD>::currency_precision(), 2);
81    /// assert_eq!(Amount::<JPY>::currency_precision(), 0);
82    /// assert_eq!(Amount::<BTC>::currency_precision(), 8);
83    /// ```
84    #[inline]
85    pub const fn currency_precision() -> u8 {
86        C::DECIMALS
87    }
88
89    /// Normalizes the amount to the currency's decimal precision.
90    ///
91    /// This is equivalent to `round(RoundingMode::HalfEven)` but more explicit
92    /// about the intent of normalizing to currency precision.
93    ///
94    /// # Examples
95    ///
96    /// ```
97    /// use typed_money::{Amount, USD};
98    ///
99    /// let amount = Amount::<USD>::from_major(100) / 3; // 33.333...
100    /// assert!(amount.has_excess_precision());
101    ///
102    /// let normalized = amount.normalize();
103    /// assert!(!normalized.has_excess_precision());
104    /// assert_eq!(normalized.to_minor(), 3333); // 33.33
105    /// ```
106    pub fn normalize(&self) -> Self {
107        use crate::RoundingMode;
108        self.round(RoundingMode::HalfEven)
109    }
110
111    /// Checks if the amount has valid precision for the currency.
112    ///
113    /// Returns an error if the amount has excess precision that should be
114    /// rounded before use in financial operations.
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// use typed_money::{Amount, USD};
120    ///
121    /// let amount = Amount::<USD>::from_minor(1234); // 12.34
122    /// assert!(amount.check_precision().is_ok());
123    ///
124    /// let divided = Amount::<USD>::from_major(100) / 3; // 33.333...
125    /// let result = divided.check_precision();
126    /// assert!(result.is_err());
127    ///
128    /// // Can recover by normalizing
129    /// let normalized = divided.normalize();
130    /// assert!(normalized.check_precision().is_ok());
131    /// ```
132    #[cfg(all(feature = "use_rust_decimal", not(feature = "use_bigdecimal")))]
133    pub fn check_precision(&self) -> MoneyResult<()> {
134        if self.has_excess_precision() {
135            Err(MoneyError::PrecisionError {
136                currency: C::CODE,
137                expected: C::DECIMALS,
138                actual: self.precision(),
139                suggestion: format!(
140                    "Use normalize() or round() to {} decimal places",
141                    C::DECIMALS
142                ),
143            })
144        } else {
145            Ok(())
146        }
147    }
148
149    #[cfg(all(feature = "use_bigdecimal", not(feature = "use_rust_decimal")))]
150    pub fn check_precision(&self) -> MoneyResult<()> {
151        if self.has_excess_precision() {
152            Err(MoneyError::PrecisionError {
153                currency: C::CODE,
154                expected: C::DECIMALS,
155                actual: self.precision() as u32,
156                suggestion: format!(
157                    "Use normalize() or round() to adjust precision to {} decimal places",
158                    C::DECIMALS
159                ),
160            })
161        } else {
162            Ok(())
163        }
164    }
165
166    /// Checks if the amount has valid precision for the currency.
167    ///
168    /// This version is used when both decimal backends are enabled (which should not
169    /// happen in normal use, but may occur during testing with --all-features).
170    #[cfg(all(feature = "use_rust_decimal", feature = "use_bigdecimal"))]
171    pub fn check_precision(&self) -> MoneyResult<()> {
172        // When both backends are enabled, we can't determine precision
173        // This is a compile-time configuration error, so we just return Ok
174        Ok(())
175    }
176}
177
178#[cfg(test)]
179#[cfg(not(all(feature = "use_rust_decimal", feature = "use_bigdecimal")))]
180mod tests {
181    use super::*;
182    use crate::{BTC, EUR, JPY, USD};
183
184    // ========================================================================
185    // Precision Detection Tests
186    // ========================================================================
187
188    #[test]
189    fn test_has_excess_precision_usd() {
190        // USD has 2 decimals
191        let exact = Amount::<USD>::from_minor(1234); // 12.34
192        assert!(!exact.has_excess_precision());
193
194        let divided = Amount::<USD>::from_major(100) / 3; // 33.333...
195        assert!(divided.has_excess_precision());
196    }
197
198    #[test]
199    fn test_has_excess_precision_jpy() {
200        // JPY has 0 decimals
201        let exact = Amount::<JPY>::from_major(100);
202        assert!(!exact.has_excess_precision());
203
204        let divided = Amount::<JPY>::from_major(100) / 3; // 33.333...
205        assert!(divided.has_excess_precision());
206    }
207
208    #[test]
209    fn test_has_excess_precision_btc() {
210        // BTC has 8 decimals
211        let exact = Amount::<BTC>::from_minor(12345678); // 0.12345678
212        assert!(!exact.has_excess_precision());
213
214        let divided = Amount::<BTC>::from_major(1) / 3; // 0.333...
215        assert!(divided.has_excess_precision());
216    }
217
218    #[test]
219    fn test_precision_method() {
220        let usd = Amount::<USD>::from_minor(1234); // 12.34
221        assert_eq!(usd.precision(), 2);
222
223        let jpy = Amount::<JPY>::from_major(1234); // 1234
224        assert_eq!(jpy.precision(), 0);
225    }
226
227    #[test]
228    fn test_precision_after_division() {
229        let amount = Amount::<USD>::from_major(10);
230        let divided = amount / 3; // 3.333...
231
232        // Should have more than 2 decimals
233        assert!(divided.precision() > 2);
234        assert!(divided.has_excess_precision());
235    }
236
237    #[test]
238    fn test_currency_precision() {
239        assert_eq!(Amount::<USD>::currency_precision(), 2);
240        assert_eq!(Amount::<EUR>::currency_precision(), 2);
241        assert_eq!(Amount::<JPY>::currency_precision(), 0);
242        assert_eq!(Amount::<BTC>::currency_precision(), 8);
243    }
244
245    // ========================================================================
246    // Normalization Tests
247    // ========================================================================
248
249    #[test]
250    fn test_normalize_removes_excess_precision() {
251        let amount = Amount::<USD>::from_major(100) / 3; // 33.333...
252        assert!(amount.has_excess_precision());
253
254        let normalized = amount.normalize();
255        assert!(!normalized.has_excess_precision());
256    }
257
258    #[test]
259    fn test_normalize_uses_half_even() {
260        use rust_decimal::Decimal;
261        use std::marker::PhantomData;
262
263        // 12.345 normalizes to 12.34 (banker's rounding)
264        let value = Decimal::new(12345, 3);
265        let amount = Amount::<USD> {
266            value,
267            _currency: PhantomData,
268        };
269
270        let normalized = amount.normalize();
271        assert_eq!(normalized.to_minor(), 1234);
272    }
273
274    #[test]
275    fn test_normalize_already_normalized() {
276        let amount = Amount::<USD>::from_minor(1234); // Already 2 decimals
277        let normalized = amount.normalize();
278
279        assert_eq!(amount, normalized);
280    }
281
282    #[test]
283    fn test_normalize_jpy() {
284        let amount = Amount::<JPY>::from_major(100) / 3; // 33.333...
285        let normalized = amount.normalize();
286
287        assert_eq!(normalized.to_major_floor(), 33);
288        assert!(!normalized.has_excess_precision());
289    }
290
291    // ========================================================================
292    // Precision Preservation Tests
293    // ========================================================================
294
295    #[test]
296    fn test_arithmetic_preserves_precision() {
297        let a = Amount::<USD>::from_minor(1000); // 10.00
298        let b = Amount::<USD>::from_minor(333); // 3.33
299
300        let sum = a + b; // 13.33
301        assert_eq!(sum.precision(), 2);
302        assert!(!sum.has_excess_precision());
303    }
304
305    #[test]
306    fn test_division_increases_precision() {
307        let amount = Amount::<USD>::from_major(10);
308        let divided = amount / 3;
309
310        // Division should create more precision
311        assert!(divided.precision() > Amount::<USD>::currency_precision().into());
312        assert!(divided.has_excess_precision());
313    }
314
315    #[test]
316    fn test_multiplication_can_increase_precision() {
317        let amount = Amount::<USD>::from_minor(1000); // 10.00
318        let multiplied = amount * 3; // 10.00 * 3 = 30.00
319
320        // Scalar multiplication may preserve precision
321        assert!(multiplied.precision() >= 2);
322    }
323
324    // ========================================================================
325    // Determinism Tests
326    // ========================================================================
327
328    #[test]
329    fn test_precision_detection_deterministic() {
330        // Same operations should always give same precision detection
331        let amount1 = Amount::<USD>::from_major(100) / 3;
332        let amount2 = Amount::<USD>::from_major(100) / 3;
333
334        assert_eq!(
335            amount1.has_excess_precision(),
336            amount2.has_excess_precision()
337        );
338    }
339
340    #[test]
341    fn test_normalize_deterministic() {
342        let amount1 = Amount::<USD>::from_major(100) / 3;
343        let amount2 = Amount::<USD>::from_major(100) / 3;
344
345        let norm1 = amount1.normalize();
346        let norm2 = amount2.normalize();
347
348        assert_eq!(norm1, norm2);
349    }
350
351    #[test]
352    fn test_cross_platform_precision_behavior() {
353        // Test that precision behavior is consistent
354        let operations = [
355            Amount::<USD>::from_major(10) / 3,
356            Amount::<USD>::from_major(100) / 7,
357            Amount::<USD>::from_minor(1) / 3,
358        ];
359
360        for op in operations {
361            // All should have excess precision
362            assert!(op.has_excess_precision());
363
364            // All should normalize consistently
365            let normalized = op.normalize();
366            assert!(!normalized.has_excess_precision());
367        }
368    }
369
370    // ========================================================================
371    // Edge Cases
372    // ========================================================================
373
374    #[test]
375    fn test_zero_precision() {
376        let zero = Amount::<USD>::from_major(0);
377        assert!(!zero.has_excess_precision());
378        assert_eq!(zero.normalize(), zero);
379    }
380
381    #[test]
382    fn test_negative_precision() {
383        let neg = Amount::<USD>::from_major(-100) / 3;
384        assert!(neg.has_excess_precision());
385
386        let normalized = neg.normalize();
387        assert!(!normalized.has_excess_precision());
388    }
389
390    #[test]
391    fn test_very_large_precision() {
392        // Test with BTC which has 8 decimals
393        let btc = Amount::<BTC>::from_major(1);
394        let divided = btc / 7; // Creates repeating decimal
395
396        assert!(divided.has_excess_precision());
397
398        let normalized = divided.normalize();
399        assert!(!normalized.has_excess_precision());
400    }
401
402    // ========================================================================
403    // Error Handling Tests (Section 6.2)
404    // ========================================================================
405
406    #[test]
407    fn test_check_precision_ok() {
408        let amount = Amount::<USD>::from_minor(1234); // 12.34
409        assert!(amount.check_precision().is_ok());
410    }
411
412    #[test]
413    fn test_check_precision_error() {
414        let amount = Amount::<USD>::from_major(100) / 3; // 33.333...
415        let result = amount.check_precision();
416        assert!(result.is_err());
417
418        if let Err(e) = result {
419            assert!(matches!(e, MoneyError::PrecisionError { .. }));
420            assert_eq!(e.currency(), Some("USD"));
421            let msg = e.to_string();
422            assert!(
423                msg.contains("Precision") || msg.contains("precision"),
424                "Message: {}",
425                msg
426            );
427        }
428    }
429
430    #[test]
431    fn test_check_precision_error_recovery() {
432        let amount = Amount::<USD>::from_major(100) / 3; // 33.333...
433
434        // Error detected
435        assert!(amount.check_precision().is_err());
436
437        // Can recover by normalizing
438        let normalized = amount.normalize();
439        assert!(normalized.check_precision().is_ok());
440    }
441
442    #[test]
443    fn test_check_precision_jpy() {
444        let jpy = Amount::<JPY>::from_major(100);
445        assert!(jpy.check_precision().is_ok());
446
447        let divided = jpy / 3;
448        assert!(divided.check_precision().is_err());
449    }
450
451    #[test]
452    fn test_precision_error_message() {
453        let amount = Amount::<USD>::from_major(100) / 3;
454        if let Err(e) = amount.check_precision() {
455            let msg = e.to_string();
456            assert!(msg.contains("USD"));
457            assert!(msg.contains("2 decimal places"));
458        }
459    }
460
461    #[test]
462    fn test_precision_error_suggestion() {
463        let amount = Amount::<USD>::from_major(100) / 3;
464        if let Err(e) = amount.check_precision() {
465            let suggestion = e.suggestion();
466            assert!(suggestion.contains("normalize") || suggestion.contains("round"));
467        }
468    }
469}