1use std::{
59 cmp::Ordering,
60 fmt::{Debug, Display},
61 hash::{Hash, Hasher},
62 ops::{Add, Div, Mul, Neg, Sub},
63 str::FromStr,
64};
65
66use nautilus_core::{
67 correctness::{
68 CorrectnessError, CorrectnessResult, CorrectnessResultExt, FAILED,
69 check_in_range_inclusive_f64, check_predicate_false, check_predicate_true,
70 },
71 string::formatting::Separable,
72};
73use rust_decimal::{Decimal, RoundingStrategy};
74use serde::{Deserialize, Deserializer, Serialize};
75
76#[cfg(not(any(feature = "defi", feature = "high-precision")))]
77use super::fixed::{f64_to_fixed_i64, fixed_i64_to_f64};
78#[cfg(any(feature = "defi", feature = "high-precision"))]
79use super::fixed::{f64_to_fixed_i128, fixed_i128_to_f64};
80#[cfg(feature = "defi")]
81use crate::types::fixed::MAX_FLOAT_PRECISION;
82use crate::{
83 deserialization::StringInput,
84 types::{
85 Currency, Quantity,
86 fixed::{
87 FIXED_PRECISION, FIXED_SCALAR, canonical_raw, check_fixed_precision,
88 check_fixed_raw_i128, check_fixed_raw_u128, compare_raw_signed, format_scaled_i128,
89 mantissa_exponent_to_fixed_i128, mantissa_exponent_to_raw_checked,
90 parse_decimal_mantissa, raw_scales_match, remove_underscores, scaled_raw_to_decimal,
91 },
92 },
93};
94
95#[cfg(feature = "high-precision")]
100pub type MoneyRaw = i128;
101
102#[cfg(not(feature = "high-precision"))]
103pub type MoneyRaw = i64;
104
105#[unsafe(no_mangle)]
116#[allow(unsafe_code)]
117pub static MONEY_RAW_MAX: MoneyRaw = (MONEY_MAX as MoneyRaw) * (FIXED_SCALAR as MoneyRaw);
118
119#[unsafe(no_mangle)]
128#[allow(unsafe_code)]
129pub static MONEY_RAW_MIN: MoneyRaw = (MONEY_MIN as MoneyRaw) * (FIXED_SCALAR as MoneyRaw);
130
131#[cfg(feature = "high-precision")]
136pub const MONEY_MAX: f64 = 17_014_118_346_046.0;
138
139#[cfg(not(feature = "high-precision"))]
140pub const MONEY_MAX: f64 = 9_223_372_036.0;
142
143#[cfg(feature = "high-precision")]
148pub const MONEY_MIN: f64 = -17_014_118_346_046.0;
150
151#[cfg(not(feature = "high-precision"))]
152pub const MONEY_MIN: f64 = -9_223_372_036.0;
154
155#[repr(C)]
165#[derive(Clone, Copy, Eq)]
166#[cfg_attr(
167 feature = "python",
168 pyo3::pyclass(module = "nautilus_trader.model", frozen, from_py_object)
169)]
170#[cfg_attr(
171 feature = "python",
172 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
173)]
174pub struct Money {
175 pub(crate) raw: MoneyRaw,
176 pub currency: Currency,
178}
179
180impl Money {
181 pub fn new_checked(amount: f64, currency: Currency) -> CorrectnessResult<Self> {
193 check_in_range_inclusive_f64(amount, MONEY_MIN, MONEY_MAX, "amount")?;
197
198 #[cfg(feature = "defi")]
199 if currency.precision > MAX_FLOAT_PRECISION {
200 return Err(CorrectnessError::PredicateViolation {
202 message: format!(
203 "`currency.precision` exceeded maximum float precision ({MAX_FLOAT_PRECISION}), use `Money::from_wei()` for wei values instead"
204 ),
205 });
206 }
207
208 check_fixed_precision(currency.precision)?;
209
210 #[cfg(feature = "high-precision")]
211 let raw = f64_to_fixed_i128(amount, currency.precision);
212
213 #[cfg(not(feature = "high-precision"))]
214 let raw = f64_to_fixed_i64(amount, currency.precision);
215
216 Ok(Self { raw, currency })
217 }
218
219 #[must_use]
225 pub fn new(amount: f64, currency: Currency) -> Self {
226 Self::new_checked(amount, currency).expect_display(FAILED)
227 }
228
229 #[must_use]
235 pub fn from_raw(raw: MoneyRaw, currency: Currency) -> Self {
236 Self::from_raw_checked(raw, currency).expect_display(FAILED)
237 }
238
239 pub fn from_raw_checked(raw: MoneyRaw, currency: Currency) -> CorrectnessResult<Self> {
248 if raw < MONEY_RAW_MIN || raw > MONEY_RAW_MAX {
249 return Err(CorrectnessError::PredicateViolation {
250 message: format!(
251 "`raw` value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
252 ),
253 });
254 }
255
256 check_fixed_precision(currency.precision)?;
257
258 Ok(Self { raw, currency })
259 }
260
261 #[must_use]
270 pub fn from_mantissa_exponent(mantissa: i64, exponent: i8, currency: Currency) -> Self {
271 check_fixed_precision(currency.precision).expect_display(FAILED);
272
273 if mantissa == 0 {
274 return Self { raw: 0, currency };
275 }
276
277 let raw_i128 =
278 mantissa_exponent_to_fixed_i128(i128::from(mantissa), exponent, currency.precision)
279 .expect("Overflow in Money::from_mantissa_exponent");
280
281 #[allow(
282 clippy::useless_conversion,
283 reason = "i128 to MoneyRaw is real when not high-precision"
284 )]
285 let raw: MoneyRaw = raw_i128
286 .try_into()
287 .expect("Raw value exceeds MoneyRaw range in Money::from_mantissa_exponent");
288 assert!(
289 raw >= MONEY_RAW_MIN && raw <= MONEY_RAW_MAX,
290 "`raw` value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
291 );
292
293 Self { raw, currency }
294 }
295
296 #[must_use]
302 pub fn zero(currency: Currency) -> Self {
303 check_fixed_precision(currency.precision).expect_display(FAILED);
304 Self { raw: 0, currency }
305 }
306
307 #[must_use]
310 pub fn normalized(&self) -> Self {
311 #[cfg(feature = "high-precision")]
312 let raw = super::fixed::correct_raw_i128(self.raw, self.currency.precision);
313
314 #[cfg(not(feature = "high-precision"))]
315 let raw = super::fixed::correct_raw_i64(self.raw, self.currency.precision);
316
317 Self {
318 raw,
319 currency: self.currency,
320 }
321 }
322
323 #[must_use]
336 #[inline]
337 pub const fn raw(&self) -> MoneyRaw {
338 self.raw
339 }
340
341 #[must_use]
343 pub fn is_zero(&self) -> bool {
344 self.raw == 0
345 }
346
347 #[must_use]
349 pub fn is_positive(&self) -> bool {
350 self.raw > 0
351 }
352
353 #[must_use]
355 pub fn is_negative(&self) -> bool {
356 self.raw < 0
357 }
358
359 #[must_use]
361 pub fn abs(self) -> Self {
362 if self.is_negative() { -self } else { self }
363 }
364
365 #[must_use]
375 pub fn checked_add(self, rhs: Self) -> Option<Self> {
376 assert_eq!(
377 self.currency, rhs.currency,
378 "Currency mismatch: cannot add {} to {}",
379 rhs.currency.code, self.currency.code
380 );
381
382 if !raw_scales_match(self.currency.precision, rhs.currency.precision) {
383 return None;
384 }
385
386 let raw = self.raw.checked_add(rhs.raw)?;
387 if raw < MONEY_RAW_MIN || raw > MONEY_RAW_MAX {
388 return None;
389 }
390
391 Some(Self {
392 raw,
393 currency: self.currency,
394 })
395 }
396
397 #[must_use]
407 pub fn checked_sub(self, rhs: Self) -> Option<Self> {
408 assert_eq!(
409 self.currency, rhs.currency,
410 "Currency mismatch: cannot subtract {} from {}",
411 rhs.currency.code, self.currency.code
412 );
413
414 if !raw_scales_match(self.currency.precision, rhs.currency.precision) {
415 return None;
416 }
417
418 let raw = self.raw.checked_sub(rhs.raw)?;
419 if raw < MONEY_RAW_MIN || raw > MONEY_RAW_MAX {
420 return None;
421 }
422
423 Some(Self {
424 raw,
425 currency: self.currency,
426 })
427 }
428
429 #[cfg(feature = "high-precision")]
430 #[must_use]
436 pub fn as_f64(&self) -> f64 {
437 #[cfg(feature = "defi")]
438 assert!(
439 self.currency.precision <= MAX_FLOAT_PRECISION,
440 "Invalid f64 conversion beyond `MAX_FLOAT_PRECISION` (16)"
441 );
442
443 fixed_i128_to_f64(self.raw)
444 }
445
446 #[cfg(not(feature = "high-precision"))]
447 #[must_use]
449 pub fn as_f64(&self) -> f64 {
450 fixed_i64_to_f64(self.raw)
451 }
452
453 #[must_use]
458 pub fn as_decimal(&self) -> Decimal {
459 let (raw, precision) = self.raw_at_precision();
460 scaled_raw_to_decimal(raw, precision)
461 }
462
463 #[must_use]
465 pub fn to_formatted_string(&self) -> String {
466 let amount_str = self.to_decimal_string();
467 format!(
468 "{} {}",
469 amount_str.separate_with_underscores(),
470 self.currency.code
471 )
472 }
473
474 pub(crate) fn to_decimal_string(self) -> String {
475 let (raw, precision) = self.raw_at_precision();
476 format_scaled_i128(raw, precision)
477 }
478
479 #[allow(
486 clippy::useless_conversion,
487 reason = "the raw width differs when high-precision is disabled"
488 )]
489 pub fn from_quantity(quantity: Quantity, currency: Currency) -> CorrectnessResult<Self> {
490 check_predicate_false(quantity.is_undefined(), "quantity was undefined")?;
491 Quantity::from_raw_checked(quantity.raw, quantity.precision)?;
492 check_fixed_raw_u128(u128::from(quantity.raw), quantity.precision).map_err(|e| {
493 CorrectnessError::PredicateViolation {
494 message: e.to_string(),
495 }
496 })?;
497
498 let raw = i128::try_from(u128::from(quantity.raw)).map_err(|_| {
499 CorrectnessError::PredicateViolation {
500 message: format!("quantity for {currency} exceeds signed raw bounds"),
501 }
502 })?;
503
504 Self::from_rescaled_raw(raw, quantity.precision, currency, "quantity")
505 }
506
507 fn raw_at_precision(&self) -> (i128, u8) {
508 let precision_diff = FIXED_PRECISION.saturating_sub(self.currency.precision);
509 let scale = MoneyRaw::pow(10, u32::from(precision_diff));
510 let mut raw = self.raw;
511 if raw % scale == 0 {
512 return (Self::raw_as_i128(raw / scale), self.currency.precision);
513 }
514
515 let mut precision = FIXED_PRECISION;
516
517 while raw % 10 == 0 {
518 raw /= 10;
519 precision -= 1;
520 }
521
522 (Self::raw_as_i128(raw), precision)
523 }
524
525 fn raw_as_i128(raw: MoneyRaw) -> i128 {
526 #[allow(
527 clippy::useless_conversion,
528 reason = "i128::from is a widening conversion when MoneyRaw is i64"
529 )]
530 i128::from(raw)
531 }
532
533 pub fn from_decimal(decimal: Decimal, currency: Currency) -> CorrectnessResult<Self> {
544 let exponent = -(decimal.scale() as i8);
545 let raw_i128 =
546 mantissa_exponent_to_fixed_i128(decimal.mantissa(), exponent, currency.precision)?;
547
548 #[allow(
549 clippy::useless_conversion,
550 reason = "i128 to MoneyRaw is real when not high-precision"
551 )]
552 let raw: MoneyRaw =
553 raw_i128
554 .try_into()
555 .map_err(|_| CorrectnessError::PredicateViolation {
556 message: format!(
557 "Decimal value exceeds MoneyRaw range [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}]"
558 ),
559 })?;
560
561 if !(raw >= MONEY_RAW_MIN && raw <= MONEY_RAW_MAX) {
562 return Err(CorrectnessError::PredicateViolation {
563 message: format!(
564 "Raw value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
565 ),
566 });
567 }
568
569 Ok(Self { raw, currency })
570 }
571
572 pub fn from_decimal_native(decimal: Decimal, currency: Currency) -> CorrectnessResult<Self> {
581 let rounded = decimal.round_dp_with_strategy(
582 u32::from(FIXED_PRECISION.max(currency.precision)),
583 RoundingStrategy::MidpointNearestEven,
584 );
585 Self::from_decimal_exact(rounded, currency)
586 }
587
588 pub fn from_decimal_exact(decimal: Decimal, currency: Currency) -> CorrectnessResult<Self> {
598 Self::from_exact_parts(decimal.mantissa(), decimal.scale() as i32, currency)
599 }
600
601 pub fn from_str_exact(value: &str) -> Result<Self, String> {
608 Self::parse(value, true)
609 }
610
611 pub(crate) fn from_decimal_str_exact(value: &str, currency: Currency) -> Result<Self, String> {
612 let invalid_decimal = || format!("Invalid decimal value '{value}'");
613
614 let (amount, exponent) = match value.split_once(['e', 'E']) {
615 Some((amount, exponent)) => {
616 let exponent = exponent.parse::<i16>().map_err(|_| invalid_decimal())?;
617 (amount, i32::from(exponent))
618 }
619 None => (value, 0),
620 };
621
622 let amount = if let Some((_, fraction)) = amount.split_once('.') {
623 if !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
624 return Err(invalid_decimal());
625 }
626
627 let trimmed = amount.trim_end_matches('0');
628 let trimmed = trimmed.strip_suffix('.').unwrap_or(trimmed);
629
630 if matches!(trimmed, "" | "+" | "-") && !fraction.is_empty() {
631 "0"
632 } else {
633 trimmed
634 }
635 } else {
636 amount
637 };
638
639 let (amount, whole_zeros) = if amount.contains('.') {
640 (amount, 0)
641 } else {
642 let trimmed = amount.trim_end_matches('0');
643 let zeros = i32::try_from(amount.len() - trimmed.len()).map_err(|e| e.to_string())?;
644 if zeros > 0 && matches!(trimmed, "" | "+" | "-") {
645 ("0", zeros)
646 } else {
647 (trimmed, zeros)
648 }
649 };
650
651 let (mantissa, precision) = parse_decimal_mantissa(amount)?;
652 Self::from_exact_parts(
653 mantissa,
654 i32::from(precision) - exponent - whole_zeros,
655 currency,
656 )
657 .map_err(|e| e.to_string())
658 }
659
660 fn from_exact_parts(
661 mut mantissa: i128,
662 mut precision: i32,
663 currency: Currency,
664 ) -> CorrectnessResult<Self> {
665 check_fixed_precision(currency.precision)?;
666 let target_precision = currency.precision.max(FIXED_PRECISION);
667 if mantissa == 0 {
668 return Ok(Self::zero(currency));
669 }
670
671 while precision > i32::from(target_precision) && mantissa % 10 == 0 {
672 mantissa /= 10;
673 precision -= 1;
674 }
675
676 if precision > i32::from(target_precision) {
677 return Err(CorrectnessError::PredicateViolation {
678 message: format!("amount exceeds native money precision {target_precision}"),
679 });
680 }
681
682 let exponent =
683 i8::try_from(-precision).map_err(|_| CorrectnessError::PredicateViolation {
684 message: "amount exceeds native money exponent range".to_string(),
685 })?;
686
687 let raw = mantissa_exponent_to_raw_checked::<MoneyRaw>(
688 mantissa,
689 exponent,
690 target_precision,
691 "exact Money construction",
692 "MoneyRaw",
693 "Money",
694 )?;
695 Self::from_raw_checked(raw, currency)
696 }
697
698 #[allow(
699 clippy::useless_conversion,
700 reason = "the raw width differs when high-precision is disabled"
701 )]
702 pub(crate) fn from_rescaled_raw(
703 raw: i128,
704 source_precision: u8,
705 currency: Currency,
706 subject: &str,
707 ) -> CorrectnessResult<Self> {
708 check_fixed_precision(source_precision)?;
709 check_fixed_precision(currency.precision)?;
710 let source_precision = source_precision.max(FIXED_PRECISION);
711 let target_precision = currency.precision.max(FIXED_PRECISION);
712
713 let raw = match source_precision.cmp(&target_precision) {
714 Ordering::Less => {
715 let scale = 10_i128.pow(u32::from(target_precision - source_precision));
716 raw.checked_mul(scale)
717 .ok_or_else(|| CorrectnessError::PredicateViolation {
718 message: format!(
719 "{subject} for {currency} overflowed while increasing raw scale"
720 ),
721 })?
722 }
723 Ordering::Greater => {
724 let scale = 10_i128.pow(u32::from(source_precision - target_precision));
725 check_predicate_true(
726 raw % scale == 0,
727 &format!("{subject} for {currency} loses precision when decreasing raw scale"),
728 )?;
729 raw / scale
730 }
731 Ordering::Equal => raw,
732 };
733
734 check_fixed_raw_i128(raw, currency.precision).map_err(|e| {
735 CorrectnessError::PredicateViolation {
736 message: e.to_string(),
737 }
738 })?;
739
740 let raw: MoneyRaw = raw
741 .try_into()
742 .map_err(|_| CorrectnessError::PredicateViolation {
743 message: format!("{subject} for {currency} exceeds Money raw bounds"),
744 })?;
745
746 Self::from_raw_checked(raw, currency)
747 }
748
749 fn parse(value: &str, exact: bool) -> Result<Self, String> {
750 let mut parts = value.split_whitespace();
751
752 let (Some(amount), Some(currency), None) = (parts.next(), parts.next(), parts.next())
754 else {
755 return Err(format!(
756 "Error invalid input format '{value}'. Expected '<amount> <currency>'"
757 ));
758 };
759
760 let clean_amount = remove_underscores(amount);
761
762 let currency = Currency::from_str(currency).map_err(|e| e.to_string())?;
763 if exact {
764 return Self::from_decimal_str_exact(&clean_amount, currency);
765 }
766
767 if clean_amount.contains('e') || clean_amount.contains('E') {
768 let decimal = Decimal::from_scientific(&clean_amount)
769 .map_err(|e| format!("Error parsing amount '{amount}' as Decimal: {e}"))?;
770 return Self::from_decimal(decimal, currency).map_err(|e| e.to_string());
771 }
772
773 let (mantissa, amount_precision) = parse_decimal_mantissa(&clean_amount)?;
774 let exponent = -i8::try_from(amount_precision).map_err(|e| e.to_string())?;
775 let raw = mantissa_exponent_to_raw_checked::<MoneyRaw>(
776 mantissa,
777 exponent,
778 currency.precision,
779 "Money::from_str",
780 "MoneyRaw",
781 "Money",
782 )
783 .map_err(|e| e.to_string())?;
784 Self::from_raw_checked(raw, currency).map_err(|e| e.to_string())
785 }
786}
787
788impl FromStr for Money {
789 type Err = String;
790
791 fn from_str(value: &str) -> Result<Self, Self::Err> {
792 Self::parse(value, false)
793 }
794}
795
796impl<T: AsRef<str>> From<T> for Money {
797 fn from(value: T) -> Self {
798 Self::from_str(value.as_ref()).expect(FAILED)
799 }
800}
801
802impl From<Money> for f64 {
803 fn from(money: Money) -> Self {
804 money.as_f64()
805 }
806}
807
808impl From<&Money> for f64 {
809 fn from(money: &Money) -> Self {
810 money.as_f64()
811 }
812}
813
814impl Hash for Money {
815 fn hash<H: Hasher>(&self, state: &mut H) {
816 self.raw.signum().hash(state);
817 canonical_raw(self.raw.unsigned_abs(), self.currency.precision).hash(state);
818 self.currency.hash(state);
819 }
820}
821
822impl PartialEq for Money {
823 fn eq(&self, other: &Self) -> bool {
824 self.cmp(other) == Ordering::Equal
825 }
826}
827
828impl PartialOrd for Money {
829 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
830 Some(self.cmp(other))
831 }
832}
833
834impl Ord for Money {
835 fn cmp(&self, other: &Self) -> Ordering {
836 self.currency.code.cmp(&other.currency.code).then_with(|| {
837 compare_raw_signed(
838 self.raw,
839 self.currency.precision,
840 other.raw,
841 other.currency.precision,
842 )
843 })
844 }
845}
846
847impl Neg for Money {
848 type Output = Self;
849 fn neg(self) -> Self::Output {
850 Self {
851 raw: -self.raw,
852 currency: self.currency,
853 }
854 }
855}
856
857impl Add for Money {
858 type Output = Self;
859 fn add(self, rhs: Self) -> Self::Output {
860 assert_eq!(
861 self.currency, rhs.currency,
862 "Currency mismatch: cannot add {} to {}",
863 rhs.currency.code, self.currency.code
864 );
865 assert!(
866 raw_scales_match(self.currency.precision, rhs.currency.precision),
867 "Cannot add `Money` values with mismatched decimal scales"
868 );
869 Self {
870 raw: self
871 .raw
872 .checked_add(rhs.raw)
873 .expect("Overflow occurred when adding `Money`"),
874 currency: self.currency,
875 }
876 }
877}
878
879impl Sub for Money {
880 type Output = Self;
881 fn sub(self, rhs: Self) -> Self::Output {
882 assert_eq!(
883 self.currency, rhs.currency,
884 "Currency mismatch: cannot subtract {} from {}",
885 rhs.currency.code, self.currency.code
886 );
887 assert!(
888 raw_scales_match(self.currency.precision, rhs.currency.precision),
889 "Cannot subtract `Money` values with mismatched decimal scales"
890 );
891 Self {
892 raw: self
893 .raw
894 .checked_sub(rhs.raw)
895 .expect("Underflow occurred when subtracting `Money`"),
896 currency: self.currency,
897 }
898 }
899}
900
901impl Add<Decimal> for Money {
902 type Output = Decimal;
903 fn add(self, rhs: Decimal) -> Self::Output {
904 self.as_decimal() + rhs
905 }
906}
907
908impl Sub<Decimal> for Money {
909 type Output = Decimal;
910 fn sub(self, rhs: Decimal) -> Self::Output {
911 self.as_decimal() - rhs
912 }
913}
914
915impl Mul<Decimal> for Money {
916 type Output = Decimal;
917 fn mul(self, rhs: Decimal) -> Self::Output {
918 self.as_decimal() * rhs
919 }
920}
921
922impl Div<Decimal> for Money {
923 type Output = Decimal;
924 fn div(self, rhs: Decimal) -> Self::Output {
925 self.as_decimal() / rhs
926 }
927}
928
929impl Add<f64> for Money {
930 type Output = f64;
931 fn add(self, rhs: f64) -> Self::Output {
932 self.as_f64() + rhs
933 }
934}
935
936impl Sub<f64> for Money {
937 type Output = f64;
938 fn sub(self, rhs: f64) -> Self::Output {
939 self.as_f64() - rhs
940 }
941}
942
943impl Mul<f64> for Money {
944 type Output = f64;
945 fn mul(self, rhs: f64) -> Self::Output {
946 self.as_f64() * rhs
947 }
948}
949
950impl Div<f64> for Money {
951 type Output = f64;
952 fn div(self, rhs: f64) -> Self::Output {
953 self.as_f64() / rhs
954 }
955}
956
957impl Debug for Money {
958 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
959 let amount = self.to_decimal_string();
960 write!(f, "Money({amount}, {})", self.currency)
961 }
962}
963
964impl Display for Money {
965 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
966 let amount = self.to_decimal_string();
967 write!(f, "{amount} {}", self.currency)
968 }
969}
970
971impl Serialize for Money {
972 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
973 where
974 S: serde::Serializer,
975 {
976 serializer.serialize_str(&self.to_string())
977 }
978}
979
980impl<'de> Deserialize<'de> for Money {
981 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
982 where
983 D: Deserializer<'de>,
984 {
985 let StringInput(money_str) = Deserialize::deserialize(deserializer)?;
986 Self::from_str_exact(money_str.as_ref()).map_err(serde::de::Error::custom)
987 }
988}
989
990#[inline(always)]
996pub fn check_positive_money(value: Money, param: &str) -> CorrectnessResult<()> {
997 if value.raw <= 0 {
998 return Err(CorrectnessError::NotPositive {
999 param: param.to_string(),
1000 value: value.to_string(),
1001 type_name: "`Money`",
1002 });
1003 }
1004 Ok(())
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009 use nautilus_core::{approx_eq, correctness::CorrectnessError};
1010 use rstest::rstest;
1011 use rust_decimal_macros::dec;
1012
1013 use super::*;
1014 use crate::enums::CurrencyType;
1015
1016 #[rstest]
1017 #[case(dec!(1.0001), "1.0001 USD")]
1018 #[case(dec!(0.000001), "0.000001 USD")]
1019 #[case(dec!(-0.000001), "-0.000001 USD")]
1020 #[case(dec!(1234.500100), "1234.5001 USD")]
1021 #[case(dec!(1.230000), "1.23 USD")]
1022 #[case(dec!(0.000000), "0.00 USD")]
1023 fn test_exact_usd_roundtrip(#[case] amount: Decimal, #[case] expected: &str) {
1024 let money = Money::from_decimal_exact(amount, Currency::USD()).unwrap();
1025 let text = money.to_string();
1026 let json = serde_json::to_vec(&money).unwrap();
1027 let msgpack = rmp_serde::to_vec(&money).unwrap();
1028 let from_text = Money::from_str_exact(&text).unwrap();
1029 let from_json: Money = serde_json::from_slice(&json).unwrap();
1030 let from_msgpack: Money = rmp_serde::from_slice(&msgpack).unwrap();
1031
1032 assert_eq!(money.currency, Currency::USD());
1033 assert_eq!(money.currency.precision, 2);
1034 assert_eq!(money.as_decimal(), amount);
1035 assert_eq!(text, expected);
1036 assert_eq!(
1037 format!("{money:?}"),
1038 format!(
1039 "Money({}, USD)",
1040 expected.split_whitespace().next().unwrap()
1041 )
1042 );
1043 assert_eq!(from_text, money);
1044 assert_eq!(from_json, money);
1045 assert_eq!(from_msgpack, money);
1046 }
1047
1048 #[rstest]
1049 fn test_exact_money_arithmetic() {
1050 let balance = Money::from_decimal_exact(dec!(1.0001), Currency::USD()).unwrap();
1051 let fee = Money::from_decimal_exact(dec!(0.000001), Currency::USD()).unwrap();
1052
1053 assert_eq!((balance + fee).as_decimal(), dec!(1.000101));
1054 assert_eq!((balance - fee).as_decimal(), dec!(1.000099));
1055 assert_eq!((-fee).as_decimal(), dec!(-0.000001));
1056 assert_eq!((fee * dec!(3)), dec!(0.000003));
1057 assert_eq!(balance.to_formatted_string(), "1.0001 USD");
1058 assert_eq!(balance.normalized().as_decimal(), dec!(1.00));
1059 }
1060
1061 #[rstest]
1062 #[case(1)]
1063 #[case(-1)]
1064 fn test_exact_money_native_precision_roundtrip(#[case] raw: MoneyRaw) {
1065 let sign = if raw < 0 { "-" } else { "" };
1066 let amount = format!("{sign}0.{}1", "0".repeat(usize::from(FIXED_PRECISION - 1)));
1067 let decimal = Decimal::from_str(&amount).unwrap();
1068 let expected = Money::from_raw(raw, Currency::USD());
1069 let from_decimal = Money::from_decimal_exact(decimal, Currency::USD()).unwrap();
1070 let text = format!("{amount} USD");
1071 let from_text = Money::from_str_exact(&text).unwrap();
1072
1073 assert_eq!(from_decimal, expected);
1074 assert_eq!(from_text, expected);
1075 assert_eq!(from_decimal.as_decimal(), decimal);
1076 assert_eq!(from_decimal.to_string(), text);
1077 assert_eq!(from_decimal.currency.precision, 2);
1078 }
1079
1080 #[rstest]
1081 fn test_exact_money_rejects_native_precision_loss() {
1082 let amount = format!("0.{}1", "0".repeat(usize::from(FIXED_PRECISION)));
1083 let decimal = Decimal::from_str(&amount).unwrap();
1084
1085 assert!(Money::from_decimal_exact(decimal, Currency::USD()).is_err());
1086 assert!(Money::from_str_exact(&format!("{amount} USD")).is_err());
1087 }
1088
1089 #[rstest]
1090 #[case(usize::from(FIXED_PRECISION) + 1)]
1091 #[case(39)]
1092 fn test_exact_money_accepts_extra_zero_digits(#[case] zeros: usize) {
1093 let amount = format!("1.{}", "0".repeat(zeros));
1094 let money = Money::from_str_exact(&format!("{amount} USD")).unwrap();
1095
1096 assert_eq!(money.as_decimal(), dec!(1.00));
1097 assert_eq!(money.to_string(), "1.00 USD");
1098 }
1099
1100 #[rstest]
1101 #[case("")]
1102 #[case("-")]
1103 fn test_exact_money_normalizes_scientific_integer(#[case] sign: &str) {
1104 let amount = format!("{sign}1{}e-39 USD", "0".repeat(39));
1105 let money = Money::from_str_exact(&amount).unwrap();
1106 let decimal_amount = format!("{sign}1{}.0e-39 USD", "0".repeat(39));
1107 let decimal_money = Money::from_str_exact(&decimal_amount).unwrap();
1108
1109 assert_eq!(money.to_string(), format!("{sign}1.00 USD"));
1110 assert_eq!(money.currency, Currency::USD());
1111 assert_eq!(decimal_money, money);
1112 }
1113
1114 #[rstest]
1115 #[case("e0")]
1116 #[case("E0")]
1117 #[case("")]
1118 fn test_exact_money_rejects_digits_beyond_decimal_capacity(#[case] exponent: &str) {
1119 let amount = format!("1.{}1{exponent} USD", "0".repeat(28));
1120
1121 assert_eq!(
1122 Money::from_str_exact(&amount),
1123 Err(format!(
1124 "amount exceeds native money precision {FIXED_PRECISION}"
1125 ))
1126 );
1127 }
1128
1129 #[rstest]
1130 #[case("1.0001e0 USD", "1.0001 USD")]
1131 #[case("1.234e-3 USD", "0.001234 USD")]
1132 #[case("1.000001E+2 USD", "100.0001 USD")]
1133 #[case("-.0000 USD", "0.00 USD")]
1134 #[case("0e32767 USD", "0.00 USD")]
1135 fn test_exact_money_scientific(#[case] input: &str, #[case] expected: &str) {
1136 let money = Money::from_str_exact(input).unwrap();
1137
1138 assert_eq!(money.to_string(), expected);
1139 assert_eq!(money.currency, Currency::USD());
1140 }
1141
1142 #[rstest]
1143 #[case("not-a-number")]
1144 #[case("None")]
1145 #[case("1e")]
1146 #[case("1e1.5")]
1147 fn test_exact_money_rejects_invalid_exponent(#[case] amount: &str) {
1148 let result = Money::from_str_exact(&format!("{amount} USD"));
1149
1150 assert_eq!(result, Err(format!("Invalid decimal value '{amount}'")));
1151 }
1152
1153 #[rstest]
1154 #[case("1..0")]
1155 #[case("1.2.3")]
1156 #[case("-")]
1157 #[case(".")]
1158 #[case("+-1")]
1159 #[case("1.5x0")]
1160 fn test_exact_money_rejects_malformed_amount(#[case] amount: &str) {
1161 let result = Money::from_str_exact(&format!("{amount} USD"));
1162
1163 assert!(result.is_err());
1164 }
1165
1166 #[rstest]
1167 fn test_exact_money_overflow_context() {
1168 let result = Money::from_str_exact("1e100 USD");
1169
1170 assert_eq!(
1171 result,
1172 Err(format!(
1173 "Overflow in exact Money construction (mantissa=1, exponent=100, precision={FIXED_PRECISION})"
1174 ))
1175 );
1176 }
1177
1178 #[rstest]
1179 fn test_exact_money_rejects_overflow() {
1180 let maximum = Money::from_raw(MONEY_RAW_MAX, Currency::USD()).as_decimal();
1181 let minimum = Money::from_raw(MONEY_RAW_MIN, Currency::USD()).as_decimal();
1182
1183 assert!(Money::from_decimal_exact(maximum + Decimal::ONE, Currency::USD()).is_err());
1184 assert!(Money::from_decimal_exact(minimum - Decimal::ONE, Currency::USD()).is_err());
1185 assert_eq!(
1186 Money::from_decimal_exact(maximum, Currency::USD())
1187 .unwrap()
1188 .raw(),
1189 MONEY_RAW_MAX
1190 );
1191 assert_eq!(
1192 Money::from_decimal_exact(minimum, Currency::USD())
1193 .unwrap()
1194 .raw(),
1195 MONEY_RAW_MIN
1196 );
1197 }
1198
1199 #[cfg(feature = "high-precision")]
1200 #[rstest]
1201 fn test_as_decimal_above_decimal_mantissa() {
1202 let currency = Currency::new("XYZ", 16, 0, "XYZ", crate::enums::CurrencyType::Crypto);
1205 let money = Money::from_raw(MONEY_RAW_MAX, currency);
1206
1207 assert_eq!(money.as_decimal(), dec!(17014118346046));
1208 }
1209
1210 #[rstest]
1211 fn test_money_ordering_is_structural_across_currencies() {
1212 use std::collections::BTreeSet;
1213
1214 let aud_low = Money::new(-1.0, Currency::AUD());
1215 let aud_high = Money::new(100.0, Currency::AUD());
1216 let usd_low = Money::new(-100.0, Currency::USD());
1217 let usd_high = Money::new(1.0, Currency::USD());
1218
1219 assert_eq!(aud_high.cmp(&usd_low), Ordering::Less);
1220 assert_eq!(aud_high.partial_cmp(&usd_low), Some(Ordering::Less));
1221 assert_eq!(
1222 (
1223 aud_high < usd_low,
1224 aud_high <= usd_low,
1225 aud_high > usd_low,
1226 aud_high >= usd_low,
1227 usd_low < aud_high,
1228 usd_low <= aud_high,
1229 usd_low > aud_high,
1230 usd_low >= aud_high,
1231 ),
1232 (true, true, false, false, false, false, true, true),
1233 );
1234
1235 let expected = vec![aud_low, aud_high, usd_low, usd_high];
1236 let mut sorted = vec![usd_high, aud_high, usd_low, aud_low];
1237 sorted.sort();
1238 let ordered = BTreeSet::from([usd_high, aud_high, usd_low, aud_low]);
1239
1240 assert_eq!(sorted, expected);
1241 assert_eq!(ordered.into_iter().collect::<Vec<_>>(), expected);
1242 }
1243
1244 #[rstest]
1245 fn test_money_cmp_equal_matches_equality() {
1246 use crate::enums::CurrencyType;
1247
1248 let currency = Currency::new("TST", 2, 1, "Test fiat", CurrencyType::Fiat);
1249 let same_code = Currency::new("TST", 8, 2, "Test crypto", CurrencyType::Crypto);
1250 let other_code = Currency::new("TSU", 2, 1, "Other fiat", CurrencyType::Fiat);
1251 let money = Money::from_raw(1, currency);
1252 let cases = [
1253 (Money::from_raw(1, same_code), true),
1254 (Money::from_raw(2, same_code), false),
1255 (Money::from_raw(1, other_code), false),
1256 ];
1257
1258 for (other, expected_equal) in cases {
1259 assert_eq!(money == other, expected_equal);
1260 assert_eq!(money.cmp(&other) == Ordering::Equal, expected_equal);
1261 }
1262 }
1263
1264 #[rstest]
1265 fn test_from_quantity_rejects_noncanonical_raw() {
1266 let precision = FIXED_PRECISION - 1;
1267 let quantity = Quantity::from_raw(1, precision);
1268 let currency = Currency::new("TOKEN", FIXED_PRECISION, 0, "Token", CurrencyType::Crypto);
1269 let result = Money::from_quantity(quantity, currency);
1270
1271 assert_eq!(
1272 result,
1273 Err(CorrectnessError::PredicateViolation {
1274 message: format!(
1275 "Invalid fixed-point raw value 1 for precision {precision}: \
1276 remainder 1 when divided by scale 10. Raw value should be a multiple of 10. \
1277 This indicates data corruption or incorrect precision/scaling upstream"
1278 ),
1279 })
1280 );
1281 }
1282
1283 #[rstest]
1284 fn test_extreme_money_round_trips_through_raw() {
1285 let max = Money::new(MONEY_MAX, Currency::USD());
1288 let min = Money::new(MONEY_MIN, Currency::USD());
1289
1290 assert_eq!(max.raw, MONEY_RAW_MAX);
1291 assert_eq!(min.raw, MONEY_RAW_MIN);
1292 assert!(Money::from_raw_checked(max.raw, Currency::USD()).is_ok());
1293 assert!(Money::from_raw_checked(min.raw, Currency::USD()).is_ok());
1294 }
1295
1296 #[cfg(all(feature = "defi", feature = "high-precision"))]
1297 #[rstest]
1298 fn test_wei_above_decimal_mantissa_formats_exactly() {
1299 let raw = 80_000_000_000_000_000_250_000_000_000_i128;
1300 let currency = Currency::new("ETH", 18, 0, "Ether", crate::enums::CurrencyType::Crypto);
1301 let money = Money::from_raw(raw, currency);
1302
1303 assert_eq!(money.to_string(), "80000000000.000000250000000000 ETH");
1304 assert_eq!(
1305 money.to_formatted_string(),
1306 "80_000_000_000.000000250000000000 ETH"
1307 );
1308 }
1309
1310 #[cfg(all(feature = "defi", feature = "high-precision"))]
1311 #[rstest]
1312 fn test_exact_money_wei_precision_boundary() {
1313 let currency = Currency::new(
1314 "EXACT18",
1315 crate::defi::WEI_PRECISION,
1316 0,
1317 "Exact token",
1318 CurrencyType::Crypto,
1319 );
1320 Currency::register(currency, true).unwrap();
1321 let amount = dec!(0.000000000000000001);
1322 let text = "0.000000000000000001 EXACT18";
1323 let from_decimal = Money::from_decimal_exact(amount, currency).unwrap();
1324 let from_text = Money::from_str_exact(text).unwrap();
1325 let too_fine = dec!(0.0000000000000000001);
1326 let expected = format!(
1327 "amount exceeds native money precision {}",
1328 currency.precision
1329 );
1330
1331 assert_eq!(from_decimal.raw(), 1);
1332 assert_eq!(from_decimal.currency, currency);
1333 assert_eq!(from_decimal.currency.precision, 18);
1334 assert_eq!(from_decimal.as_decimal(), amount);
1335 assert_eq!(from_decimal.to_string(), text);
1336 assert_eq!(from_text, from_decimal);
1337 assert_eq!(
1338 Money::from_decimal_exact(too_fine, currency),
1339 Err(CorrectnessError::PredicateViolation {
1340 message: expected.clone(),
1341 })
1342 );
1343 assert_eq!(
1344 Money::from_str_exact("0.0000000000000000001 EXACT18"),
1345 Err(expected)
1346 );
1347 }
1348
1349 #[cfg(all(feature = "defi", feature = "high-precision"))]
1350 #[rstest]
1351 fn test_wei_money_serde_json_round_trip() {
1352 let currency = Currency::new(
1353 "WXW",
1354 18,
1355 0,
1356 "Wei exact",
1357 crate::enums::CurrencyType::Crypto,
1358 );
1359 Currency::register(currency, true).unwrap();
1360 let money = Money::from_raw(80_000_000_000_000_000_250_000_000_000_i128, currency);
1361
1362 let json = serde_json::to_string(&money).unwrap();
1363 let deserialized = serde_json::from_str::<Money>(&json).unwrap();
1364
1365 assert_eq!(json, "\"80000000000.000000250000000000 WXW\"");
1366 assert_eq!(deserialized, money);
1367 }
1368
1369 #[rstest]
1370 fn test_debug() {
1371 let money = Money::new(1010.12, Currency::USD());
1372 let result = format!("{money:?}");
1373 let expected = "Money(1010.12, USD)";
1374 assert_eq!(result, expected);
1375 }
1376
1377 #[rstest]
1378 #[case(dec!(9007199253.999999999), "Money(9007199253.999999999, TST9)")]
1379 #[case(dec!(-9007199253.999999999), "Money(-9007199253.999999999, TST9)")]
1380 fn test_debug_preserves_exact_amount(#[case] amount: Decimal, #[case] expected: &str) {
1381 use crate::enums::CurrencyType;
1382
1383 let currency = Currency::new("TST9", 9, 0, "Test 9dp", CurrencyType::Crypto);
1384 let money = Money::from_decimal(amount, currency).unwrap();
1385
1386 assert_eq!(format!("{money:?}"), expected);
1387 }
1388
1389 #[rstest]
1390 fn test_debug_preserves_domain_maximum() {
1391 use crate::enums::CurrencyType;
1392
1393 let currency = Currency::new(
1394 "TST",
1395 FIXED_PRECISION,
1396 0,
1397 "Test fixed precision",
1398 CurrencyType::Crypto,
1399 );
1400 let money = Money::from_raw(MONEY_RAW_MAX, currency);
1401 let expected = if cfg!(feature = "high-precision") {
1402 "Money(17014118346046.0000000000000000, TST)"
1403 } else {
1404 "Money(9223372036.000000000, TST)"
1405 };
1406
1407 assert_eq!(format!("{money:?}"), expected);
1408 }
1409
1410 #[rstest]
1411 fn test_display() {
1412 let money = Money::new(1010.12, Currency::USD());
1413 let result = format!("{money}");
1414 let expected = "1010.12 USD";
1415 assert_eq!(result, expected);
1416 }
1417
1418 #[rstest]
1419 #[case(42.0, 0, "JPY", "Money(42, JPY)", "42 JPY")]
1420 #[case(0.0, 2, "USD", "Money(0.00, USD)", "0.00 USD")]
1421 #[case(-1010.12, 2, "USD", "Money(-1010.12, USD)", "-1010.12 USD")]
1422 #[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(
1425 #[case] value: f64,
1426 #[case] precision: u8,
1427 #[case] currency_code: &str,
1428 #[case] expected_debug: &str,
1429 #[case] expected_display: &str,
1430 ) {
1431 use crate::enums::CurrencyType;
1432 let currency = Currency::new(
1433 currency_code,
1434 precision,
1435 0,
1436 currency_code,
1437 CurrencyType::Fiat,
1438 );
1439 let money = Money::new(value, currency);
1440
1441 assert_eq!(format!("{money:?}"), expected_debug);
1442 assert_eq!(format!("{money}"), expected_display);
1443 }
1444
1445 #[rstest]
1446 #[cfg(feature = "defi")]
1447 #[case(
1448 1_000_000_000_000_000_000_i128,
1449 18,
1450 "wei",
1451 "Money(1.000000000000000000, wei)",
1452 "1.000000000000000000 wei"
1453 )] #[case(
1455 2_500_000_000_000_000_000_i128,
1456 18,
1457 "ETH",
1458 "Money(2.500000000000000000, ETH)",
1459 "2.500000000000000000 ETH"
1460 )] fn test_formatting_high_precision(
1462 #[case] raw_value: i128,
1463 #[case] precision: u8,
1464 #[case] currency_code: &str,
1465 #[case] expected_debug: &str,
1466 #[case] expected_display: &str,
1467 ) {
1468 use crate::enums::CurrencyType;
1469 let currency = Currency::new(
1470 currency_code,
1471 precision,
1472 0,
1473 currency_code,
1474 CurrencyType::Crypto,
1475 );
1476 let money = Money::from_raw(raw_value, currency);
1477
1478 assert_eq!(format!("{money:?}"), expected_debug);
1479 assert_eq!(format!("{money}"), expected_display);
1480 assert_eq!(
1481 serde_json::to_value(money).unwrap(),
1482 serde_json::Value::String(expected_display.to_string()),
1483 );
1484 }
1485
1486 #[rstest]
1487 fn test_zero_constructor() {
1488 let usd = Currency::USD();
1489 let money = Money::zero(usd);
1490 assert_eq!(money.raw, 0);
1491 assert_eq!(money.currency, usd);
1492 }
1493
1494 #[rstest]
1495 #[should_panic(expected = "Currency mismatch")]
1496 fn test_money_different_currency_addition() {
1497 let usd = Money::new(1000.0, Currency::USD());
1498 let btc = Money::new(1.0, Currency::BTC());
1499 let _ = usd + btc; }
1501
1502 #[rstest] fn test_with_maximum_value() {
1504 let money = Money::new_checked(MONEY_MAX, Currency::USD());
1505 assert!(money.is_ok());
1506 }
1507
1508 #[rstest] fn test_with_minimum_value() {
1510 let money = Money::new_checked(MONEY_MIN, Currency::USD());
1511 assert!(money.is_ok());
1512 }
1513
1514 #[rstest]
1515 fn test_new_checked_returns_typed_error_with_stable_display() {
1516 let error = Money::new_checked(MONEY_MAX + 1.0, Currency::USD()).unwrap_err();
1517
1518 assert!(matches!(error, CorrectnessError::OutOfRange { .. }));
1519 assert_eq!(
1520 error.to_string(),
1521 format!(
1522 "invalid f64 for 'amount' not in range [{MONEY_MIN}, {MONEY_MAX}], was {}",
1523 MONEY_MAX + 1.0
1524 )
1525 );
1526 }
1527
1528 #[cfg(not(feature = "defi"))]
1529 #[rstest]
1530 fn test_new_checked_invalid_currency_precision_returns_error() {
1531 let mut currency = Currency::USD();
1532 currency.precision = FIXED_PRECISION + 1;
1533
1534 let error = Money::new_checked(1.0, currency).unwrap_err();
1535 assert!(
1536 error
1537 .to_string()
1538 .contains("`precision` exceeded maximum `FIXED_PRECISION`"),
1539 "unexpected message: {error}"
1540 );
1541 }
1542
1543 #[cfg(feature = "defi")]
1544 #[rstest]
1545 fn test_new_checked_rejects_float_for_wei_currency() {
1546 use crate::enums::CurrencyType;
1547
1548 let currency = Currency::new("TST18", 18, 0, "Test token", CurrencyType::Crypto);
1549 let error = Money::new_checked(1.0, currency).unwrap_err();
1550 let message = "`currency.precision` exceeded maximum float precision (16), use \
1551 `Money::from_wei()` for wei values instead";
1552
1553 assert_eq!(
1554 error,
1555 CorrectnessError::PredicateViolation {
1556 message: message.to_string(),
1557 }
1558 );
1559 assert_eq!(error.to_string(), message);
1560 }
1561
1562 #[rstest]
1563 fn test_money_is_zero() {
1564 let zero_usd = Money::new(0.0, Currency::USD());
1565 assert!(zero_usd.is_zero());
1566 assert_eq!(zero_usd, Money::from("0.0 USD"));
1567
1568 let non_zero_usd = Money::new(100.0, Currency::USD());
1569 assert!(!non_zero_usd.is_zero());
1570 }
1571
1572 #[rstest]
1573 fn test_money_is_positive() {
1574 let usd = Currency::USD();
1575 assert!(Money::new(100.0, usd).is_positive());
1576 assert!(!Money::new(0.0, usd).is_positive());
1577 assert!(!Money::new(-100.0, usd).is_positive());
1578 }
1579
1580 #[rstest]
1581 fn test_money_comparisons() {
1582 let usd = Currency::USD();
1583 let m1 = Money::new(100.0, usd);
1584 let m2 = Money::new(200.0, usd);
1585
1586 assert!(m1 < m2);
1587 assert!(m2 > m1);
1588 assert!(m1 <= m2);
1589 assert!(m2 >= m1);
1590 assert_eq!(m1.cmp(&m2), Ordering::Less);
1591 assert_eq!(m2.cmp(&m1), Ordering::Greater);
1592
1593 let m3 = Money::new(100.0, usd);
1595 assert_eq!(m1, m3);
1596 }
1597
1598 #[rstest]
1599 fn test_add() {
1600 let a = 1000.0;
1601 let b = 500.0;
1602 let money1 = Money::new(a, Currency::USD());
1603 let money2 = Money::new(b, Currency::USD());
1604 let money3 = money1 + money2;
1605 assert_eq!(money3.raw, Money::new(a + b, Currency::USD()).raw);
1606 }
1607
1608 #[rstest]
1609 fn test_sub() {
1610 let usd = Currency::USD();
1611 let money1 = Money::new(1000.0, usd);
1612 let money2 = Money::new(250.0, usd);
1613 let result = money1 - money2;
1614 assert!(approx_eq!(f64, result.as_f64(), 750.0, epsilon = 1e-9));
1615 assert_eq!(result.currency, usd);
1616 }
1617
1618 #[rstest]
1619 fn test_money_checked_add_within_bounds() {
1620 let usd = Currency::USD();
1621 let a = Money::new(100.0, usd);
1622 let b = Money::new(50.0, usd);
1623 assert_eq!(a.checked_add(b), Some(Money::new(150.0, usd)));
1624 }
1625
1626 #[rstest]
1627 fn test_money_checked_add_above_max_returns_none() {
1628 let usd = Currency::USD();
1629 let near_max = Money::from_raw(MONEY_RAW_MAX, usd);
1630 let one = Money::new(1.0, usd);
1631 assert_eq!(near_max.checked_add(one), None);
1632 }
1633
1634 #[rstest]
1635 fn test_money_checked_sub_within_bounds() {
1636 let usd = Currency::USD();
1637 let a = Money::new(100.0, usd);
1638 let b = Money::new(40.0, usd);
1639 assert_eq!(a.checked_sub(b), Some(Money::new(60.0, usd)));
1640 }
1641
1642 #[rstest]
1643 fn test_money_checked_sub_below_min_returns_none() {
1644 let usd = Currency::USD();
1645 let near_min = Money::from_raw(MONEY_RAW_MIN, usd);
1646 let one = Money::new(1.0, usd);
1647 assert_eq!(near_min.checked_sub(one), None);
1648 }
1649
1650 #[rstest]
1651 fn test_money_checked_add_below_min_returns_none() {
1652 let usd = Currency::USD();
1653 let minus_one_unit = Money::from_raw(-1, usd);
1654
1655 assert_eq!(
1656 Money::from_raw(MONEY_RAW_MIN, usd).checked_add(minus_one_unit),
1657 None
1658 );
1659 assert_eq!(
1660 Money::from_raw(MONEY_RAW_MIN + 1, usd).checked_add(minus_one_unit),
1661 Some(Money::from_raw(MONEY_RAW_MIN, usd))
1662 );
1663 }
1664
1665 #[rstest]
1666 fn test_money_checked_sub_above_max_returns_none() {
1667 let usd = Currency::USD();
1668 let minus_one_unit = Money::from_raw(-1, usd);
1669
1670 assert_eq!(
1671 Money::from_raw(MONEY_RAW_MAX, usd).checked_sub(minus_one_unit),
1672 None
1673 );
1674 assert_eq!(
1675 Money::from_raw(MONEY_RAW_MAX - 1, usd).checked_sub(minus_one_unit),
1676 Some(Money::from_raw(MONEY_RAW_MAX, usd))
1677 );
1678 }
1679
1680 #[rstest]
1681 #[should_panic(expected = "Currency mismatch")]
1682 fn test_money_checked_add_currency_mismatch_panics() {
1683 let usd = Money::new(100.0, Currency::USD());
1684 let aud = Money::new(50.0, Currency::AUD());
1685 let _ = usd.checked_add(aud);
1686 }
1687
1688 #[rstest]
1689 #[should_panic(expected = "Currency mismatch")]
1690 fn test_money_checked_sub_currency_mismatch_panics() {
1691 let usd = Money::new(100.0, Currency::USD());
1692 let aud = Money::new(50.0, Currency::AUD());
1693 let _ = usd.checked_sub(aud);
1694 }
1695
1696 #[rstest]
1697 fn test_money_checked_add_at_exact_max_returns_some() {
1698 let usd = Currency::USD();
1699 let near_max = Money::from_raw(MONEY_RAW_MAX - 1, usd);
1700 let one_unit = Money::from_raw(1, usd);
1701 assert_eq!(
1702 near_max.checked_add(one_unit),
1703 Some(Money::from_raw(MONEY_RAW_MAX, usd)),
1704 );
1705 }
1706
1707 #[rstest]
1708 fn test_money_checked_sub_at_exact_min_returns_some() {
1709 let usd = Currency::USD();
1710 let near_min = Money::from_raw(MONEY_RAW_MIN + 1, usd);
1711 let one_unit = Money::from_raw(1, usd);
1712 assert_eq!(
1713 near_min.checked_sub(one_unit),
1714 Some(Money::from_raw(MONEY_RAW_MIN, usd)),
1715 );
1716 }
1717
1718 #[rstest]
1719 fn test_money_negation() {
1720 let money = Money::new(100.0, Currency::USD());
1721 let result = -money;
1722 assert_eq!(result, Money::from("-100.0 USD"));
1723 assert_eq!(result.currency, Currency::USD().clone());
1724 }
1725
1726 #[rstest]
1727 fn test_money_addition_decimal() {
1728 let money = Money::new(100.0, Currency::USD());
1729 let result = money + dec!(50.25);
1730 assert_eq!(result, dec!(150.25));
1731 }
1732
1733 #[rstest]
1734 fn test_money_subtraction_decimal() {
1735 let money = Money::new(100.0, Currency::USD());
1736 let result = money - dec!(30.50);
1737 assert_eq!(result, dec!(69.50));
1738 }
1739
1740 #[rstest]
1741 fn test_money_multiplication_decimal() {
1742 let money = Money::new(100.0, Currency::USD());
1743 let result = money * dec!(1.5);
1744 assert_eq!(result, dec!(150.00));
1745 }
1746
1747 #[rstest]
1748 fn test_money_division_decimal() {
1749 let money = Money::new(100.0, Currency::USD());
1750 let result = money / dec!(4);
1751 assert_eq!(result, dec!(25.00));
1752 }
1753
1754 #[rstest]
1755 fn test_money_addition_f64() {
1756 let money = Money::new(100.0, Currency::USD());
1757 let result = money + 50.25;
1758 assert!(approx_eq!(f64, result, 150.25, epsilon = 1e-9));
1759 }
1760
1761 #[rstest]
1762 fn test_money_subtraction_f64() {
1763 let money = Money::new(100.0, Currency::USD());
1764 let result = money - 30.50;
1765 assert!(approx_eq!(f64, result, 69.50, epsilon = 1e-9));
1766 }
1767
1768 #[rstest]
1769 fn test_money_multiplication_f64() {
1770 let money = Money::new(100.0, Currency::USD());
1771 let result = money * 1.5;
1772 assert!(approx_eq!(f64, result, 150.0, epsilon = 1e-9));
1773 }
1774
1775 #[rstest]
1776 fn test_money_division_f64() {
1777 let money = Money::new(100.0, Currency::USD());
1778 let result = money / 4.0;
1779 assert!(approx_eq!(f64, result, 25.0, epsilon = 1e-9));
1780 }
1781
1782 #[rstest]
1783 fn test_money_new_usd() {
1784 let money = Money::new(1000.0, Currency::USD());
1785 assert_eq!(money.currency.code.as_str(), "USD");
1786 assert_eq!(money.currency.precision, 2);
1787 assert_eq!(money.to_string(), "1000.00 USD");
1788 assert_eq!(money.to_formatted_string(), "1_000.00 USD");
1789 assert_eq!(money.as_decimal(), dec!(1000.00));
1790 assert!(approx_eq!(f64, money.as_f64(), 1000.0, epsilon = 0.001));
1791 }
1792
1793 #[rstest]
1794 fn test_money_new_btc() {
1795 let money = Money::new(10.3, Currency::BTC());
1796 assert_eq!(money.currency.code.as_str(), "BTC");
1797 assert_eq!(money.currency.precision, 8);
1798 assert_eq!(money.to_string(), "10.30000000 BTC");
1799 assert_eq!(money.to_formatted_string(), "10.30000000 BTC");
1800 }
1801
1802 #[rstest]
1803 fn test_to_formatted_string_preserves_digits_beyond_f64_precision() {
1804 use crate::enums::CurrencyType;
1805
1806 let currency = Currency::new("TST9", 9, 0, "Test 9dp", CurrencyType::Crypto);
1809 let money = Money::from_decimal(dec!(1234567890.123456789), currency).unwrap();
1810
1811 assert_eq!(money.to_formatted_string(), "1_234_567_890.123456789 TST9");
1812 }
1813
1814 #[rstest]
1815 #[case("0USD")] #[case("0x00 USD")] #[case("0 US")] #[case("0 USD USD")] #[should_panic(expected = "Condition failed")]
1820 fn test_from_str_invalid_input(#[case] input: &str) {
1821 let _ = Money::from(input);
1822 }
1823
1824 #[rstest]
1825 #[case("invalid UNKNOWN_FROM_STR", "Unknown currency: UNKNOWN_FROM_STR")]
1826 #[case(
1827 "1__e+ USD",
1828 "Error parsing amount '1__e+' as Decimal: Failed to parse"
1829 )]
1830 fn test_from_str_preserves_error_order_and_input(#[case] input: &str, #[case] expected: &str) {
1831 assert_eq!(input.parse::<Money>(), Err(expected.to_string()));
1832 }
1833
1834 #[rstest]
1835 #[case("0 USD", Currency::USD(), dec!(0.00))]
1836 #[case("1.1 AUD", Currency::AUD(), dec!(1.10))]
1837 #[case("1.12345678 BTC", Currency::BTC(), dec!(1.12345678))]
1838 #[case("10_000.10 USD", Currency::USD(), dec!(10000.10))]
1839 fn test_from_str_valid_input(
1840 #[case] input: &str,
1841 #[case] expected_currency: Currency,
1842 #[case] expected_dec: Decimal,
1843 ) {
1844 let money = Money::from(input);
1845 assert_eq!(money.currency, expected_currency);
1846 assert_eq!(money.as_decimal(), expected_dec);
1847 }
1848
1849 #[rstest]
1850 #[case("1.005 USD", dec!(1.00))]
1851 #[case("1.015 USD", dec!(1.02))]
1852 #[case("-1.005 USD", dec!(-1.00))]
1853 #[case("-1.015 USD", dec!(-1.02))]
1854 fn test_from_str_rounds_half_to_even(#[case] input: &str, #[case] expected: Decimal) {
1855 let money = input.parse::<Money>().unwrap();
1856
1857 assert_eq!(
1858 money,
1859 Money::from_decimal(expected, Currency::USD()).unwrap()
1860 );
1861 assert_eq!(money.currency, Currency::USD());
1862 assert_eq!(money.as_decimal(), expected);
1863 }
1864
1865 #[rstest]
1866 fn test_money_from_str_negative() {
1867 let money = Money::from("-123.45 USD");
1868 assert!(approx_eq!(f64, money.as_f64(), -123.45, epsilon = 1e-9));
1869 assert_eq!(money.currency, Currency::USD());
1870 }
1871
1872 #[rstest]
1873 #[case("1e7 USD", 10_000_000.0)]
1874 #[case("2.5e3 EUR", 2_500.0)]
1875 #[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) {
1878 let money = Money::from_str(input).unwrap();
1879 assert!(approx_eq!(
1880 f64,
1881 money.as_f64(),
1882 expected_value,
1883 epsilon = 1e-10
1884 ));
1885 }
1886
1887 #[rstest]
1888 #[case("1_234.56 USD", 1234.56)]
1889 #[case("1_000_000 EUR", 1_000_000.0)]
1890 #[case("99_999.99 GBP", 99_999.99)]
1891 fn test_from_str_with_underscores(#[case] input: &str, #[case] expected_value: f64) {
1892 let money = Money::from_str(input).unwrap();
1893 assert!(approx_eq!(
1894 f64,
1895 money.as_f64(),
1896 expected_value,
1897 epsilon = 1e-10
1898 ));
1899 }
1900
1901 #[rstest]
1902 fn test_from_decimal_precision_preservation() {
1903 use rust_decimal::Decimal;
1904
1905 let decimal = Decimal::from_str("123.45").unwrap();
1906 let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
1907 assert_eq!(money.currency.precision, 2);
1908 assert!(approx_eq!(f64, money.as_f64(), 123.45, epsilon = 1e-10));
1909
1910 let expected_raw = 12345 * 10_i64.pow(u32::from(FIXED_PRECISION - 2));
1912 assert_eq!(money.raw, MoneyRaw::from(expected_raw));
1913 }
1914
1915 #[rstest]
1916 fn test_from_decimal_rounding() {
1917 use rust_decimal::Decimal;
1918
1919 let decimal = Decimal::from_str("1.005").unwrap();
1921 let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
1922 assert_eq!(money.as_f64(), 1.0); let decimal = Decimal::from_str("1.015").unwrap();
1925 let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
1926 assert_eq!(money.as_f64(), 1.02); }
1928
1929 #[rstest]
1930 fn test_money_hash() {
1931 use std::{
1932 collections::hash_map::DefaultHasher,
1933 hash::{Hash, Hasher},
1934 };
1935
1936 let m1 = Money::new(100.0, Currency::USD());
1937 let m2 = Money::new(100.0, Currency::USD());
1938 let m3 = Money::new(100.0, Currency::AUD());
1939
1940 let mut s1 = DefaultHasher::new();
1941 let mut s2 = DefaultHasher::new();
1942 let mut s3 = DefaultHasher::new();
1943
1944 m1.hash(&mut s1);
1945 m2.hash(&mut s2);
1946 m3.hash(&mut s3);
1947
1948 assert_eq!(
1949 s1.finish(),
1950 s2.finish(),
1951 "Same amount + same currency => same hash"
1952 );
1953 assert_ne!(
1954 s1.finish(),
1955 s3.finish(),
1956 "Same amount + different currency => different hash"
1957 );
1958 }
1959
1960 #[rstest]
1961 fn test_money_serialization_deserialization() {
1962 let money = Money::new(123.45, Currency::USD());
1963 let serialized = serde_json::to_string(&money);
1964 let deserialized: Money = serde_json::from_str(&serialized.unwrap()).unwrap();
1965 assert_eq!(money, deserialized);
1966 }
1967
1968 #[rstest]
1969 fn test_money_deserialize_from_owned_value() {
1970 let money = Money::new(123.45, Currency::USD());
1971 let value = serde_json::to_value(money).unwrap();
1972
1973 let deserialized: Money = serde_json::from_value(value).unwrap();
1974 assert_eq!(money, deserialized);
1975 }
1976
1977 #[rstest]
1978 fn test_money_deserialize_invalid_format_returns_error() {
1979 let result = serde_json::from_str::<Money>("\"100.00\"");
1980 let error = result.unwrap_err();
1981 assert!(
1982 error.to_string().contains("Expected '<amount> <currency>'"),
1983 "unexpected message: {error}"
1984 );
1985 }
1986
1987 #[rstest]
1988 fn test_money_deserialize_unknown_currency_returns_error() {
1989 let result = serde_json::from_str::<Money>("\"100.00 ZZZZ\"");
1990 let error = result.unwrap_err();
1991 assert!(
1992 error.to_string().contains("Unknown currency"),
1993 "unexpected message: {error}"
1994 );
1995 }
1996
1997 #[rstest]
1998 #[should_panic(expected = "`raw` value")]
1999 fn test_money_from_raw_out_of_range_panics() {
2000 let usd = Currency::USD();
2001 let raw = MONEY_RAW_MAX.saturating_add(1);
2002 let _ = Money::from_raw(raw, usd);
2003 }
2004
2005 #[rstest]
2006 fn test_money_from_raw_checked_valid() {
2007 let usd = Currency::USD();
2008 let money = Money::from_raw_checked(123_450_000_000, usd).unwrap();
2009 assert_eq!(money.currency, usd);
2010 }
2011
2012 #[rstest]
2013 fn test_money_from_raw_checked_above_max_returns_error() {
2014 let usd = Currency::USD();
2015 let raw = MONEY_RAW_MAX.saturating_add(1);
2016 let error = Money::from_raw_checked(raw, usd).unwrap_err();
2017 assert!(matches!(error, CorrectnessError::PredicateViolation { .. }));
2018 }
2019
2020 #[rstest]
2021 fn test_money_from_raw_checked_below_min_returns_error() {
2022 let usd = Currency::USD();
2023 let raw = MONEY_RAW_MIN.saturating_sub(1);
2024 let error = Money::from_raw_checked(raw, usd).unwrap_err();
2025 assert!(matches!(error, CorrectnessError::PredicateViolation { .. }));
2026 }
2027
2028 #[rstest]
2029 fn test_from_decimal_rejects_out_of_range() {
2030 let huge = Decimal::from_str("99999999999999999999.99").unwrap();
2031 let result = Money::from_decimal(huge, Currency::USD());
2032 assert!(result.is_err());
2033 }
2034
2035 #[rstest]
2036 fn test_from_decimal_rejects_raw_between_money_and_raw_bounds() {
2037 let usd = Currency::USD();
2038 let at_max = Decimal::try_from(MONEY_MAX).unwrap();
2039 let above_max = at_max + dec!(0.5);
2040 let expected_raw = MONEY_RAW_MAX + 5 * MoneyRaw::pow(10, u32::from(FIXED_PRECISION - 1));
2041
2042 let error = Money::from_decimal(above_max, usd).unwrap_err();
2043
2044 assert_eq!(
2045 error.to_string(),
2046 format!(
2047 "Raw value {expected_raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
2048 )
2049 );
2050 assert_eq!(Money::from_decimal(at_max, usd).unwrap().raw, MONEY_RAW_MAX);
2051 }
2052
2053 #[rstest]
2054 fn test_from_decimal_out_of_range_returns_typed_error_with_stable_display() {
2055 let huge = Decimal::from_str("99999999999999999999.99").unwrap();
2056 let error = Money::from_decimal(huge, Currency::USD()).unwrap_err();
2057 match error {
2058 CorrectnessError::PredicateViolation { ref message } => {
2059 assert!(
2060 message.contains("MoneyRaw range") || message.contains("Money"),
2061 "unexpected message: {message:?}",
2062 );
2063 }
2064 _ => panic!("expected PredicateViolation, was {error:?}"),
2065 }
2066 }
2067
2068 #[rstest]
2069 fn test_from_mantissa_exponent_exact_precision() {
2070 let money = Money::from_mantissa_exponent(12345, -2, Currency::USD());
2071 assert_eq!(money.as_f64(), 123.45);
2072 }
2073
2074 #[rstest]
2075 fn test_from_mantissa_exponent_excess_rounds_down() {
2076 let money = Money::from_mantissa_exponent(12345, -3, Currency::USD());
2078 assert_eq!(money.as_f64(), 12.34);
2079 }
2080
2081 #[rstest]
2082 fn test_from_mantissa_exponent_excess_rounds_up() {
2083 let money = Money::from_mantissa_exponent(12355, -3, Currency::USD());
2085 assert_eq!(money.as_f64(), 12.36);
2086 }
2087
2088 #[rstest]
2089 fn test_from_mantissa_exponent_positive_exponent() {
2090 let money = Money::from_mantissa_exponent(5, 2, Currency::USD());
2091 assert_eq!(money.as_f64(), 500.0);
2092 }
2093
2094 #[rstest]
2095 #[should_panic(expected = "Money::from_mantissa_exponent")]
2096 fn test_from_mantissa_exponent_overflow_panics() {
2097 let _ = Money::from_mantissa_exponent(i64::MAX, 9, Currency::USD());
2098 }
2099
2100 #[rstest]
2101 #[should_panic(expected = "exceeds i128 range")]
2102 fn test_from_mantissa_exponent_large_exponent_panics() {
2103 let _ = Money::from_mantissa_exponent(1, 119, Currency::USD());
2104 }
2105
2106 #[rstest]
2107 fn test_from_mantissa_exponent_zero_with_large_exponent() {
2108 let money = Money::from_mantissa_exponent(0, 119, Currency::USD());
2109 assert_eq!(money.as_f64(), 0.0);
2110 }
2111
2112 #[rstest]
2113 fn test_from_mantissa_exponent_very_negative_exponent_rounds_to_zero() {
2114 let money = Money::from_mantissa_exponent(12345, -120, Currency::USD());
2116 assert_eq!(money.as_f64(), 0.0);
2117 }
2118
2119 #[rstest]
2120 #[case(42.0, true, "positive value")]
2121 #[case(0.0, false, "zero value")]
2122 #[case( -13.5, false, "negative value")]
2123 #[expect(
2124 clippy::used_underscore_binding,
2125 reason = "rstest case name documents the parameterized input"
2126 )]
2127 fn test_check_positive_money(
2128 #[case] amount: f64,
2129 #[case] should_succeed: bool,
2130 #[case] _case_name: &str,
2131 ) {
2132 let money = Money::new(amount, Currency::USD());
2133
2134 let res = check_positive_money(money, "money");
2135
2136 if should_succeed {
2137 assert!(res.is_ok(), "expected Ok(..) for {amount}");
2138 } else {
2139 assert!(res.is_err(), "expected Err(..) for {amount}");
2140 let msg = res.unwrap_err().to_string();
2141 assert!(
2142 msg.contains("not positive"),
2143 "error message should mention positivity; got: {msg:?}"
2144 );
2145 }
2146 }
2147
2148 #[rstest]
2149 fn test_check_positive_money_returns_typed_error_with_stable_display() {
2150 let error = check_positive_money(Money::new(0.0, Currency::USD()), "money").unwrap_err();
2151
2152 assert_eq!(
2153 error,
2154 CorrectnessError::NotPositive {
2155 param: "money".to_string(),
2156 value: "0.00 USD".to_string(),
2157 type_name: "`Money`",
2158 }
2159 );
2160 assert_eq!(
2161 error.to_string(),
2162 "invalid `Money` for 'money' not positive, was 0.00 USD"
2163 );
2164 }
2165}
2166
2167#[cfg(test)]
2168mod property_tests {
2169 use proptest::prelude::*;
2170 use rstest::rstest;
2171
2172 use super::*;
2173
2174 fn currency_strategy() -> impl Strategy<Value = Currency> {
2175 prop_oneof![
2176 Just(Currency::USD()),
2177 Just(Currency::EUR()),
2178 Just(Currency::GBP()),
2179 Just(Currency::JPY()),
2180 Just(Currency::AUD()),
2181 Just(Currency::CAD()),
2182 Just(Currency::CHF()),
2183 Just(Currency::BTC()),
2184 Just(Currency::ETH()),
2185 Just(Currency::USDT()),
2186 ]
2187 }
2188
2189 fn money_amount_strategy() -> impl Strategy<Value = f64> {
2190 prop_oneof![
2191 -1000.0..1000.0,
2192 -100_000.0..100_000.0,
2193 -1_000_000.0..1_000_000.0,
2194 Just(0.0),
2195 Just(MONEY_MIN / 2.0),
2196 Just(MONEY_MAX / 2.0),
2197 Just(MONEY_MIN + 1.0),
2198 Just(MONEY_MAX - 1.0),
2199 Just(MONEY_MIN),
2200 Just(MONEY_MAX),
2201 ]
2202 }
2203
2204 fn money_strategy() -> impl Strategy<Value = Money> {
2205 (money_amount_strategy(), currency_strategy())
2206 .prop_filter_map("constructible money", |(amount, currency)| {
2207 Money::new_checked(amount, currency).ok()
2208 })
2209 }
2210
2211 proptest! {
2212 #[rstest]
2213 fn prop_money_construction_roundtrip(
2214 amount in money_amount_strategy(),
2215 currency in currency_strategy()
2216 ) {
2217 if let Ok(money) = Money::new_checked(amount, currency) {
2218 let roundtrip = money.as_f64();
2219 let precision_epsilon = if currency.precision == 0 {
2220 1.0
2221 } else {
2222 let currency_epsilon = 10.0_f64.powi(-i32::from(currency.precision));
2223 let magnitude_epsilon = amount.abs() * 1e-10;
2224 currency_epsilon.max(magnitude_epsilon)
2225 };
2226 prop_assert!((roundtrip - amount).abs() <= precision_epsilon,
2227 "Roundtrip failed: {} -> {} -> {} (precision: {}, epsilon: {})",
2228 amount, money.raw, roundtrip, currency.precision, precision_epsilon);
2229 prop_assert_eq!(money.currency, currency);
2230 }
2231 }
2232
2233 #[rstest]
2234 fn prop_money_addition_commutative(
2235 money1 in money_strategy(),
2236 money2 in money_strategy(),
2237 ) {
2238 if money1.currency == money2.currency
2239 && let (Some(_), Some(_)) = (
2240 money1.raw.checked_add(money2.raw),
2241 money2.raw.checked_add(money1.raw)
2242 )
2243 {
2244 let sum1 = money1 + money2;
2245 let sum2 = money2 + money1;
2246 prop_assert_eq!(sum1, sum2, "Addition should be commutative");
2247 prop_assert_eq!(sum1.currency, money1.currency);
2248 }
2249 }
2250
2251 #[rstest]
2252 fn prop_money_addition_associative(
2253 money1 in money_strategy(),
2254 money2 in money_strategy(),
2255 money3 in money_strategy(),
2256 ) {
2257 if money1.currency == money2.currency
2258 && money2.currency == money3.currency
2259 && let (Some(sum1), Some(sum2)) = (
2260 money1.raw.checked_add(money2.raw),
2261 money2.raw.checked_add(money3.raw)
2262 )
2263 && let (Some(left), Some(right)) = (
2264 sum1.checked_add(money3.raw),
2265 money1.raw.checked_add(sum2)
2266 )
2267 && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&left)
2268 && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&right)
2269 {
2270 let left_result = Money::from_raw(left, money1.currency);
2271 let right_result = Money::from_raw(right, money1.currency);
2272 prop_assert_eq!(left_result, right_result, "Addition should be associative");
2273 }
2274 }
2275
2276 #[rstest]
2277 fn prop_money_subtraction_inverse(
2278 money1 in money_strategy(),
2279 money2 in money_strategy(),
2280 ) {
2281 if money1.currency == money2.currency
2282 && let Some(sum_raw) = money1.raw.checked_add(money2.raw)
2283 && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&sum_raw)
2284 {
2285 let sum = Money::from_raw(sum_raw, money1.currency);
2286 let diff = sum - money2;
2287 prop_assert_eq!(diff, money1, "Subtraction should be inverse of addition");
2288 }
2289 }
2290
2291 #[rstest]
2294 fn prop_money_checked_add_matches_spec(
2295 raw1 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
2296 raw2 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
2297 currency in currency_strategy(),
2298 ) {
2299 let m1 = Money::from_raw(raw1, currency);
2300 let m2 = Money::from_raw(raw2, currency);
2301 let expected = m1.raw
2302 .checked_add(m2.raw)
2303 .filter(|r| (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(r))
2304 .map(|raw| Money { raw, currency });
2305 prop_assert_eq!(m1.checked_add(m2), expected);
2306 }
2307
2308 #[rstest]
2311 fn prop_money_checked_sub_matches_spec(
2312 raw1 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
2313 raw2 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
2314 currency in currency_strategy(),
2315 ) {
2316 let m1 = Money::from_raw(raw1, currency);
2317 let m2 = Money::from_raw(raw2, currency);
2318 let expected = m1.raw
2319 .checked_sub(m2.raw)
2320 .filter(|r| (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(r))
2321 .map(|raw| Money { raw, currency });
2322 prop_assert_eq!(m1.checked_sub(m2), expected);
2323 }
2324
2325 #[rstest]
2326 fn prop_money_zero_identity(money in money_strategy()) {
2327 let zero = Money::zero(money.currency);
2328 prop_assert_eq!(money + zero, money, "Zero should be additive identity");
2329 prop_assert_eq!(zero + money, money, "Zero should be additive identity (commutative)");
2330 prop_assert!(zero.is_zero(), "Zero should be recognized as zero");
2331 }
2332
2333 #[rstest]
2334 fn prop_money_negation_inverse(money in money_strategy()) {
2335 let negated = -money;
2336 let double_neg = -negated;
2337 prop_assert_eq!(money, double_neg, "Double negation should equal original");
2338 prop_assert_eq!(negated.currency, money.currency, "Negation preserves currency");
2339
2340 if let Some(sum_raw) = money.raw.checked_add(negated.raw)
2341 && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&sum_raw) {
2342 let sum = Money::from_raw(sum_raw, money.currency);
2343 prop_assert!(sum.is_zero(), "Money + (-Money) should equal zero");
2344 }
2345 }
2346
2347 #[rstest]
2348 fn prop_money_comparison_consistency(
2349 money1 in money_strategy(),
2350 money2 in money_strategy(),
2351 ) {
2352 let eq = money1 == money2;
2353 let lt = money1 < money2;
2354 let gt = money1 > money2;
2355 let le = money1 <= money2;
2356 let ge = money1 >= money2;
2357
2358 let exclusive_count = [eq, lt, gt].iter().filter(|&&x| x).count();
2359 prop_assert_eq!(exclusive_count, 1, "Exactly one of ==, <, > should be true");
2360
2361 prop_assert_eq!(le, eq || lt, "<= should equal == || <");
2362 prop_assert_eq!(ge, eq || gt, ">= should equal == || >");
2363 prop_assert_eq!(lt, money2 > money1, "< should be symmetric with >");
2364 prop_assert_eq!(le, money2 >= money1, "<= should be symmetric with >=");
2365 }
2366
2367 #[rstest]
2368 fn prop_money_decimal_conversion(money in money_strategy()) {
2369 let decimal = money.as_decimal();
2370
2371 prop_assert_eq!(decimal.scale(), u32::from(money.currency.precision));
2373
2374 #[cfg(feature = "defi")]
2375 {
2376 let decimal_f64: f64 = decimal.try_into().unwrap_or(0.0);
2377 prop_assert!(decimal_f64.is_finite(), "Decimal should convert to finite f64");
2378 }
2379 #[cfg(not(feature = "defi"))]
2380 {
2381 let decimal_f64: f64 = decimal.try_into().unwrap_or(0.0);
2382 let original_f64 = money.as_f64();
2383
2384 let base_epsilon = 10.0_f64.powi(-i32::from(money.currency.precision));
2385 let precision_epsilon = if cfg!(feature = "high-precision") {
2386 base_epsilon.max(1e-10)
2387 } else {
2388 base_epsilon
2389 };
2390 let diff = (decimal_f64 - original_f64).abs();
2391 prop_assert!(diff <= precision_epsilon,
2392 "Decimal conversion should preserve value within currency precision: {} vs {} (diff: {}, epsilon: {})",
2393 original_f64, decimal_f64, diff, precision_epsilon);
2394 }
2395 }
2396
2397 #[rstest]
2398 fn prop_money_arithmetic_with_f64(
2399 money in money_strategy(),
2400 factor in -1000.0..1000.0_f64,
2401 ) {
2402 if factor != 0.0 {
2403 let original_f64 = money.as_f64();
2404
2405 let mul_result = money * factor;
2406 let expected_mul = original_f64 * factor;
2407 prop_assert!((mul_result - expected_mul).abs() < 0.01,
2408 "Multiplication with f64 should be accurate");
2409
2410 let div_result = money / factor;
2411 let expected_div = original_f64 / factor;
2412 if expected_div.is_finite() {
2413 prop_assert!((div_result - expected_div).abs() < 0.01,
2414 "Division with f64 should be accurate");
2415 }
2416
2417 let add_result = money + factor;
2418 let expected_add = original_f64 + factor;
2419 prop_assert!((add_result - expected_add).abs() < 0.01,
2420 "Addition with f64 should be accurate");
2421
2422 let sub_result = money - factor;
2423 let expected_sub = original_f64 - factor;
2424 prop_assert!((sub_result - expected_sub).abs() < 0.01,
2425 "Subtraction with f64 should be accurate");
2426 }
2427 }
2428 }
2429}