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