1use std::{
48 cmp::Ordering,
49 fmt::{Debug, Display},
50 hash::{Hash, Hasher},
51 ops::{Add, Div, Mul, Neg, Sub},
52 str::FromStr,
53};
54
55use nautilus_core::{
56 correctness::{
57 CorrectnessError, CorrectnessResult, CorrectnessResultExt, FAILED,
58 check_in_range_inclusive_f64,
59 },
60 string::formatting::Separable,
61};
62use rust_decimal::Decimal;
63use serde::{Deserialize, Deserializer, Serialize};
64
65#[cfg(not(any(feature = "defi", feature = "high-precision")))]
66use super::fixed::{f64_to_fixed_i64, fixed_i64_to_f64};
67#[cfg(any(feature = "defi", feature = "high-precision"))]
68use super::fixed::{f64_to_fixed_i128, fixed_i128_to_f64};
69#[cfg(feature = "defi")]
70use crate::types::fixed::MAX_FLOAT_PRECISION;
71use crate::types::{
72 Currency,
73 fixed::{
74 FIXED_PRECISION, FIXED_SCALAR, check_fixed_precision, mantissa_exponent_to_fixed_i128,
75 raw_scales_match,
76 },
77};
78
79#[cfg(feature = "high-precision")]
84pub type MoneyRaw = i128;
85
86#[cfg(not(feature = "high-precision"))]
87pub type MoneyRaw = i64;
88
89#[unsafe(no_mangle)]
100#[allow(unsafe_code)]
101pub static MONEY_RAW_MAX: MoneyRaw = (MONEY_MAX as MoneyRaw) * (FIXED_SCALAR as MoneyRaw);
102
103#[unsafe(no_mangle)]
112#[allow(unsafe_code)]
113pub static MONEY_RAW_MIN: MoneyRaw = (MONEY_MIN as MoneyRaw) * (FIXED_SCALAR as MoneyRaw);
114
115#[cfg(feature = "high-precision")]
120pub const MONEY_MAX: f64 = 17_014_118_346_046.0;
122
123#[cfg(not(feature = "high-precision"))]
124pub const MONEY_MAX: f64 = 9_223_372_036.0;
126
127#[cfg(feature = "high-precision")]
132pub const MONEY_MIN: f64 = -17_014_118_346_046.0;
134
135#[cfg(not(feature = "high-precision"))]
136pub const MONEY_MIN: f64 = -9_223_372_036.0;
138
139#[repr(C)]
146#[derive(Clone, Copy, Eq)]
147#[cfg_attr(
148 feature = "python",
149 pyo3::pyclass(
150 module = "nautilus_trader.core.nautilus_pyo3.model",
151 frozen,
152 from_py_object
153 )
154)]
155#[cfg_attr(
156 feature = "python",
157 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
158)]
159pub struct Money {
160 pub raw: MoneyRaw,
162 pub currency: Currency,
164}
165
166impl Money {
167 pub fn new_checked(amount: f64, currency: Currency) -> CorrectnessResult<Self> {
179 check_in_range_inclusive_f64(amount, MONEY_MIN, MONEY_MAX, "amount")?;
183
184 #[cfg(feature = "defi")]
185 if currency.precision > MAX_FLOAT_PRECISION {
186 return Err(CorrectnessError::PredicateViolation {
188 message: format!(
189 "`currency.precision` exceeded maximum float precision ({MAX_FLOAT_PRECISION}), use `Money::from_wei()` for wei values instead"
190 ),
191 });
192 }
193
194 check_fixed_precision(currency.precision)?;
195
196 #[cfg(feature = "high-precision")]
197 let raw = f64_to_fixed_i128(amount, currency.precision);
198
199 #[cfg(not(feature = "high-precision"))]
200 let raw = f64_to_fixed_i64(amount, currency.precision);
201
202 Ok(Self { raw, currency })
203 }
204
205 #[must_use]
211 pub fn new(amount: f64, currency: Currency) -> Self {
212 Self::new_checked(amount, currency).expect_display(FAILED)
213 }
214
215 #[must_use]
221 pub fn from_raw(raw: MoneyRaw, currency: Currency) -> Self {
222 Self::from_raw_checked(raw, currency).expect_display(FAILED)
223 }
224
225 pub fn from_raw_checked(raw: MoneyRaw, currency: Currency) -> CorrectnessResult<Self> {
234 if raw < MONEY_RAW_MIN || raw > MONEY_RAW_MAX {
235 return Err(CorrectnessError::PredicateViolation {
236 message: format!(
237 "`raw` value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
238 ),
239 });
240 }
241
242 check_fixed_precision(currency.precision)?;
243
244 Ok(Self { raw, currency })
254 }
255
256 #[must_use]
265 pub fn from_mantissa_exponent(mantissa: i64, exponent: i8, currency: Currency) -> Self {
266 check_fixed_precision(currency.precision).expect_display(FAILED);
267
268 if mantissa == 0 {
269 return Self { raw: 0, currency };
270 }
271
272 let raw_i128 =
273 mantissa_exponent_to_fixed_i128(i128::from(mantissa), exponent, currency.precision)
274 .expect("Overflow in Money::from_mantissa_exponent");
275
276 #[allow(
277 clippy::useless_conversion,
278 reason = "i128 to MoneyRaw is real when not high-precision"
279 )]
280 let raw: MoneyRaw = raw_i128
281 .try_into()
282 .expect("Raw value exceeds MoneyRaw range in Money::from_mantissa_exponent");
283 assert!(
284 raw >= MONEY_RAW_MIN && raw <= MONEY_RAW_MAX,
285 "`raw` value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
286 );
287
288 Self { raw, currency }
289 }
290
291 #[must_use]
297 pub fn zero(currency: Currency) -> Self {
298 check_fixed_precision(currency.precision).expect_display(FAILED);
299 Self { raw: 0, currency }
300 }
301
302 #[must_use]
305 pub fn normalized(&self) -> Self {
306 #[cfg(feature = "high-precision")]
307 let raw = super::fixed::correct_raw_i128(self.raw, self.currency.precision);
308
309 #[cfg(not(feature = "high-precision"))]
310 let raw = super::fixed::correct_raw_i64(self.raw, self.currency.precision);
311
312 Self {
313 raw,
314 currency: self.currency,
315 }
316 }
317
318 #[must_use]
320 pub fn is_zero(&self) -> bool {
321 self.raw == 0
322 }
323
324 #[must_use]
326 pub fn is_positive(&self) -> bool {
327 self.raw > 0
328 }
329
330 #[must_use]
340 pub fn checked_add(self, rhs: Self) -> Option<Self> {
341 assert_eq!(
342 self.currency, rhs.currency,
343 "Currency mismatch: cannot add {} to {}",
344 rhs.currency.code, self.currency.code
345 );
346
347 if !raw_scales_match(self.currency.precision, rhs.currency.precision) {
348 return None;
349 }
350 let raw = self.raw.checked_add(rhs.raw)?;
351 if raw < MONEY_RAW_MIN || raw > MONEY_RAW_MAX {
352 return None;
353 }
354 Some(Self {
355 raw,
356 currency: self.currency,
357 })
358 }
359
360 #[must_use]
370 pub fn checked_sub(self, rhs: Self) -> Option<Self> {
371 assert_eq!(
372 self.currency, rhs.currency,
373 "Currency mismatch: cannot subtract {} from {}",
374 rhs.currency.code, self.currency.code
375 );
376
377 if !raw_scales_match(self.currency.precision, rhs.currency.precision) {
378 return None;
379 }
380 let raw = self.raw.checked_sub(rhs.raw)?;
381 if raw < MONEY_RAW_MIN || raw > MONEY_RAW_MAX {
382 return None;
383 }
384 Some(Self {
385 raw,
386 currency: self.currency,
387 })
388 }
389
390 #[cfg(feature = "high-precision")]
391 #[must_use]
397 pub fn as_f64(&self) -> f64 {
398 #[cfg(feature = "defi")]
399 assert!(
400 self.currency.precision <= MAX_FLOAT_PRECISION,
401 "Invalid f64 conversion beyond `MAX_FLOAT_PRECISION` (16)"
402 );
403
404 fixed_i128_to_f64(self.raw)
405 }
406
407 #[cfg(not(feature = "high-precision"))]
408 #[must_use]
414 pub fn as_f64(&self) -> f64 {
415 #[cfg(feature = "defi")]
416 if self.currency.precision > MAX_FLOAT_PRECISION {
417 panic!("Invalid f64 conversion beyond `MAX_FLOAT_PRECISION` (16)");
418 }
419
420 fixed_i64_to_f64(self.raw)
421 }
422
423 #[must_use]
425 pub fn as_decimal(&self) -> Decimal {
426 let precision = self.currency.precision;
428 let precision_diff = FIXED_PRECISION.saturating_sub(precision);
429
430 let rescaled_raw = self.raw / MoneyRaw::pow(10, u32::from(precision_diff));
433
434 #[allow(
435 clippy::useless_conversion,
436 reason = "i128::from is real when MoneyRaw is i64"
437 )]
438 Decimal::from_i128_with_scale(i128::from(rescaled_raw), u32::from(precision))
439 }
440
441 #[must_use]
448 pub fn to_formatted_string(&self) -> String {
449 let amount_str = if self.currency.precision > crate::types::fixed::MAX_FLOAT_PRECISION {
450 self.raw.to_string()
451 } else {
452 self.as_decimal().to_string()
453 };
454 format!(
455 "{} {}",
456 amount_str.separate_with_underscores(),
457 self.currency.code
458 )
459 }
460
461 pub fn from_decimal(decimal: Decimal, currency: Currency) -> CorrectnessResult<Self> {
472 let exponent = -(decimal.scale() as i8);
473 let raw_i128 =
474 mantissa_exponent_to_fixed_i128(decimal.mantissa(), exponent, currency.precision)?;
475
476 #[allow(
477 clippy::useless_conversion,
478 reason = "i128 to MoneyRaw is real when not high-precision"
479 )]
480 let raw: MoneyRaw =
481 raw_i128
482 .try_into()
483 .map_err(|_| CorrectnessError::PredicateViolation {
484 message: format!(
485 "Decimal value exceeds MoneyRaw range [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}]"
486 ),
487 })?;
488
489 if !(raw >= MONEY_RAW_MIN && raw <= MONEY_RAW_MAX) {
490 return Err(CorrectnessError::PredicateViolation {
491 message: format!(
492 "Raw value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
493 ),
494 });
495 }
496
497 Ok(Self { raw, currency })
498 }
499}
500
501impl FromStr for Money {
502 type Err = String;
503
504 fn from_str(value: &str) -> Result<Self, Self::Err> {
505 let parts: Vec<&str> = value.split_whitespace().collect();
506
507 if parts.len() != 2 {
509 return Err(format!(
510 "Error invalid input format '{value}'. Expected '<amount> <currency>'"
511 ));
512 }
513
514 let clean_amount = parts[0].replace('_', "");
515
516 let decimal = if clean_amount.contains('e') || clean_amount.contains('E') {
517 Decimal::from_scientific(&clean_amount)
518 .map_err(|e| format!("Error parsing amount '{}' as Decimal: {e}", parts[0]))?
519 } else {
520 Decimal::from_str(&clean_amount)
521 .map_err(|e| format!("Error parsing amount '{}' as Decimal: {e}", parts[0]))?
522 };
523
524 let currency = Currency::from_str(parts[1]).map_err(|e| e.to_string())?;
525 Self::from_decimal(decimal, currency).map_err(|e| e.to_string())
526 }
527}
528
529impl<T: AsRef<str>> From<T> for Money {
530 fn from(value: T) -> Self {
531 Self::from_str(value.as_ref()).expect(FAILED)
532 }
533}
534
535impl From<Money> for f64 {
536 fn from(money: Money) -> Self {
537 money.as_f64()
538 }
539}
540
541impl From<&Money> for f64 {
542 fn from(money: &Money) -> Self {
543 money.as_f64()
544 }
545}
546
547impl Hash for Money {
548 fn hash<H: Hasher>(&self, state: &mut H) {
549 self.raw.hash(state);
550 self.currency.hash(state);
551 }
552}
553
554impl PartialEq for Money {
555 fn eq(&self, other: &Self) -> bool {
556 self.raw == other.raw && self.currency == other.currency
557 }
558}
559
560impl PartialOrd for Money {
561 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
562 Some(self.cmp(other))
563 }
564
565 fn lt(&self, other: &Self) -> bool {
566 assert_eq!(self.currency, other.currency);
567 self.raw.lt(&other.raw)
568 }
569
570 fn le(&self, other: &Self) -> bool {
571 assert_eq!(self.currency, other.currency);
572 self.raw.le(&other.raw)
573 }
574
575 fn gt(&self, other: &Self) -> bool {
576 assert_eq!(self.currency, other.currency);
577 self.raw.gt(&other.raw)
578 }
579
580 fn ge(&self, other: &Self) -> bool {
581 assert_eq!(self.currency, other.currency);
582 self.raw.ge(&other.raw)
583 }
584}
585
586impl Ord for Money {
587 fn cmp(&self, other: &Self) -> Ordering {
588 assert_eq!(self.currency, other.currency);
589 self.raw.cmp(&other.raw)
590 }
591}
592
593impl Neg for Money {
594 type Output = Self;
595 fn neg(self) -> Self::Output {
596 Self {
597 raw: -self.raw,
598 currency: self.currency,
599 }
600 }
601}
602
603impl Add for Money {
604 type Output = Self;
605 fn add(self, rhs: Self) -> Self::Output {
606 assert_eq!(
607 self.currency, rhs.currency,
608 "Currency mismatch: cannot add {} to {}",
609 rhs.currency.code, self.currency.code
610 );
611 Self {
612 raw: self
613 .raw
614 .checked_add(rhs.raw)
615 .expect("Overflow occurred when adding `Money`"),
616 currency: self.currency,
617 }
618 }
619}
620
621impl Sub for Money {
622 type Output = Self;
623 fn sub(self, rhs: Self) -> Self::Output {
624 assert_eq!(
625 self.currency, rhs.currency,
626 "Currency mismatch: cannot subtract {} from {}",
627 rhs.currency.code, self.currency.code
628 );
629 Self {
630 raw: self
631 .raw
632 .checked_sub(rhs.raw)
633 .expect("Underflow occurred when subtracting `Money`"),
634 currency: self.currency,
635 }
636 }
637}
638
639impl Add<Decimal> for Money {
640 type Output = Decimal;
641 fn add(self, rhs: Decimal) -> Self::Output {
642 self.as_decimal() + rhs
643 }
644}
645
646impl Sub<Decimal> for Money {
647 type Output = Decimal;
648 fn sub(self, rhs: Decimal) -> Self::Output {
649 self.as_decimal() - rhs
650 }
651}
652
653impl Mul<Decimal> for Money {
654 type Output = Decimal;
655 fn mul(self, rhs: Decimal) -> Self::Output {
656 self.as_decimal() * rhs
657 }
658}
659
660impl Div<Decimal> for Money {
661 type Output = Decimal;
662 fn div(self, rhs: Decimal) -> Self::Output {
663 self.as_decimal() / rhs
664 }
665}
666
667impl Add<f64> for Money {
668 type Output = f64;
669 fn add(self, rhs: f64) -> Self::Output {
670 self.as_f64() + rhs
671 }
672}
673
674impl Sub<f64> for Money {
675 type Output = f64;
676 fn sub(self, rhs: f64) -> Self::Output {
677 self.as_f64() - rhs
678 }
679}
680
681impl Mul<f64> for Money {
682 type Output = f64;
683 fn mul(self, rhs: f64) -> Self::Output {
684 self.as_f64() * rhs
685 }
686}
687
688impl Div<f64> for Money {
689 type Output = f64;
690 fn div(self, rhs: f64) -> Self::Output {
691 self.as_f64() / rhs
692 }
693}
694
695impl Debug for Money {
696 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697 if self.currency.precision > crate::types::fixed::MAX_FLOAT_PRECISION {
698 write!(f, "{}({}, {})", stringify!(Money), self.raw, self.currency)
699 } else {
700 write!(
701 f,
702 "{}({:.*}, {})",
703 stringify!(Money),
704 self.currency.precision as usize,
705 self.as_f64(),
706 self.currency
707 )
708 }
709 }
710}
711
712impl Display for Money {
713 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
714 if self.currency.precision > crate::types::fixed::MAX_FLOAT_PRECISION {
715 write!(f, "{} {}", self.raw, self.currency)
716 } else {
717 write!(f, "{} {}", self.as_decimal(), self.currency)
718 }
719 }
720}
721
722impl Serialize for Money {
723 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
724 where
725 S: serde::Serializer,
726 {
727 serializer.serialize_str(&self.to_string())
728 }
729}
730
731impl<'de> Deserialize<'de> for Money {
732 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
733 where
734 D: Deserializer<'de>,
735 {
736 let money_str: std::borrow::Cow<'de, str> = Deserialize::deserialize(deserializer)?;
737 Self::from_str(money_str.as_ref()).map_err(serde::de::Error::custom)
738 }
739}
740
741#[inline(always)]
747pub fn check_positive_money(value: Money, param: &str) -> CorrectnessResult<()> {
748 if value.raw <= 0 {
749 return Err(CorrectnessError::NotPositive {
750 param: param.to_string(),
751 value: value.to_string(),
752 type_name: "`Money`",
753 });
754 }
755 Ok(())
756}
757
758#[cfg(test)]
759mod tests {
760 use nautilus_core::{approx_eq, correctness::CorrectnessError};
761 use rstest::rstest;
762 use rust_decimal_macros::dec;
763
764 use super::*;
765
766 #[rstest]
767 fn test_extreme_money_round_trips_through_raw() {
768 let max = Money::new(MONEY_MAX, Currency::USD());
771 let min = Money::new(MONEY_MIN, Currency::USD());
772
773 assert_eq!(max.raw, MONEY_RAW_MAX);
774 assert_eq!(min.raw, MONEY_RAW_MIN);
775 assert!(Money::from_raw_checked(max.raw, Currency::USD()).is_ok());
776 assert!(Money::from_raw_checked(min.raw, Currency::USD()).is_ok());
777 }
778
779 #[rstest]
780 fn test_debug() {
781 let money = Money::new(1010.12, Currency::USD());
782 let result = format!("{money:?}");
783 let expected = "Money(1010.12, USD)";
784 assert_eq!(result, expected);
785 }
786
787 #[rstest]
788 fn test_display() {
789 let money = Money::new(1010.12, Currency::USD());
790 let result = format!("{money}");
791 let expected = "1010.12 USD";
792 assert_eq!(result, expected);
793 }
794
795 #[rstest]
796 #[case(1010.12, 2, "USD", "Money(1010.12, USD)", "1010.12 USD")] #[case(123.456_789, 8, "BTC", "Money(123.45678900, BTC)", "123.45678900 BTC")] fn test_formatting_normal_precision(
799 #[case] value: f64,
800 #[case] precision: u8,
801 #[case] currency_code: &str,
802 #[case] expected_debug: &str,
803 #[case] expected_display: &str,
804 ) {
805 use crate::enums::CurrencyType;
806 let currency = Currency::new(
807 currency_code,
808 precision,
809 0,
810 currency_code,
811 CurrencyType::Fiat,
812 );
813 let money = Money::new(value, currency);
814
815 assert_eq!(format!("{money:?}"), expected_debug);
816 assert_eq!(format!("{money}"), expected_display);
817 }
818
819 #[rstest]
820 #[cfg(feature = "defi")]
821 #[case(
822 1_000_000_000_000_000_000_i128,
823 18,
824 "wei",
825 "Money(1000000000000000000, wei)",
826 "1000000000000000000 wei"
827 )] #[case(
829 2_500_000_000_000_000_000_i128,
830 18,
831 "ETH",
832 "Money(2500000000000000000, ETH)",
833 "2500000000000000000 ETH"
834 )] fn test_formatting_high_precision(
836 #[case] raw_value: i128,
837 #[case] precision: u8,
838 #[case] currency_code: &str,
839 #[case] expected_debug: &str,
840 #[case] expected_display: &str,
841 ) {
842 use crate::enums::CurrencyType;
843 let currency = Currency::new(
844 currency_code,
845 precision,
846 0,
847 currency_code,
848 CurrencyType::Crypto,
849 );
850 let money = Money::from_raw(raw_value, currency);
851
852 assert_eq!(format!("{money:?}"), expected_debug);
853 assert_eq!(format!("{money}"), expected_display);
854 }
855
856 #[rstest]
857 fn test_zero_constructor() {
858 let usd = Currency::USD();
859 let money = Money::zero(usd);
860 assert_eq!(money.raw, 0);
861 assert_eq!(money.currency, usd);
862 }
863
864 #[rstest]
865 #[should_panic(expected = "Currency mismatch")]
866 fn test_money_different_currency_addition() {
867 let usd = Money::new(1000.0, Currency::USD());
868 let btc = Money::new(1.0, Currency::BTC());
869 let _ = usd + btc; }
871
872 #[rstest] fn test_with_maximum_value() {
874 let money = Money::new_checked(MONEY_MAX, Currency::USD());
875 assert!(money.is_ok());
876 }
877
878 #[rstest] fn test_with_minimum_value() {
880 let money = Money::new_checked(MONEY_MIN, Currency::USD());
881 assert!(money.is_ok());
882 }
883
884 #[rstest]
885 fn test_new_checked_returns_typed_error_with_stable_display() {
886 let error = Money::new_checked(MONEY_MAX + 1.0, Currency::USD()).unwrap_err();
887
888 assert!(matches!(error, CorrectnessError::OutOfRange { .. }));
889 assert_eq!(
890 error.to_string(),
891 format!(
892 "invalid f64 for 'amount' not in range [{MONEY_MIN}, {MONEY_MAX}], was {}",
893 MONEY_MAX + 1.0
894 )
895 );
896 }
897
898 #[cfg(not(feature = "defi"))]
899 #[rstest]
900 fn test_new_checked_invalid_currency_precision_returns_error() {
901 let mut currency = Currency::USD();
902 currency.precision = FIXED_PRECISION + 1;
903
904 let error = Money::new_checked(1.0, currency).unwrap_err();
905 assert!(
906 error
907 .to_string()
908 .contains("`precision` exceeded maximum `FIXED_PRECISION`"),
909 "unexpected message: {error}"
910 );
911 }
912
913 #[rstest]
914 fn test_money_is_zero() {
915 let zero_usd = Money::new(0.0, Currency::USD());
916 assert!(zero_usd.is_zero());
917 assert_eq!(zero_usd, Money::from("0.0 USD"));
918
919 let non_zero_usd = Money::new(100.0, Currency::USD());
920 assert!(!non_zero_usd.is_zero());
921 }
922
923 #[rstest]
924 fn test_money_is_positive() {
925 let usd = Currency::USD();
926 assert!(Money::new(100.0, usd).is_positive());
927 assert!(!Money::new(0.0, usd).is_positive());
928 assert!(!Money::new(-100.0, usd).is_positive());
929 }
930
931 #[rstest]
932 fn test_money_comparisons() {
933 let usd = Currency::USD();
934 let m1 = Money::new(100.0, usd);
935 let m2 = Money::new(200.0, usd);
936
937 assert!(m1 < m2);
938 assert!(m2 > m1);
939 assert!(m1 <= m2);
940 assert!(m2 >= m1);
941
942 let m3 = Money::new(100.0, usd);
944 assert_eq!(m1, m3);
945 }
946
947 #[rstest]
948 fn test_add() {
949 let a = 1000.0;
950 let b = 500.0;
951 let money1 = Money::new(a, Currency::USD());
952 let money2 = Money::new(b, Currency::USD());
953 let money3 = money1 + money2;
954 assert_eq!(money3.raw, Money::new(a + b, Currency::USD()).raw);
955 }
956
957 #[rstest]
958 fn test_sub() {
959 let usd = Currency::USD();
960 let money1 = Money::new(1000.0, usd);
961 let money2 = Money::new(250.0, usd);
962 let result = money1 - money2;
963 assert!(approx_eq!(f64, result.as_f64(), 750.0, epsilon = 1e-9));
964 assert_eq!(result.currency, usd);
965 }
966
967 #[rstest]
968 fn test_money_checked_add_within_bounds() {
969 let usd = Currency::USD();
970 let a = Money::new(100.0, usd);
971 let b = Money::new(50.0, usd);
972 assert_eq!(a.checked_add(b), Some(Money::new(150.0, usd)));
973 }
974
975 #[rstest]
976 fn test_money_checked_add_above_max_returns_none() {
977 let usd = Currency::USD();
978 let near_max = Money::from_raw(MONEY_RAW_MAX, usd);
979 let one = Money::new(1.0, usd);
980 assert_eq!(near_max.checked_add(one), None);
981 }
982
983 #[rstest]
984 fn test_money_checked_sub_within_bounds() {
985 let usd = Currency::USD();
986 let a = Money::new(100.0, usd);
987 let b = Money::new(40.0, usd);
988 assert_eq!(a.checked_sub(b), Some(Money::new(60.0, usd)));
989 }
990
991 #[rstest]
992 fn test_money_checked_sub_below_min_returns_none() {
993 let usd = Currency::USD();
994 let near_min = Money::from_raw(MONEY_RAW_MIN, usd);
995 let one = Money::new(1.0, usd);
996 assert_eq!(near_min.checked_sub(one), None);
997 }
998
999 #[rstest]
1000 #[should_panic(expected = "Currency mismatch")]
1001 fn test_money_checked_add_currency_mismatch_panics() {
1002 let usd = Money::new(100.0, Currency::USD());
1003 let aud = Money::new(50.0, Currency::AUD());
1004 let _ = usd.checked_add(aud);
1005 }
1006
1007 #[rstest]
1008 #[should_panic(expected = "Currency mismatch")]
1009 fn test_money_checked_sub_currency_mismatch_panics() {
1010 let usd = Money::new(100.0, Currency::USD());
1011 let aud = Money::new(50.0, Currency::AUD());
1012 let _ = usd.checked_sub(aud);
1013 }
1014
1015 #[rstest]
1016 fn test_money_checked_add_at_exact_max_returns_some() {
1017 let usd = Currency::USD();
1018 let near_max = Money::from_raw(MONEY_RAW_MAX - 1, usd);
1019 let one_unit = Money::from_raw(1, usd);
1020 assert_eq!(
1021 near_max.checked_add(one_unit),
1022 Some(Money::from_raw(MONEY_RAW_MAX, usd)),
1023 );
1024 }
1025
1026 #[rstest]
1027 fn test_money_checked_sub_at_exact_min_returns_some() {
1028 let usd = Currency::USD();
1029 let near_min = Money::from_raw(MONEY_RAW_MIN + 1, usd);
1030 let one_unit = Money::from_raw(1, usd);
1031 assert_eq!(
1032 near_min.checked_sub(one_unit),
1033 Some(Money::from_raw(MONEY_RAW_MIN, usd)),
1034 );
1035 }
1036
1037 #[rstest]
1038 fn test_money_negation() {
1039 let money = Money::new(100.0, Currency::USD());
1040 let result = -money;
1041 assert_eq!(result, Money::from("-100.0 USD"));
1042 assert_eq!(result.currency, Currency::USD().clone());
1043 }
1044
1045 #[rstest]
1046 fn test_money_addition_decimal() {
1047 let money = Money::new(100.0, Currency::USD());
1048 let result = money + dec!(50.25);
1049 assert_eq!(result, dec!(150.25));
1050 }
1051
1052 #[rstest]
1053 fn test_money_subtraction_decimal() {
1054 let money = Money::new(100.0, Currency::USD());
1055 let result = money - dec!(30.50);
1056 assert_eq!(result, dec!(69.50));
1057 }
1058
1059 #[rstest]
1060 fn test_money_multiplication_decimal() {
1061 let money = Money::new(100.0, Currency::USD());
1062 let result = money * dec!(1.5);
1063 assert_eq!(result, dec!(150.00));
1064 }
1065
1066 #[rstest]
1067 fn test_money_division_decimal() {
1068 let money = Money::new(100.0, Currency::USD());
1069 let result = money / dec!(4);
1070 assert_eq!(result, dec!(25.00));
1071 }
1072
1073 #[rstest]
1074 fn test_money_addition_f64() {
1075 let money = Money::new(100.0, Currency::USD());
1076 let result = money + 50.25;
1077 assert!(approx_eq!(f64, result, 150.25, epsilon = 1e-9));
1078 }
1079
1080 #[rstest]
1081 fn test_money_subtraction_f64() {
1082 let money = Money::new(100.0, Currency::USD());
1083 let result = money - 30.50;
1084 assert!(approx_eq!(f64, result, 69.50, epsilon = 1e-9));
1085 }
1086
1087 #[rstest]
1088 fn test_money_multiplication_f64() {
1089 let money = Money::new(100.0, Currency::USD());
1090 let result = money * 1.5;
1091 assert!(approx_eq!(f64, result, 150.0, epsilon = 1e-9));
1092 }
1093
1094 #[rstest]
1095 fn test_money_division_f64() {
1096 let money = Money::new(100.0, Currency::USD());
1097 let result = money / 4.0;
1098 assert!(approx_eq!(f64, result, 25.0, epsilon = 1e-9));
1099 }
1100
1101 #[rstest]
1102 fn test_money_new_usd() {
1103 let money = Money::new(1000.0, Currency::USD());
1104 assert_eq!(money.currency.code.as_str(), "USD");
1105 assert_eq!(money.currency.precision, 2);
1106 assert_eq!(money.to_string(), "1000.00 USD");
1107 assert_eq!(money.to_formatted_string(), "1_000.00 USD");
1108 assert_eq!(money.as_decimal(), dec!(1000.00));
1109 assert!(approx_eq!(f64, money.as_f64(), 1000.0, epsilon = 0.001));
1110 }
1111
1112 #[rstest]
1113 fn test_money_new_btc() {
1114 let money = Money::new(10.3, Currency::BTC());
1115 assert_eq!(money.currency.code.as_str(), "BTC");
1116 assert_eq!(money.currency.precision, 8);
1117 assert_eq!(money.to_string(), "10.30000000 BTC");
1118 assert_eq!(money.to_formatted_string(), "10.30000000 BTC");
1119 }
1120
1121 #[rstest]
1122 fn test_to_formatted_string_preserves_digits_beyond_f64_precision() {
1123 use crate::enums::CurrencyType;
1124
1125 let currency = Currency::new("TST9", 9, 0, "Test 9dp", CurrencyType::Crypto);
1128 let money = Money::from_decimal(dec!(1234567890.123456789), currency).unwrap();
1129
1130 assert_eq!(money.to_formatted_string(), "1_234_567_890.123456789 TST9");
1131 }
1132
1133 #[rstest]
1134 #[case("0USD")] #[case("0x00 USD")] #[case("0 US")] #[case("0 USD USD")] #[should_panic(expected = "Condition failed")]
1139 fn test_from_str_invalid_input(#[case] input: &str) {
1140 let _ = Money::from(input);
1141 }
1142
1143 #[rstest]
1144 #[case("0 USD", Currency::USD(), dec!(0.00))]
1145 #[case("1.1 AUD", Currency::AUD(), dec!(1.10))]
1146 #[case("1.12345678 BTC", Currency::BTC(), dec!(1.12345678))]
1147 #[case("10_000.10 USD", Currency::USD(), dec!(10000.10))]
1148 fn test_from_str_valid_input(
1149 #[case] input: &str,
1150 #[case] expected_currency: Currency,
1151 #[case] expected_dec: Decimal,
1152 ) {
1153 let money = Money::from(input);
1154 assert_eq!(money.currency, expected_currency);
1155 assert_eq!(money.as_decimal(), expected_dec);
1156 }
1157
1158 #[rstest]
1159 fn test_money_from_str_negative() {
1160 let money = Money::from("-123.45 USD");
1161 assert!(approx_eq!(f64, money.as_f64(), -123.45, epsilon = 1e-9));
1162 assert_eq!(money.currency, Currency::USD());
1163 }
1164
1165 #[rstest]
1166 #[case("1e7 USD", 10_000_000.0)]
1167 #[case("2.5e3 EUR", 2_500.0)]
1168 #[case("1.234e-2 GBP", 0.01)] #[case("5E-3 JPY", 0.0)] fn test_from_str_scientific_notation(#[case] input: &str, #[case] expected_value: f64) {
1171 let money = Money::from_str(input).unwrap();
1172 assert!(approx_eq!(
1173 f64,
1174 money.as_f64(),
1175 expected_value,
1176 epsilon = 1e-10
1177 ));
1178 }
1179
1180 #[rstest]
1181 #[case("1_234.56 USD", 1234.56)]
1182 #[case("1_000_000 EUR", 1_000_000.0)]
1183 #[case("99_999.99 GBP", 99_999.99)]
1184 fn test_from_str_with_underscores(#[case] input: &str, #[case] expected_value: f64) {
1185 let money = Money::from_str(input).unwrap();
1186 assert!(approx_eq!(
1187 f64,
1188 money.as_f64(),
1189 expected_value,
1190 epsilon = 1e-10
1191 ));
1192 }
1193
1194 #[rstest]
1195 fn test_from_decimal_precision_preservation() {
1196 use rust_decimal::Decimal;
1197
1198 let decimal = Decimal::from_str("123.45").unwrap();
1199 let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
1200 assert_eq!(money.currency.precision, 2);
1201 assert!(approx_eq!(f64, money.as_f64(), 123.45, epsilon = 1e-10));
1202
1203 let expected_raw = 12345 * 10_i64.pow(u32::from(FIXED_PRECISION - 2));
1205 assert_eq!(money.raw, MoneyRaw::from(expected_raw));
1206 }
1207
1208 #[rstest]
1209 fn test_from_decimal_rounding() {
1210 use rust_decimal::Decimal;
1211
1212 let decimal = Decimal::from_str("1.005").unwrap();
1214 let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
1215 assert_eq!(money.as_f64(), 1.0); let decimal = Decimal::from_str("1.015").unwrap();
1218 let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
1219 assert_eq!(money.as_f64(), 1.02); }
1221
1222 #[rstest]
1223 fn test_money_hash() {
1224 use std::{
1225 collections::hash_map::DefaultHasher,
1226 hash::{Hash, Hasher},
1227 };
1228
1229 let m1 = Money::new(100.0, Currency::USD());
1230 let m2 = Money::new(100.0, Currency::USD());
1231 let m3 = Money::new(100.0, Currency::AUD());
1232
1233 let mut s1 = DefaultHasher::new();
1234 let mut s2 = DefaultHasher::new();
1235 let mut s3 = DefaultHasher::new();
1236
1237 m1.hash(&mut s1);
1238 m2.hash(&mut s2);
1239 m3.hash(&mut s3);
1240
1241 assert_eq!(
1242 s1.finish(),
1243 s2.finish(),
1244 "Same amount + same currency => same hash"
1245 );
1246 assert_ne!(
1247 s1.finish(),
1248 s3.finish(),
1249 "Same amount + different currency => different hash"
1250 );
1251 }
1252
1253 #[rstest]
1254 fn test_money_serialization_deserialization() {
1255 let money = Money::new(123.45, Currency::USD());
1256 let serialized = serde_json::to_string(&money);
1257 let deserialized: Money = serde_json::from_str(&serialized.unwrap()).unwrap();
1258 assert_eq!(money, deserialized);
1259 }
1260
1261 #[rstest]
1262 fn test_money_deserialize_from_owned_value() {
1263 let money = Money::new(123.45, Currency::USD());
1264 let value = serde_json::to_value(money).unwrap();
1265
1266 let deserialized: Money = serde_json::from_value(value).unwrap();
1267 assert_eq!(money, deserialized);
1268 }
1269
1270 #[rstest]
1271 fn test_money_deserialize_invalid_format_returns_error() {
1272 let result = serde_json::from_str::<Money>("\"100.00\"");
1273 let error = result.unwrap_err();
1274 assert!(
1275 error.to_string().contains("Expected '<amount> <currency>'"),
1276 "unexpected message: {error}"
1277 );
1278 }
1279
1280 #[rstest]
1281 fn test_money_deserialize_unknown_currency_returns_error() {
1282 let result = serde_json::from_str::<Money>("\"100.00 ZZZZ\"");
1283 let error = result.unwrap_err();
1284 assert!(
1285 error.to_string().contains("Unknown currency"),
1286 "unexpected message: {error}"
1287 );
1288 }
1289
1290 #[rstest]
1291 #[should_panic(expected = "`raw` value")]
1292 fn test_money_from_raw_out_of_range_panics() {
1293 let usd = Currency::USD();
1294 let raw = MONEY_RAW_MAX.saturating_add(1);
1295 let _ = Money::from_raw(raw, usd);
1296 }
1297
1298 #[rstest]
1299 fn test_money_from_raw_checked_valid() {
1300 let usd = Currency::USD();
1301 let money = Money::from_raw_checked(123_450_000_000, usd).unwrap();
1302 assert_eq!(money.currency, usd);
1303 }
1304
1305 #[rstest]
1306 fn test_money_from_raw_checked_above_max_returns_error() {
1307 let usd = Currency::USD();
1308 let raw = MONEY_RAW_MAX.saturating_add(1);
1309 let error = Money::from_raw_checked(raw, usd).unwrap_err();
1310 assert!(matches!(error, CorrectnessError::PredicateViolation { .. }));
1311 }
1312
1313 #[rstest]
1314 fn test_money_from_raw_checked_below_min_returns_error() {
1315 let usd = Currency::USD();
1316 let raw = MONEY_RAW_MIN.saturating_sub(1);
1317 let error = Money::from_raw_checked(raw, usd).unwrap_err();
1318 assert!(matches!(error, CorrectnessError::PredicateViolation { .. }));
1319 }
1320
1321 #[rstest]
1322 fn test_from_decimal_rejects_out_of_range() {
1323 let huge = Decimal::from_str("99999999999999999999.99").unwrap();
1324 let result = Money::from_decimal(huge, Currency::USD());
1325 assert!(result.is_err());
1326 }
1327
1328 #[rstest]
1329 fn test_from_decimal_out_of_range_returns_typed_error_with_stable_display() {
1330 let huge = Decimal::from_str("99999999999999999999.99").unwrap();
1331 let error = Money::from_decimal(huge, Currency::USD()).unwrap_err();
1332 match error {
1333 CorrectnessError::PredicateViolation { ref message } => {
1334 assert!(
1335 message.contains("MoneyRaw range") || message.contains("Money"),
1336 "unexpected message: {message:?}",
1337 );
1338 }
1339 _ => panic!("expected PredicateViolation, was {error:?}"),
1340 }
1341 }
1342
1343 #[rstest]
1344 fn test_from_mantissa_exponent_exact_precision() {
1345 let money = Money::from_mantissa_exponent(12345, -2, Currency::USD());
1346 assert_eq!(money.as_f64(), 123.45);
1347 }
1348
1349 #[rstest]
1350 fn test_from_mantissa_exponent_excess_rounds_down() {
1351 let money = Money::from_mantissa_exponent(12345, -3, Currency::USD());
1353 assert_eq!(money.as_f64(), 12.34);
1354 }
1355
1356 #[rstest]
1357 fn test_from_mantissa_exponent_excess_rounds_up() {
1358 let money = Money::from_mantissa_exponent(12355, -3, Currency::USD());
1360 assert_eq!(money.as_f64(), 12.36);
1361 }
1362
1363 #[rstest]
1364 fn test_from_mantissa_exponent_positive_exponent() {
1365 let money = Money::from_mantissa_exponent(5, 2, Currency::USD());
1366 assert_eq!(money.as_f64(), 500.0);
1367 }
1368
1369 #[rstest]
1370 #[should_panic(expected = "Money::from_mantissa_exponent")]
1371 fn test_from_mantissa_exponent_overflow_panics() {
1372 let _ = Money::from_mantissa_exponent(i64::MAX, 9, Currency::USD());
1373 }
1374
1375 #[rstest]
1376 #[should_panic(expected = "exceeds i128 range")]
1377 fn test_from_mantissa_exponent_large_exponent_panics() {
1378 let _ = Money::from_mantissa_exponent(1, 119, Currency::USD());
1379 }
1380
1381 #[rstest]
1382 fn test_from_mantissa_exponent_zero_with_large_exponent() {
1383 let money = Money::from_mantissa_exponent(0, 119, Currency::USD());
1384 assert_eq!(money.as_f64(), 0.0);
1385 }
1386
1387 #[rstest]
1388 fn test_from_mantissa_exponent_very_negative_exponent_rounds_to_zero() {
1389 let money = Money::from_mantissa_exponent(12345, -120, Currency::USD());
1391 assert_eq!(money.as_f64(), 0.0);
1392 }
1393
1394 #[rstest]
1395 #[case(42.0, true, "positive value")]
1396 #[case(0.0, false, "zero value")]
1397 #[case( -13.5, false, "negative value")]
1398 #[expect(
1399 clippy::used_underscore_binding,
1400 reason = "rstest case name documents the parameterized input"
1401 )]
1402 fn test_check_positive_money(
1403 #[case] amount: f64,
1404 #[case] should_succeed: bool,
1405 #[case] _case_name: &str,
1406 ) {
1407 let money = Money::new(amount, Currency::USD());
1408
1409 let res = check_positive_money(money, "money");
1410
1411 if should_succeed {
1412 assert!(res.is_ok(), "expected Ok(..) for {amount}");
1413 } else {
1414 assert!(res.is_err(), "expected Err(..) for {amount}");
1415 let msg = res.unwrap_err().to_string();
1416 assert!(
1417 msg.contains("not positive"),
1418 "error message should mention positivity; got: {msg:?}"
1419 );
1420 }
1421 }
1422
1423 #[rstest]
1424 fn test_check_positive_money_returns_typed_error_with_stable_display() {
1425 let error = check_positive_money(Money::new(0.0, Currency::USD()), "money").unwrap_err();
1426
1427 assert_eq!(
1428 error,
1429 CorrectnessError::NotPositive {
1430 param: "money".to_string(),
1431 value: "0.00 USD".to_string(),
1432 type_name: "`Money`",
1433 }
1434 );
1435 assert_eq!(
1436 error.to_string(),
1437 "invalid `Money` for 'money' not positive, was 0.00 USD"
1438 );
1439 }
1440}
1441
1442#[cfg(test)]
1443mod property_tests {
1444 use proptest::prelude::*;
1445 use rstest::rstest;
1446
1447 use super::*;
1448
1449 fn currency_strategy() -> impl Strategy<Value = Currency> {
1450 prop_oneof![
1451 Just(Currency::USD()),
1452 Just(Currency::EUR()),
1453 Just(Currency::GBP()),
1454 Just(Currency::JPY()),
1455 Just(Currency::AUD()),
1456 Just(Currency::CAD()),
1457 Just(Currency::CHF()),
1458 Just(Currency::BTC()),
1459 Just(Currency::ETH()),
1460 Just(Currency::USDT()),
1461 ]
1462 }
1463
1464 fn money_amount_strategy() -> impl Strategy<Value = f64> {
1465 prop_oneof![
1466 -1000.0..1000.0,
1467 -100_000.0..100_000.0,
1468 -1_000_000.0..1_000_000.0,
1469 Just(0.0),
1470 Just(MONEY_MIN / 2.0),
1471 Just(MONEY_MAX / 2.0),
1472 Just(MONEY_MIN + 1.0),
1473 Just(MONEY_MAX - 1.0),
1474 Just(MONEY_MIN),
1475 Just(MONEY_MAX),
1476 ]
1477 }
1478
1479 fn money_strategy() -> impl Strategy<Value = Money> {
1480 (money_amount_strategy(), currency_strategy())
1481 .prop_filter_map("constructible money", |(amount, currency)| {
1482 Money::new_checked(amount, currency).ok()
1483 })
1484 }
1485
1486 proptest! {
1487 #[rstest]
1488 fn prop_money_construction_roundtrip(
1489 amount in money_amount_strategy(),
1490 currency in currency_strategy()
1491 ) {
1492 if let Ok(money) = Money::new_checked(amount, currency) {
1493 let roundtrip = money.as_f64();
1494 let precision_epsilon = if currency.precision == 0 {
1495 1.0
1496 } else {
1497 let currency_epsilon = 10.0_f64.powi(-i32::from(currency.precision));
1498 let magnitude_epsilon = amount.abs() * 1e-10;
1499 currency_epsilon.max(magnitude_epsilon)
1500 };
1501 prop_assert!((roundtrip - amount).abs() <= precision_epsilon,
1502 "Roundtrip failed: {} -> {} -> {} (precision: {}, epsilon: {})",
1503 amount, money.raw, roundtrip, currency.precision, precision_epsilon);
1504 prop_assert_eq!(money.currency, currency);
1505 }
1506 }
1507
1508 #[rstest]
1509 fn prop_money_addition_commutative(
1510 money1 in money_strategy(),
1511 money2 in money_strategy(),
1512 ) {
1513 if money1.currency == money2.currency
1514 && let (Some(_), Some(_)) = (
1515 money1.raw.checked_add(money2.raw),
1516 money2.raw.checked_add(money1.raw)
1517 )
1518 {
1519 let sum1 = money1 + money2;
1520 let sum2 = money2 + money1;
1521 prop_assert_eq!(sum1, sum2, "Addition should be commutative");
1522 prop_assert_eq!(sum1.currency, money1.currency);
1523 }
1524 }
1525
1526 #[rstest]
1527 fn prop_money_addition_associative(
1528 money1 in money_strategy(),
1529 money2 in money_strategy(),
1530 money3 in money_strategy(),
1531 ) {
1532 if money1.currency == money2.currency
1533 && money2.currency == money3.currency
1534 && let (Some(sum1), Some(sum2)) = (
1535 money1.raw.checked_add(money2.raw),
1536 money2.raw.checked_add(money3.raw)
1537 )
1538 && let (Some(left), Some(right)) = (
1539 sum1.checked_add(money3.raw),
1540 money1.raw.checked_add(sum2)
1541 )
1542 && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&left)
1543 && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&right)
1544 {
1545 let left_result = Money::from_raw(left, money1.currency);
1546 let right_result = Money::from_raw(right, money1.currency);
1547 prop_assert_eq!(left_result, right_result, "Addition should be associative");
1548 }
1549 }
1550
1551 #[rstest]
1552 fn prop_money_subtraction_inverse(
1553 money1 in money_strategy(),
1554 money2 in money_strategy(),
1555 ) {
1556 if money1.currency == money2.currency
1557 && let Some(sum_raw) = money1.raw.checked_add(money2.raw)
1558 && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&sum_raw)
1559 {
1560 let sum = Money::from_raw(sum_raw, money1.currency);
1561 let diff = sum - money2;
1562 prop_assert_eq!(diff, money1, "Subtraction should be inverse of addition");
1563 }
1564 }
1565
1566 #[rstest]
1569 fn prop_money_checked_add_matches_spec(
1570 raw1 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1571 raw2 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1572 currency in currency_strategy(),
1573 ) {
1574 let m1 = Money::from_raw(raw1, currency);
1575 let m2 = Money::from_raw(raw2, currency);
1576 let expected = m1.raw
1577 .checked_add(m2.raw)
1578 .filter(|r| (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(r))
1579 .map(|raw| Money { raw, currency });
1580 prop_assert_eq!(m1.checked_add(m2), expected);
1581 }
1582
1583 #[rstest]
1586 fn prop_money_checked_sub_matches_spec(
1587 raw1 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1588 raw2 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1589 currency in currency_strategy(),
1590 ) {
1591 let m1 = Money::from_raw(raw1, currency);
1592 let m2 = Money::from_raw(raw2, currency);
1593 let expected = m1.raw
1594 .checked_sub(m2.raw)
1595 .filter(|r| (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(r))
1596 .map(|raw| Money { raw, currency });
1597 prop_assert_eq!(m1.checked_sub(m2), expected);
1598 }
1599
1600 #[rstest]
1601 fn prop_money_zero_identity(money in money_strategy()) {
1602 let zero = Money::zero(money.currency);
1603 prop_assert_eq!(money + zero, money, "Zero should be additive identity");
1604 prop_assert_eq!(zero + money, money, "Zero should be additive identity (commutative)");
1605 prop_assert!(zero.is_zero(), "Zero should be recognized as zero");
1606 }
1607
1608 #[rstest]
1609 fn prop_money_negation_inverse(money in money_strategy()) {
1610 let negated = -money;
1611 let double_neg = -negated;
1612 prop_assert_eq!(money, double_neg, "Double negation should equal original");
1613 prop_assert_eq!(negated.currency, money.currency, "Negation preserves currency");
1614
1615 if let Some(sum_raw) = money.raw.checked_add(negated.raw)
1616 && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&sum_raw) {
1617 let sum = Money::from_raw(sum_raw, money.currency);
1618 prop_assert!(sum.is_zero(), "Money + (-Money) should equal zero");
1619 }
1620 }
1621
1622 #[rstest]
1623 fn prop_money_comparison_consistency(
1624 money1 in money_strategy(),
1625 money2 in money_strategy(),
1626 ) {
1627 if money1.currency == money2.currency {
1628 let eq = money1 == money2;
1629 let lt = money1 < money2;
1630 let gt = money1 > money2;
1631 let le = money1 <= money2;
1632 let ge = money1 >= money2;
1633
1634 let exclusive_count = [eq, lt, gt].iter().filter(|&&x| x).count();
1635 prop_assert_eq!(exclusive_count, 1, "Exactly one of ==, <, > should be true");
1636
1637 prop_assert_eq!(le, eq || lt, "<= should equal == || <");
1638 prop_assert_eq!(ge, eq || gt, ">= should equal == || >");
1639 prop_assert_eq!(lt, money2 > money1, "< should be symmetric with >");
1640 prop_assert_eq!(le, money2 >= money1, "<= should be symmetric with >=");
1641 }
1642 }
1643
1644 #[rstest]
1645 fn prop_money_decimal_conversion(money in money_strategy()) {
1646 let decimal = money.as_decimal();
1647
1648 prop_assert_eq!(decimal.scale(), u32::from(money.currency.precision));
1650
1651 #[cfg(feature = "defi")]
1652 {
1653 let decimal_f64: f64 = decimal.try_into().unwrap_or(0.0);
1654 prop_assert!(decimal_f64.is_finite(), "Decimal should convert to finite f64");
1655 }
1656 #[cfg(not(feature = "defi"))]
1657 {
1658 let decimal_f64: f64 = decimal.try_into().unwrap_or(0.0);
1659 let original_f64 = money.as_f64();
1660
1661 let base_epsilon = 10.0_f64.powi(-(money.currency.precision as i32));
1662 let precision_epsilon = if cfg!(feature = "high-precision") {
1663 base_epsilon.max(1e-10)
1664 } else {
1665 base_epsilon
1666 };
1667 let diff = (decimal_f64 - original_f64).abs();
1668 prop_assert!(diff <= precision_epsilon,
1669 "Decimal conversion should preserve value within currency precision: {} vs {} (diff: {}, epsilon: {})",
1670 original_f64, decimal_f64, diff, precision_epsilon);
1671 }
1672 }
1673
1674 #[rstest]
1675 fn prop_money_arithmetic_with_f64(
1676 money in money_strategy(),
1677 factor in -1000.0..1000.0_f64,
1678 ) {
1679 if factor != 0.0 {
1680 let original_f64 = money.as_f64();
1681
1682 let mul_result = money * factor;
1683 let expected_mul = original_f64 * factor;
1684 prop_assert!((mul_result - expected_mul).abs() < 0.01,
1685 "Multiplication with f64 should be accurate");
1686
1687 let div_result = money / factor;
1688 let expected_div = original_f64 / factor;
1689 if expected_div.is_finite() {
1690 prop_assert!((div_result - expected_div).abs() < 0.01,
1691 "Division with f64 should be accurate");
1692 }
1693
1694 let add_result = money + factor;
1695 let expected_add = original_f64 + factor;
1696 prop_assert!((add_result - expected_add).abs() < 0.01,
1697 "Addition with f64 should be accurate");
1698
1699 let sub_result = money - factor;
1700 let expected_sub = original_f64 - factor;
1701 prop_assert!((sub_result - expected_sub).abs() < 0.01,
1702 "Subtraction with f64 should be accurate");
1703 }
1704 }
1705 }
1706}