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;
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::types::{
83 Currency, Quantity,
84 fixed::{
85 FIXED_PRECISION, FIXED_SCALAR, canonical_raw, check_fixed_precision, check_fixed_raw_i128,
86 check_fixed_raw_u128, compare_raw_signed, format_scaled_i128,
87 mantissa_exponent_to_fixed_i128, mantissa_exponent_to_raw_checked, parse_decimal_mantissa,
88 raw_scale, raw_scales_match, scaled_raw_to_decimal,
89 },
90};
91
92#[cfg(feature = "high-precision")]
97pub type MoneyRaw = i128;
98
99#[cfg(not(feature = "high-precision"))]
100pub type MoneyRaw = i64;
101
102#[unsafe(no_mangle)]
113#[allow(unsafe_code)]
114pub static MONEY_RAW_MAX: MoneyRaw = (MONEY_MAX as MoneyRaw) * (FIXED_SCALAR as MoneyRaw);
115
116#[unsafe(no_mangle)]
125#[allow(unsafe_code)]
126pub static MONEY_RAW_MIN: MoneyRaw = (MONEY_MIN as MoneyRaw) * (FIXED_SCALAR as MoneyRaw);
127
128#[cfg(feature = "high-precision")]
133pub const MONEY_MAX: f64 = 17_014_118_346_046.0;
135
136#[cfg(not(feature = "high-precision"))]
137pub const MONEY_MAX: f64 = 9_223_372_036.0;
139
140#[cfg(feature = "high-precision")]
145pub const MONEY_MIN: f64 = -17_014_118_346_046.0;
147
148#[cfg(not(feature = "high-precision"))]
149pub const MONEY_MIN: f64 = -9_223_372_036.0;
151
152#[repr(C)]
159#[derive(Clone, Copy, Eq)]
160#[cfg_attr(
161 feature = "python",
162 pyo3::pyclass(module = "nautilus_trader.model", frozen, from_py_object)
163)]
164#[cfg_attr(
165 feature = "python",
166 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
167)]
168pub struct Money {
169 pub(crate) raw: MoneyRaw,
170 pub currency: Currency,
172}
173
174impl Money {
175 pub fn new_checked(amount: f64, currency: Currency) -> CorrectnessResult<Self> {
187 check_in_range_inclusive_f64(amount, MONEY_MIN, MONEY_MAX, "amount")?;
191
192 #[cfg(feature = "defi")]
193 if currency.precision > MAX_FLOAT_PRECISION {
194 return Err(CorrectnessError::PredicateViolation {
196 message: format!(
197 "`currency.precision` exceeded maximum float precision ({MAX_FLOAT_PRECISION}), use `Money::from_wei()` for wei values instead"
198 ),
199 });
200 }
201
202 check_fixed_precision(currency.precision)?;
203
204 #[cfg(feature = "high-precision")]
205 let raw = f64_to_fixed_i128(amount, currency.precision);
206
207 #[cfg(not(feature = "high-precision"))]
208 let raw = f64_to_fixed_i64(amount, currency.precision);
209
210 Ok(Self { raw, currency })
211 }
212
213 #[must_use]
219 pub fn new(amount: f64, currency: Currency) -> Self {
220 Self::new_checked(amount, currency).expect_display(FAILED)
221 }
222
223 #[must_use]
229 pub fn from_raw(raw: MoneyRaw, currency: Currency) -> Self {
230 Self::from_raw_checked(raw, currency).expect_display(FAILED)
231 }
232
233 pub fn from_raw_checked(raw: MoneyRaw, currency: Currency) -> CorrectnessResult<Self> {
242 if raw < MONEY_RAW_MIN || raw > MONEY_RAW_MAX {
243 return Err(CorrectnessError::PredicateViolation {
244 message: format!(
245 "`raw` value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
246 ),
247 });
248 }
249
250 check_fixed_precision(currency.precision)?;
251
252 Ok(Self { raw, currency })
262 }
263
264 #[must_use]
273 pub fn from_mantissa_exponent(mantissa: i64, exponent: i8, currency: Currency) -> Self {
274 check_fixed_precision(currency.precision).expect_display(FAILED);
275
276 if mantissa == 0 {
277 return Self { raw: 0, currency };
278 }
279
280 let raw_i128 =
281 mantissa_exponent_to_fixed_i128(i128::from(mantissa), exponent, currency.precision)
282 .expect("Overflow in Money::from_mantissa_exponent");
283
284 #[allow(
285 clippy::useless_conversion,
286 reason = "i128 to MoneyRaw is real when not high-precision"
287 )]
288 let raw: MoneyRaw = raw_i128
289 .try_into()
290 .expect("Raw value exceeds MoneyRaw range in Money::from_mantissa_exponent");
291 assert!(
292 raw >= MONEY_RAW_MIN && raw <= MONEY_RAW_MAX,
293 "`raw` value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
294 );
295
296 Self { raw, currency }
297 }
298
299 #[must_use]
305 pub fn zero(currency: Currency) -> Self {
306 check_fixed_precision(currency.precision).expect_display(FAILED);
307 Self { raw: 0, currency }
308 }
309
310 #[must_use]
313 pub fn normalized(&self) -> Self {
314 #[cfg(feature = "high-precision")]
315 let raw = super::fixed::correct_raw_i128(self.raw, self.currency.precision);
316
317 #[cfg(not(feature = "high-precision"))]
318 let raw = super::fixed::correct_raw_i64(self.raw, self.currency.precision);
319
320 Self {
321 raw,
322 currency: self.currency,
323 }
324 }
325
326 #[must_use]
339 #[inline]
340 pub const fn raw(&self) -> MoneyRaw {
341 self.raw
342 }
343
344 #[must_use]
346 pub fn is_zero(&self) -> bool {
347 self.raw == 0
348 }
349
350 #[must_use]
352 pub fn is_positive(&self) -> bool {
353 self.raw > 0
354 }
355
356 #[must_use]
358 pub fn is_negative(&self) -> bool {
359 self.raw < 0
360 }
361
362 #[must_use]
364 pub fn abs(self) -> Self {
365 if self.is_negative() { -self } else { self }
366 }
367
368 #[must_use]
378 pub fn checked_add(self, rhs: Self) -> Option<Self> {
379 assert_eq!(
380 self.currency, rhs.currency,
381 "Currency mismatch: cannot add {} to {}",
382 rhs.currency.code, self.currency.code
383 );
384
385 if !raw_scales_match(self.currency.precision, rhs.currency.precision) {
386 return None;
387 }
388
389 let raw = self.raw.checked_add(rhs.raw)?;
390 if raw < MONEY_RAW_MIN || raw > MONEY_RAW_MAX {
391 return None;
392 }
393
394 Some(Self {
395 raw,
396 currency: self.currency,
397 })
398 }
399
400 #[must_use]
410 pub fn checked_sub(self, rhs: Self) -> Option<Self> {
411 assert_eq!(
412 self.currency, rhs.currency,
413 "Currency mismatch: cannot subtract {} from {}",
414 rhs.currency.code, self.currency.code
415 );
416
417 if !raw_scales_match(self.currency.precision, rhs.currency.precision) {
418 return None;
419 }
420
421 let raw = self.raw.checked_sub(rhs.raw)?;
422 if raw < MONEY_RAW_MIN || raw > MONEY_RAW_MAX {
423 return None;
424 }
425
426 Some(Self {
427 raw,
428 currency: self.currency,
429 })
430 }
431
432 #[cfg(feature = "high-precision")]
433 #[must_use]
439 pub fn as_f64(&self) -> f64 {
440 #[cfg(feature = "defi")]
441 assert!(
442 self.currency.precision <= MAX_FLOAT_PRECISION,
443 "Invalid f64 conversion beyond `MAX_FLOAT_PRECISION` (16)"
444 );
445
446 fixed_i128_to_f64(self.raw)
447 }
448
449 #[cfg(not(feature = "high-precision"))]
450 #[must_use]
452 pub fn as_f64(&self) -> f64 {
453 fixed_i64_to_f64(self.raw)
454 }
455
456 #[must_use]
458 pub fn as_decimal(&self) -> Decimal {
459 let precision = self.currency.precision;
461 let precision_diff = FIXED_PRECISION.saturating_sub(precision);
462
463 let rescaled_raw = self.raw / MoneyRaw::pow(10, u32::from(precision_diff));
466
467 #[allow(
468 clippy::useless_conversion,
469 reason = "i128::from is real when MoneyRaw is i64"
470 )]
471 scaled_raw_to_decimal(i128::from(rescaled_raw), precision)
472 }
473
474 #[must_use]
476 pub fn to_formatted_string(&self) -> String {
477 let amount_str = format_scaled_i128(self.raw_at_precision(), self.currency.precision);
478 format!(
479 "{} {}",
480 amount_str.separate_with_underscores(),
481 self.currency.code
482 )
483 }
484
485 #[allow(
492 clippy::useless_conversion,
493 reason = "the raw width differs when high-precision is disabled"
494 )]
495 pub fn from_quantity(quantity: Quantity, currency: Currency) -> CorrectnessResult<Self> {
496 check_predicate_false(quantity.is_undefined(), "quantity was undefined")?;
497 Quantity::from_raw_checked(quantity.raw, quantity.precision)?;
498 check_fixed_raw_u128(u128::from(quantity.raw), quantity.precision).map_err(|e| {
499 CorrectnessError::PredicateViolation {
500 message: e.to_string(),
501 }
502 })?;
503
504 let raw = i128::try_from(u128::from(quantity.raw)).map_err(|_| {
505 CorrectnessError::PredicateViolation {
506 message: format!("quantity for {currency} exceeds signed raw bounds"),
507 }
508 })?;
509
510 Self::from_rescaled_raw(raw, quantity.precision, currency, "quantity")
511 }
512
513 fn raw_at_precision(&self) -> i128 {
514 let precision_diff = FIXED_PRECISION.saturating_sub(self.currency.precision);
515 let rescaled_raw = self.raw / MoneyRaw::pow(10, u32::from(precision_diff));
516 Self::raw_as_i128(rescaled_raw)
517 }
518
519 fn raw_as_i128(raw: MoneyRaw) -> i128 {
520 #[allow(
521 clippy::useless_conversion,
522 reason = "i128::from is a widening conversion when MoneyRaw is i64"
523 )]
524 i128::from(raw)
525 }
526
527 pub fn from_decimal(decimal: Decimal, currency: Currency) -> CorrectnessResult<Self> {
538 let exponent = -(decimal.scale() as i8);
539 let raw_i128 =
540 mantissa_exponent_to_fixed_i128(decimal.mantissa(), exponent, currency.precision)?;
541
542 #[allow(
543 clippy::useless_conversion,
544 reason = "i128 to MoneyRaw is real when not high-precision"
545 )]
546 let raw: MoneyRaw =
547 raw_i128
548 .try_into()
549 .map_err(|_| CorrectnessError::PredicateViolation {
550 message: format!(
551 "Decimal value exceeds MoneyRaw range [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}]"
552 ),
553 })?;
554
555 if !(raw >= MONEY_RAW_MIN && raw <= MONEY_RAW_MAX) {
556 return Err(CorrectnessError::PredicateViolation {
557 message: format!(
558 "Raw value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
559 ),
560 });
561 }
562
563 Ok(Self { raw, currency })
564 }
565
566 #[allow(
567 clippy::useless_conversion,
568 reason = "the raw width differs when high-precision is disabled"
569 )]
570 pub(crate) fn from_rescaled_raw(
571 raw: i128,
572 source_precision: u8,
573 currency: Currency,
574 subject: &str,
575 ) -> CorrectnessResult<Self> {
576 check_fixed_precision(source_precision)?;
577 check_fixed_precision(currency.precision)?;
578 let source_precision = source_precision.max(FIXED_PRECISION);
579 let target_precision = currency.precision.max(FIXED_PRECISION);
580
581 let raw = match source_precision.cmp(&target_precision) {
582 Ordering::Less => {
583 let scale = 10_i128.pow(u32::from(target_precision - source_precision));
584 raw.checked_mul(scale)
585 .ok_or_else(|| CorrectnessError::PredicateViolation {
586 message: format!(
587 "{subject} for {currency} overflowed while increasing raw scale"
588 ),
589 })?
590 }
591 Ordering::Greater => {
592 let scale = 10_i128.pow(u32::from(source_precision - target_precision));
593 check_predicate_true(
594 raw % scale == 0,
595 &format!("{subject} for {currency} loses precision when decreasing raw scale"),
596 )?;
597 raw / scale
598 }
599 Ordering::Equal => raw,
600 };
601
602 check_fixed_raw_i128(raw, currency.precision).map_err(|e| {
603 CorrectnessError::PredicateViolation {
604 message: e.to_string(),
605 }
606 })?;
607
608 let raw: MoneyRaw = raw
609 .try_into()
610 .map_err(|_| CorrectnessError::PredicateViolation {
611 message: format!("{subject} for {currency} exceeds Money raw bounds"),
612 })?;
613
614 Self::from_raw_checked(raw, currency)
615 }
616}
617
618impl FromStr for Money {
619 type Err = String;
620
621 fn from_str(value: &str) -> Result<Self, Self::Err> {
622 let parts: Vec<&str> = value.split_whitespace().collect();
623
624 if parts.len() != 2 {
626 return Err(format!(
627 "Error invalid input format '{value}'. Expected '<amount> <currency>'"
628 ));
629 }
630
631 let clean_amount = parts[0].replace('_', "");
632
633 let currency = Currency::from_str(parts[1]).map_err(|e| e.to_string())?;
634 if clean_amount.contains('e') || clean_amount.contains('E') {
635 let decimal = Decimal::from_scientific(&clean_amount)
636 .map_err(|e| format!("Error parsing amount '{}' as Decimal: {e}", parts[0]))?;
637 return Self::from_decimal(decimal, currency).map_err(|e| e.to_string());
638 }
639
640 let (mantissa, amount_precision) = parse_decimal_mantissa(&clean_amount)?;
641 let exponent = -i8::try_from(amount_precision).map_err(|e| e.to_string())?;
642 let raw = mantissa_exponent_to_raw_checked::<MoneyRaw>(
643 mantissa,
644 exponent,
645 currency.precision,
646 "Money::from_str",
647 "MoneyRaw",
648 "Money",
649 )
650 .map_err(|e| e.to_string())?;
651 Self::from_raw_checked(raw, currency).map_err(|e| e.to_string())
652 }
653}
654
655impl<T: AsRef<str>> From<T> for Money {
656 fn from(value: T) -> Self {
657 Self::from_str(value.as_ref()).expect(FAILED)
658 }
659}
660
661impl From<Money> for f64 {
662 fn from(money: Money) -> Self {
663 money.as_f64()
664 }
665}
666
667impl From<&Money> for f64 {
668 fn from(money: &Money) -> Self {
669 money.as_f64()
670 }
671}
672
673impl Hash for Money {
674 fn hash<H: Hasher>(&self, state: &mut H) {
675 self.raw.signum().hash(state);
676 canonical_raw(self.raw.unsigned_abs(), self.currency.precision).hash(state);
677 self.currency.hash(state);
678 }
679}
680
681impl PartialEq for Money {
682 fn eq(&self, other: &Self) -> bool {
683 self.cmp(other) == Ordering::Equal
684 }
685}
686
687impl PartialOrd for Money {
688 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
689 Some(self.cmp(other))
690 }
691}
692
693impl Ord for Money {
694 fn cmp(&self, other: &Self) -> Ordering {
695 self.currency.code.cmp(&other.currency.code).then_with(|| {
696 compare_raw_signed(
697 self.raw,
698 self.currency.precision,
699 other.raw,
700 other.currency.precision,
701 )
702 })
703 }
704}
705
706impl Neg for Money {
707 type Output = Self;
708 fn neg(self) -> Self::Output {
709 Self {
710 raw: -self.raw,
711 currency: self.currency,
712 }
713 }
714}
715
716impl Add for Money {
717 type Output = Self;
718 fn add(self, rhs: Self) -> Self::Output {
719 assert_eq!(
720 self.currency, rhs.currency,
721 "Currency mismatch: cannot add {} to {}",
722 rhs.currency.code, self.currency.code
723 );
724 assert!(
725 raw_scales_match(self.currency.precision, rhs.currency.precision),
726 "Cannot add `Money` values with mismatched decimal scales"
727 );
728 Self {
729 raw: self
730 .raw
731 .checked_add(rhs.raw)
732 .expect("Overflow occurred when adding `Money`"),
733 currency: self.currency,
734 }
735 }
736}
737
738impl Sub for Money {
739 type Output = Self;
740 fn sub(self, rhs: Self) -> Self::Output {
741 assert_eq!(
742 self.currency, rhs.currency,
743 "Currency mismatch: cannot subtract {} from {}",
744 rhs.currency.code, self.currency.code
745 );
746 assert!(
747 raw_scales_match(self.currency.precision, rhs.currency.precision),
748 "Cannot subtract `Money` values with mismatched decimal scales"
749 );
750 Self {
751 raw: self
752 .raw
753 .checked_sub(rhs.raw)
754 .expect("Underflow occurred when subtracting `Money`"),
755 currency: self.currency,
756 }
757 }
758}
759
760impl Add<Decimal> for Money {
761 type Output = Decimal;
762 fn add(self, rhs: Decimal) -> Self::Output {
763 self.as_decimal() + rhs
764 }
765}
766
767impl Sub<Decimal> for Money {
768 type Output = Decimal;
769 fn sub(self, rhs: Decimal) -> Self::Output {
770 self.as_decimal() - rhs
771 }
772}
773
774impl Mul<Decimal> for Money {
775 type Output = Decimal;
776 fn mul(self, rhs: Decimal) -> Self::Output {
777 self.as_decimal() * rhs
778 }
779}
780
781impl Div<Decimal> for Money {
782 type Output = Decimal;
783 fn div(self, rhs: Decimal) -> Self::Output {
784 self.as_decimal() / rhs
785 }
786}
787
788impl Add<f64> for Money {
789 type Output = f64;
790 fn add(self, rhs: f64) -> Self::Output {
791 self.as_f64() + rhs
792 }
793}
794
795impl Sub<f64> for Money {
796 type Output = f64;
797 fn sub(self, rhs: f64) -> Self::Output {
798 self.as_f64() - rhs
799 }
800}
801
802impl Mul<f64> for Money {
803 type Output = f64;
804 fn mul(self, rhs: f64) -> Self::Output {
805 self.as_f64() * rhs
806 }
807}
808
809impl Div<f64> for Money {
810 type Output = f64;
811 fn div(self, rhs: f64) -> Self::Output {
812 self.as_f64() / rhs
813 }
814}
815
816impl Debug for Money {
817 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
818 if self.currency.precision > crate::types::fixed::MAX_FLOAT_PRECISION {
819 write!(
820 f,
821 "{}({}, {})",
822 stringify!(Money),
823 format_scaled_i128(Self::raw_as_i128(self.raw), self.currency.precision),
824 self.currency,
825 )
826 } else {
827 let precision = self.currency.precision;
828 let scale = MoneyRaw::try_from(raw_scale(precision))
829 .expect("effective raw scale should fit in MoneyRaw");
830 let currency_scale = MoneyRaw::pow(10, u32::from(precision));
831 let amount = self.raw / (scale / currency_scale);
832
833 if precision == 0 {
834 write!(f, "{}({}, {})", stringify!(Money), amount, self.currency)
835 } else {
836 let sign = if amount < 0 { "-" } else { "" };
837 let amount_abs = amount.unsigned_abs();
838 let currency_scale = currency_scale.unsigned_abs();
839 let whole = amount_abs / currency_scale;
840 let fraction = amount_abs % currency_scale;
841 write!(
842 f,
843 "{}({sign}{whole}.{fraction:0>width$}, {})",
844 stringify!(Money),
845 self.currency,
846 width = usize::from(precision),
847 )
848 }
849 }
850 }
851}
852
853impl Display for Money {
854 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
855 write!(
856 f,
857 "{} {}",
858 format_scaled_i128(self.raw_at_precision(), self.currency.precision),
859 self.currency,
860 )
861 }
862}
863
864impl Serialize for Money {
865 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
866 where
867 S: serde::Serializer,
868 {
869 serializer.serialize_str(&self.to_string())
870 }
871}
872
873impl<'de> Deserialize<'de> for Money {
874 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
875 where
876 D: Deserializer<'de>,
877 {
878 let money_str: std::borrow::Cow<'de, str> = Deserialize::deserialize(deserializer)?;
879 Self::from_str(money_str.as_ref()).map_err(serde::de::Error::custom)
880 }
881}
882
883#[inline(always)]
889pub fn check_positive_money(value: Money, param: &str) -> CorrectnessResult<()> {
890 if value.raw <= 0 {
891 return Err(CorrectnessError::NotPositive {
892 param: param.to_string(),
893 value: value.to_string(),
894 type_name: "`Money`",
895 });
896 }
897 Ok(())
898}
899
900#[cfg(test)]
901mod tests {
902 use nautilus_core::{approx_eq, correctness::CorrectnessError};
903 use rstest::rstest;
904 use rust_decimal_macros::dec;
905
906 use super::*;
907 use crate::enums::CurrencyType;
908
909 #[cfg(feature = "high-precision")]
910 #[rstest]
911 fn test_as_decimal_above_decimal_mantissa() {
912 let currency = Currency::new("XYZ", 16, 0, "XYZ", crate::enums::CurrencyType::Crypto);
915 let money = Money::from_raw(MONEY_RAW_MAX, currency);
916
917 assert_eq!(money.as_decimal(), dec!(17014118346046));
918 }
919
920 #[rstest]
921 fn test_money_ordering_is_structural_across_currencies() {
922 use std::collections::BTreeSet;
923
924 let aud_low = Money::new(-1.0, Currency::AUD());
925 let aud_high = Money::new(100.0, Currency::AUD());
926 let usd_low = Money::new(-100.0, Currency::USD());
927 let usd_high = Money::new(1.0, Currency::USD());
928
929 assert_eq!(aud_high.cmp(&usd_low), Ordering::Less);
930 assert_eq!(aud_high.partial_cmp(&usd_low), Some(Ordering::Less));
931 assert_eq!(
932 (
933 aud_high < usd_low,
934 aud_high <= usd_low,
935 aud_high > usd_low,
936 aud_high >= usd_low,
937 usd_low < aud_high,
938 usd_low <= aud_high,
939 usd_low > aud_high,
940 usd_low >= aud_high,
941 ),
942 (true, true, false, false, false, false, true, true),
943 );
944
945 let expected = vec![aud_low, aud_high, usd_low, usd_high];
946 let mut sorted = vec![usd_high, aud_high, usd_low, aud_low];
947 sorted.sort();
948 let ordered = BTreeSet::from([usd_high, aud_high, usd_low, aud_low]);
949
950 assert_eq!(sorted, expected);
951 assert_eq!(ordered.into_iter().collect::<Vec<_>>(), expected);
952 }
953
954 #[rstest]
955 fn test_money_cmp_equal_matches_equality() {
956 use crate::enums::CurrencyType;
957
958 let currency = Currency::new("TST", 2, 1, "Test fiat", CurrencyType::Fiat);
959 let same_code = Currency::new("TST", 8, 2, "Test crypto", CurrencyType::Crypto);
960 let other_code = Currency::new("TSU", 2, 1, "Other fiat", CurrencyType::Fiat);
961 let money = Money::from_raw(1, currency);
962 let cases = [
963 (Money::from_raw(1, same_code), true),
964 (Money::from_raw(2, same_code), false),
965 (Money::from_raw(1, other_code), false),
966 ];
967
968 for (other, expected_equal) in cases {
969 assert_eq!(money == other, expected_equal);
970 assert_eq!(money.cmp(&other) == Ordering::Equal, expected_equal);
971 }
972 }
973
974 #[rstest]
975 fn test_from_quantity_rejects_noncanonical_raw() {
976 let precision = FIXED_PRECISION - 1;
977 let quantity = Quantity::from_raw(1, precision);
978 let currency = Currency::new("TOKEN", FIXED_PRECISION, 0, "Token", CurrencyType::Crypto);
979 let result = Money::from_quantity(quantity, currency);
980
981 assert_eq!(
982 result,
983 Err(CorrectnessError::PredicateViolation {
984 message: format!(
985 "Invalid fixed-point raw value 1 for precision {precision}: \
986 remainder 1 when divided by scale 10. Raw value should be a multiple of 10. \
987 This indicates data corruption or incorrect precision/scaling upstream"
988 ),
989 })
990 );
991 }
992
993 #[rstest]
994 fn test_extreme_money_round_trips_through_raw() {
995 let max = Money::new(MONEY_MAX, Currency::USD());
998 let min = Money::new(MONEY_MIN, Currency::USD());
999
1000 assert_eq!(max.raw, MONEY_RAW_MAX);
1001 assert_eq!(min.raw, MONEY_RAW_MIN);
1002 assert!(Money::from_raw_checked(max.raw, Currency::USD()).is_ok());
1003 assert!(Money::from_raw_checked(min.raw, Currency::USD()).is_ok());
1004 }
1005
1006 #[cfg(all(feature = "defi", feature = "high-precision"))]
1007 #[rstest]
1008 fn test_wei_above_decimal_mantissa_formats_exactly() {
1009 let raw = 80_000_000_000_000_000_250_000_000_000_i128;
1010 let currency = Currency::new("ETH", 18, 0, "Ether", crate::enums::CurrencyType::Crypto);
1011 let money = Money::from_raw(raw, currency);
1012
1013 assert_eq!(money.to_string(), "80000000000.000000250000000000 ETH");
1014 assert_eq!(
1015 money.to_formatted_string(),
1016 "80_000_000_000.000000250000000000 ETH"
1017 );
1018 }
1019
1020 #[cfg(all(feature = "defi", feature = "high-precision"))]
1021 #[rstest]
1022 fn test_wei_money_serde_json_round_trip() {
1023 let currency = Currency::new(
1024 "WXW",
1025 18,
1026 0,
1027 "Wei exact",
1028 crate::enums::CurrencyType::Crypto,
1029 );
1030 Currency::register(currency, true).unwrap();
1031 let money = Money::from_raw(80_000_000_000_000_000_250_000_000_000_i128, currency);
1032
1033 let json = serde_json::to_string(&money).unwrap();
1034 let deserialized = serde_json::from_str::<Money>(&json).unwrap();
1035
1036 assert_eq!(json, "\"80000000000.000000250000000000 WXW\"");
1037 assert_eq!(deserialized, money);
1038 }
1039
1040 #[rstest]
1041 fn test_debug() {
1042 let money = Money::new(1010.12, Currency::USD());
1043 let result = format!("{money:?}");
1044 let expected = "Money(1010.12, USD)";
1045 assert_eq!(result, expected);
1046 }
1047
1048 #[rstest]
1049 #[case(dec!(9007199253.999999999), "Money(9007199253.999999999, TST9)")]
1050 #[case(dec!(-9007199253.999999999), "Money(-9007199253.999999999, TST9)")]
1051 fn test_debug_preserves_exact_amount(#[case] amount: Decimal, #[case] expected: &str) {
1052 use crate::enums::CurrencyType;
1053
1054 let currency = Currency::new("TST9", 9, 0, "Test 9dp", CurrencyType::Crypto);
1055 let money = Money::from_decimal(amount, currency).unwrap();
1056
1057 assert_eq!(format!("{money:?}"), expected);
1058 }
1059
1060 #[rstest]
1061 fn test_debug_preserves_domain_maximum() {
1062 use crate::enums::CurrencyType;
1063
1064 let currency = Currency::new(
1065 "TST",
1066 FIXED_PRECISION,
1067 0,
1068 "Test fixed precision",
1069 CurrencyType::Crypto,
1070 );
1071 let money = Money::from_raw(MONEY_RAW_MAX, currency);
1072 let expected = if cfg!(feature = "high-precision") {
1073 "Money(17014118346046.0000000000000000, TST)"
1074 } else {
1075 "Money(9223372036.000000000, TST)"
1076 };
1077
1078 assert_eq!(format!("{money:?}"), expected);
1079 }
1080
1081 #[rstest]
1082 fn test_display() {
1083 let money = Money::new(1010.12, Currency::USD());
1084 let result = format!("{money}");
1085 let expected = "1010.12 USD";
1086 assert_eq!(result, expected);
1087 }
1088
1089 #[rstest]
1090 #[case(42.0, 0, "JPY", "Money(42, JPY)", "42 JPY")]
1091 #[case(0.0, 2, "USD", "Money(0.00, USD)", "0.00 USD")]
1092 #[case(-1010.12, 2, "USD", "Money(-1010.12, USD)", "-1010.12 USD")]
1093 #[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(
1096 #[case] value: f64,
1097 #[case] precision: u8,
1098 #[case] currency_code: &str,
1099 #[case] expected_debug: &str,
1100 #[case] expected_display: &str,
1101 ) {
1102 use crate::enums::CurrencyType;
1103 let currency = Currency::new(
1104 currency_code,
1105 precision,
1106 0,
1107 currency_code,
1108 CurrencyType::Fiat,
1109 );
1110 let money = Money::new(value, currency);
1111
1112 assert_eq!(format!("{money:?}"), expected_debug);
1113 assert_eq!(format!("{money}"), expected_display);
1114 }
1115
1116 #[rstest]
1117 #[cfg(feature = "defi")]
1118 #[case(
1119 1_000_000_000_000_000_000_i128,
1120 18,
1121 "wei",
1122 "Money(1.000000000000000000, wei)",
1123 "1.000000000000000000 wei"
1124 )] #[case(
1126 2_500_000_000_000_000_000_i128,
1127 18,
1128 "ETH",
1129 "Money(2.500000000000000000, ETH)",
1130 "2.500000000000000000 ETH"
1131 )] fn test_formatting_high_precision(
1133 #[case] raw_value: i128,
1134 #[case] precision: u8,
1135 #[case] currency_code: &str,
1136 #[case] expected_debug: &str,
1137 #[case] expected_display: &str,
1138 ) {
1139 use crate::enums::CurrencyType;
1140 let currency = Currency::new(
1141 currency_code,
1142 precision,
1143 0,
1144 currency_code,
1145 CurrencyType::Crypto,
1146 );
1147 let money = Money::from_raw(raw_value, currency);
1148
1149 assert_eq!(format!("{money:?}"), expected_debug);
1150 assert_eq!(format!("{money}"), expected_display);
1151 assert_eq!(
1152 serde_json::to_value(money).unwrap(),
1153 serde_json::Value::String(expected_display.to_string()),
1154 );
1155 }
1156
1157 #[rstest]
1158 fn test_zero_constructor() {
1159 let usd = Currency::USD();
1160 let money = Money::zero(usd);
1161 assert_eq!(money.raw, 0);
1162 assert_eq!(money.currency, usd);
1163 }
1164
1165 #[rstest]
1166 #[should_panic(expected = "Currency mismatch")]
1167 fn test_money_different_currency_addition() {
1168 let usd = Money::new(1000.0, Currency::USD());
1169 let btc = Money::new(1.0, Currency::BTC());
1170 let _ = usd + btc; }
1172
1173 #[rstest] fn test_with_maximum_value() {
1175 let money = Money::new_checked(MONEY_MAX, Currency::USD());
1176 assert!(money.is_ok());
1177 }
1178
1179 #[rstest] fn test_with_minimum_value() {
1181 let money = Money::new_checked(MONEY_MIN, Currency::USD());
1182 assert!(money.is_ok());
1183 }
1184
1185 #[rstest]
1186 fn test_new_checked_returns_typed_error_with_stable_display() {
1187 let error = Money::new_checked(MONEY_MAX + 1.0, Currency::USD()).unwrap_err();
1188
1189 assert!(matches!(error, CorrectnessError::OutOfRange { .. }));
1190 assert_eq!(
1191 error.to_string(),
1192 format!(
1193 "invalid f64 for 'amount' not in range [{MONEY_MIN}, {MONEY_MAX}], was {}",
1194 MONEY_MAX + 1.0
1195 )
1196 );
1197 }
1198
1199 #[cfg(not(feature = "defi"))]
1200 #[rstest]
1201 fn test_new_checked_invalid_currency_precision_returns_error() {
1202 let mut currency = Currency::USD();
1203 currency.precision = FIXED_PRECISION + 1;
1204
1205 let error = Money::new_checked(1.0, currency).unwrap_err();
1206 assert!(
1207 error
1208 .to_string()
1209 .contains("`precision` exceeded maximum `FIXED_PRECISION`"),
1210 "unexpected message: {error}"
1211 );
1212 }
1213
1214 #[cfg(feature = "defi")]
1215 #[rstest]
1216 fn test_new_checked_rejects_float_for_wei_currency() {
1217 use crate::enums::CurrencyType;
1218
1219 let currency = Currency::new("TST18", 18, 0, "Test token", CurrencyType::Crypto);
1220 let error = Money::new_checked(1.0, currency).unwrap_err();
1221 let message = "`currency.precision` exceeded maximum float precision (16), use \
1222 `Money::from_wei()` for wei values instead";
1223
1224 assert_eq!(
1225 error,
1226 CorrectnessError::PredicateViolation {
1227 message: message.to_string(),
1228 }
1229 );
1230 assert_eq!(error.to_string(), message);
1231 }
1232
1233 #[rstest]
1234 fn test_money_is_zero() {
1235 let zero_usd = Money::new(0.0, Currency::USD());
1236 assert!(zero_usd.is_zero());
1237 assert_eq!(zero_usd, Money::from("0.0 USD"));
1238
1239 let non_zero_usd = Money::new(100.0, Currency::USD());
1240 assert!(!non_zero_usd.is_zero());
1241 }
1242
1243 #[rstest]
1244 fn test_money_is_positive() {
1245 let usd = Currency::USD();
1246 assert!(Money::new(100.0, usd).is_positive());
1247 assert!(!Money::new(0.0, usd).is_positive());
1248 assert!(!Money::new(-100.0, usd).is_positive());
1249 }
1250
1251 #[rstest]
1252 fn test_money_comparisons() {
1253 let usd = Currency::USD();
1254 let m1 = Money::new(100.0, usd);
1255 let m2 = Money::new(200.0, usd);
1256
1257 assert!(m1 < m2);
1258 assert!(m2 > m1);
1259 assert!(m1 <= m2);
1260 assert!(m2 >= m1);
1261 assert_eq!(m1.cmp(&m2), Ordering::Less);
1262 assert_eq!(m2.cmp(&m1), Ordering::Greater);
1263
1264 let m3 = Money::new(100.0, usd);
1266 assert_eq!(m1, m3);
1267 }
1268
1269 #[rstest]
1270 fn test_add() {
1271 let a = 1000.0;
1272 let b = 500.0;
1273 let money1 = Money::new(a, Currency::USD());
1274 let money2 = Money::new(b, Currency::USD());
1275 let money3 = money1 + money2;
1276 assert_eq!(money3.raw, Money::new(a + b, Currency::USD()).raw);
1277 }
1278
1279 #[rstest]
1280 fn test_sub() {
1281 let usd = Currency::USD();
1282 let money1 = Money::new(1000.0, usd);
1283 let money2 = Money::new(250.0, usd);
1284 let result = money1 - money2;
1285 assert!(approx_eq!(f64, result.as_f64(), 750.0, epsilon = 1e-9));
1286 assert_eq!(result.currency, usd);
1287 }
1288
1289 #[rstest]
1290 fn test_money_checked_add_within_bounds() {
1291 let usd = Currency::USD();
1292 let a = Money::new(100.0, usd);
1293 let b = Money::new(50.0, usd);
1294 assert_eq!(a.checked_add(b), Some(Money::new(150.0, usd)));
1295 }
1296
1297 #[rstest]
1298 fn test_money_checked_add_above_max_returns_none() {
1299 let usd = Currency::USD();
1300 let near_max = Money::from_raw(MONEY_RAW_MAX, usd);
1301 let one = Money::new(1.0, usd);
1302 assert_eq!(near_max.checked_add(one), None);
1303 }
1304
1305 #[rstest]
1306 fn test_money_checked_sub_within_bounds() {
1307 let usd = Currency::USD();
1308 let a = Money::new(100.0, usd);
1309 let b = Money::new(40.0, usd);
1310 assert_eq!(a.checked_sub(b), Some(Money::new(60.0, usd)));
1311 }
1312
1313 #[rstest]
1314 fn test_money_checked_sub_below_min_returns_none() {
1315 let usd = Currency::USD();
1316 let near_min = Money::from_raw(MONEY_RAW_MIN, usd);
1317 let one = Money::new(1.0, usd);
1318 assert_eq!(near_min.checked_sub(one), None);
1319 }
1320
1321 #[rstest]
1322 fn test_money_checked_add_below_min_returns_none() {
1323 let usd = Currency::USD();
1324 let minus_one_unit = Money::from_raw(-1, usd);
1325
1326 assert_eq!(
1327 Money::from_raw(MONEY_RAW_MIN, usd).checked_add(minus_one_unit),
1328 None
1329 );
1330 assert_eq!(
1331 Money::from_raw(MONEY_RAW_MIN + 1, usd).checked_add(minus_one_unit),
1332 Some(Money::from_raw(MONEY_RAW_MIN, usd))
1333 );
1334 }
1335
1336 #[rstest]
1337 fn test_money_checked_sub_above_max_returns_none() {
1338 let usd = Currency::USD();
1339 let minus_one_unit = Money::from_raw(-1, usd);
1340
1341 assert_eq!(
1342 Money::from_raw(MONEY_RAW_MAX, usd).checked_sub(minus_one_unit),
1343 None
1344 );
1345 assert_eq!(
1346 Money::from_raw(MONEY_RAW_MAX - 1, usd).checked_sub(minus_one_unit),
1347 Some(Money::from_raw(MONEY_RAW_MAX, usd))
1348 );
1349 }
1350
1351 #[rstest]
1352 #[should_panic(expected = "Currency mismatch")]
1353 fn test_money_checked_add_currency_mismatch_panics() {
1354 let usd = Money::new(100.0, Currency::USD());
1355 let aud = Money::new(50.0, Currency::AUD());
1356 let _ = usd.checked_add(aud);
1357 }
1358
1359 #[rstest]
1360 #[should_panic(expected = "Currency mismatch")]
1361 fn test_money_checked_sub_currency_mismatch_panics() {
1362 let usd = Money::new(100.0, Currency::USD());
1363 let aud = Money::new(50.0, Currency::AUD());
1364 let _ = usd.checked_sub(aud);
1365 }
1366
1367 #[rstest]
1368 fn test_money_checked_add_at_exact_max_returns_some() {
1369 let usd = Currency::USD();
1370 let near_max = Money::from_raw(MONEY_RAW_MAX - 1, usd);
1371 let one_unit = Money::from_raw(1, usd);
1372 assert_eq!(
1373 near_max.checked_add(one_unit),
1374 Some(Money::from_raw(MONEY_RAW_MAX, usd)),
1375 );
1376 }
1377
1378 #[rstest]
1379 fn test_money_checked_sub_at_exact_min_returns_some() {
1380 let usd = Currency::USD();
1381 let near_min = Money::from_raw(MONEY_RAW_MIN + 1, usd);
1382 let one_unit = Money::from_raw(1, usd);
1383 assert_eq!(
1384 near_min.checked_sub(one_unit),
1385 Some(Money::from_raw(MONEY_RAW_MIN, usd)),
1386 );
1387 }
1388
1389 #[rstest]
1390 fn test_money_negation() {
1391 let money = Money::new(100.0, Currency::USD());
1392 let result = -money;
1393 assert_eq!(result, Money::from("-100.0 USD"));
1394 assert_eq!(result.currency, Currency::USD().clone());
1395 }
1396
1397 #[rstest]
1398 fn test_money_addition_decimal() {
1399 let money = Money::new(100.0, Currency::USD());
1400 let result = money + dec!(50.25);
1401 assert_eq!(result, dec!(150.25));
1402 }
1403
1404 #[rstest]
1405 fn test_money_subtraction_decimal() {
1406 let money = Money::new(100.0, Currency::USD());
1407 let result = money - dec!(30.50);
1408 assert_eq!(result, dec!(69.50));
1409 }
1410
1411 #[rstest]
1412 fn test_money_multiplication_decimal() {
1413 let money = Money::new(100.0, Currency::USD());
1414 let result = money * dec!(1.5);
1415 assert_eq!(result, dec!(150.00));
1416 }
1417
1418 #[rstest]
1419 fn test_money_division_decimal() {
1420 let money = Money::new(100.0, Currency::USD());
1421 let result = money / dec!(4);
1422 assert_eq!(result, dec!(25.00));
1423 }
1424
1425 #[rstest]
1426 fn test_money_addition_f64() {
1427 let money = Money::new(100.0, Currency::USD());
1428 let result = money + 50.25;
1429 assert!(approx_eq!(f64, result, 150.25, epsilon = 1e-9));
1430 }
1431
1432 #[rstest]
1433 fn test_money_subtraction_f64() {
1434 let money = Money::new(100.0, Currency::USD());
1435 let result = money - 30.50;
1436 assert!(approx_eq!(f64, result, 69.50, epsilon = 1e-9));
1437 }
1438
1439 #[rstest]
1440 fn test_money_multiplication_f64() {
1441 let money = Money::new(100.0, Currency::USD());
1442 let result = money * 1.5;
1443 assert!(approx_eq!(f64, result, 150.0, epsilon = 1e-9));
1444 }
1445
1446 #[rstest]
1447 fn test_money_division_f64() {
1448 let money = Money::new(100.0, Currency::USD());
1449 let result = money / 4.0;
1450 assert!(approx_eq!(f64, result, 25.0, epsilon = 1e-9));
1451 }
1452
1453 #[rstest]
1454 fn test_money_new_usd() {
1455 let money = Money::new(1000.0, Currency::USD());
1456 assert_eq!(money.currency.code.as_str(), "USD");
1457 assert_eq!(money.currency.precision, 2);
1458 assert_eq!(money.to_string(), "1000.00 USD");
1459 assert_eq!(money.to_formatted_string(), "1_000.00 USD");
1460 assert_eq!(money.as_decimal(), dec!(1000.00));
1461 assert!(approx_eq!(f64, money.as_f64(), 1000.0, epsilon = 0.001));
1462 }
1463
1464 #[rstest]
1465 fn test_money_new_btc() {
1466 let money = Money::new(10.3, Currency::BTC());
1467 assert_eq!(money.currency.code.as_str(), "BTC");
1468 assert_eq!(money.currency.precision, 8);
1469 assert_eq!(money.to_string(), "10.30000000 BTC");
1470 assert_eq!(money.to_formatted_string(), "10.30000000 BTC");
1471 }
1472
1473 #[rstest]
1474 fn test_to_formatted_string_preserves_digits_beyond_f64_precision() {
1475 use crate::enums::CurrencyType;
1476
1477 let currency = Currency::new("TST9", 9, 0, "Test 9dp", CurrencyType::Crypto);
1480 let money = Money::from_decimal(dec!(1234567890.123456789), currency).unwrap();
1481
1482 assert_eq!(money.to_formatted_string(), "1_234_567_890.123456789 TST9");
1483 }
1484
1485 #[rstest]
1486 #[case("0USD")] #[case("0x00 USD")] #[case("0 US")] #[case("0 USD USD")] #[should_panic(expected = "Condition failed")]
1491 fn test_from_str_invalid_input(#[case] input: &str) {
1492 let _ = Money::from(input);
1493 }
1494
1495 #[rstest]
1496 #[case("0 USD", Currency::USD(), dec!(0.00))]
1497 #[case("1.1 AUD", Currency::AUD(), dec!(1.10))]
1498 #[case("1.12345678 BTC", Currency::BTC(), dec!(1.12345678))]
1499 #[case("10_000.10 USD", Currency::USD(), dec!(10000.10))]
1500 fn test_from_str_valid_input(
1501 #[case] input: &str,
1502 #[case] expected_currency: Currency,
1503 #[case] expected_dec: Decimal,
1504 ) {
1505 let money = Money::from(input);
1506 assert_eq!(money.currency, expected_currency);
1507 assert_eq!(money.as_decimal(), expected_dec);
1508 }
1509
1510 #[rstest]
1511 #[case("1.005 USD", dec!(1.00))]
1512 #[case("1.015 USD", dec!(1.02))]
1513 #[case("-1.005 USD", dec!(-1.00))]
1514 #[case("-1.015 USD", dec!(-1.02))]
1515 fn test_from_str_rounds_half_to_even(#[case] input: &str, #[case] expected: Decimal) {
1516 let money = input.parse::<Money>().unwrap();
1517
1518 assert_eq!(
1519 money,
1520 Money::from_decimal(expected, Currency::USD()).unwrap()
1521 );
1522 assert_eq!(money.currency, Currency::USD());
1523 assert_eq!(money.as_decimal(), expected);
1524 }
1525
1526 #[rstest]
1527 fn test_money_from_str_negative() {
1528 let money = Money::from("-123.45 USD");
1529 assert!(approx_eq!(f64, money.as_f64(), -123.45, epsilon = 1e-9));
1530 assert_eq!(money.currency, Currency::USD());
1531 }
1532
1533 #[rstest]
1534 #[case("1e7 USD", 10_000_000.0)]
1535 #[case("2.5e3 EUR", 2_500.0)]
1536 #[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) {
1539 let money = Money::from_str(input).unwrap();
1540 assert!(approx_eq!(
1541 f64,
1542 money.as_f64(),
1543 expected_value,
1544 epsilon = 1e-10
1545 ));
1546 }
1547
1548 #[rstest]
1549 #[case("1_234.56 USD", 1234.56)]
1550 #[case("1_000_000 EUR", 1_000_000.0)]
1551 #[case("99_999.99 GBP", 99_999.99)]
1552 fn test_from_str_with_underscores(#[case] input: &str, #[case] expected_value: f64) {
1553 let money = Money::from_str(input).unwrap();
1554 assert!(approx_eq!(
1555 f64,
1556 money.as_f64(),
1557 expected_value,
1558 epsilon = 1e-10
1559 ));
1560 }
1561
1562 #[rstest]
1563 fn test_from_decimal_precision_preservation() {
1564 use rust_decimal::Decimal;
1565
1566 let decimal = Decimal::from_str("123.45").unwrap();
1567 let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
1568 assert_eq!(money.currency.precision, 2);
1569 assert!(approx_eq!(f64, money.as_f64(), 123.45, epsilon = 1e-10));
1570
1571 let expected_raw = 12345 * 10_i64.pow(u32::from(FIXED_PRECISION - 2));
1573 assert_eq!(money.raw, MoneyRaw::from(expected_raw));
1574 }
1575
1576 #[rstest]
1577 fn test_from_decimal_rounding() {
1578 use rust_decimal::Decimal;
1579
1580 let decimal = Decimal::from_str("1.005").unwrap();
1582 let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
1583 assert_eq!(money.as_f64(), 1.0); let decimal = Decimal::from_str("1.015").unwrap();
1586 let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
1587 assert_eq!(money.as_f64(), 1.02); }
1589
1590 #[rstest]
1591 fn test_money_hash() {
1592 use std::{
1593 collections::hash_map::DefaultHasher,
1594 hash::{Hash, Hasher},
1595 };
1596
1597 let m1 = Money::new(100.0, Currency::USD());
1598 let m2 = Money::new(100.0, Currency::USD());
1599 let m3 = Money::new(100.0, Currency::AUD());
1600
1601 let mut s1 = DefaultHasher::new();
1602 let mut s2 = DefaultHasher::new();
1603 let mut s3 = DefaultHasher::new();
1604
1605 m1.hash(&mut s1);
1606 m2.hash(&mut s2);
1607 m3.hash(&mut s3);
1608
1609 assert_eq!(
1610 s1.finish(),
1611 s2.finish(),
1612 "Same amount + same currency => same hash"
1613 );
1614 assert_ne!(
1615 s1.finish(),
1616 s3.finish(),
1617 "Same amount + different currency => different hash"
1618 );
1619 }
1620
1621 #[rstest]
1622 fn test_money_serialization_deserialization() {
1623 let money = Money::new(123.45, Currency::USD());
1624 let serialized = serde_json::to_string(&money);
1625 let deserialized: Money = serde_json::from_str(&serialized.unwrap()).unwrap();
1626 assert_eq!(money, deserialized);
1627 }
1628
1629 #[rstest]
1630 fn test_money_deserialize_from_owned_value() {
1631 let money = Money::new(123.45, Currency::USD());
1632 let value = serde_json::to_value(money).unwrap();
1633
1634 let deserialized: Money = serde_json::from_value(value).unwrap();
1635 assert_eq!(money, deserialized);
1636 }
1637
1638 #[rstest]
1639 fn test_money_deserialize_invalid_format_returns_error() {
1640 let result = serde_json::from_str::<Money>("\"100.00\"");
1641 let error = result.unwrap_err();
1642 assert!(
1643 error.to_string().contains("Expected '<amount> <currency>'"),
1644 "unexpected message: {error}"
1645 );
1646 }
1647
1648 #[rstest]
1649 fn test_money_deserialize_unknown_currency_returns_error() {
1650 let result = serde_json::from_str::<Money>("\"100.00 ZZZZ\"");
1651 let error = result.unwrap_err();
1652 assert!(
1653 error.to_string().contains("Unknown currency"),
1654 "unexpected message: {error}"
1655 );
1656 }
1657
1658 #[rstest]
1659 #[should_panic(expected = "`raw` value")]
1660 fn test_money_from_raw_out_of_range_panics() {
1661 let usd = Currency::USD();
1662 let raw = MONEY_RAW_MAX.saturating_add(1);
1663 let _ = Money::from_raw(raw, usd);
1664 }
1665
1666 #[rstest]
1667 fn test_money_from_raw_checked_valid() {
1668 let usd = Currency::USD();
1669 let money = Money::from_raw_checked(123_450_000_000, usd).unwrap();
1670 assert_eq!(money.currency, usd);
1671 }
1672
1673 #[rstest]
1674 fn test_money_from_raw_checked_above_max_returns_error() {
1675 let usd = Currency::USD();
1676 let raw = MONEY_RAW_MAX.saturating_add(1);
1677 let error = Money::from_raw_checked(raw, usd).unwrap_err();
1678 assert!(matches!(error, CorrectnessError::PredicateViolation { .. }));
1679 }
1680
1681 #[rstest]
1682 fn test_money_from_raw_checked_below_min_returns_error() {
1683 let usd = Currency::USD();
1684 let raw = MONEY_RAW_MIN.saturating_sub(1);
1685 let error = Money::from_raw_checked(raw, usd).unwrap_err();
1686 assert!(matches!(error, CorrectnessError::PredicateViolation { .. }));
1687 }
1688
1689 #[rstest]
1690 fn test_from_decimal_rejects_out_of_range() {
1691 let huge = Decimal::from_str("99999999999999999999.99").unwrap();
1692 let result = Money::from_decimal(huge, Currency::USD());
1693 assert!(result.is_err());
1694 }
1695
1696 #[rstest]
1697 fn test_from_decimal_rejects_raw_between_money_and_raw_bounds() {
1698 let usd = Currency::USD();
1699 let at_max = Decimal::try_from(MONEY_MAX).unwrap();
1700 let above_max = at_max + dec!(0.5);
1701 let expected_raw = MONEY_RAW_MAX + 5 * MoneyRaw::pow(10, u32::from(FIXED_PRECISION - 1));
1702
1703 let error = Money::from_decimal(above_max, usd).unwrap_err();
1704
1705 assert_eq!(
1706 error.to_string(),
1707 format!(
1708 "Raw value {expected_raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
1709 )
1710 );
1711 assert_eq!(Money::from_decimal(at_max, usd).unwrap().raw, MONEY_RAW_MAX);
1712 }
1713
1714 #[rstest]
1715 fn test_from_decimal_out_of_range_returns_typed_error_with_stable_display() {
1716 let huge = Decimal::from_str("99999999999999999999.99").unwrap();
1717 let error = Money::from_decimal(huge, Currency::USD()).unwrap_err();
1718 match error {
1719 CorrectnessError::PredicateViolation { ref message } => {
1720 assert!(
1721 message.contains("MoneyRaw range") || message.contains("Money"),
1722 "unexpected message: {message:?}",
1723 );
1724 }
1725 _ => panic!("expected PredicateViolation, was {error:?}"),
1726 }
1727 }
1728
1729 #[rstest]
1730 fn test_from_mantissa_exponent_exact_precision() {
1731 let money = Money::from_mantissa_exponent(12345, -2, Currency::USD());
1732 assert_eq!(money.as_f64(), 123.45);
1733 }
1734
1735 #[rstest]
1736 fn test_from_mantissa_exponent_excess_rounds_down() {
1737 let money = Money::from_mantissa_exponent(12345, -3, Currency::USD());
1739 assert_eq!(money.as_f64(), 12.34);
1740 }
1741
1742 #[rstest]
1743 fn test_from_mantissa_exponent_excess_rounds_up() {
1744 let money = Money::from_mantissa_exponent(12355, -3, Currency::USD());
1746 assert_eq!(money.as_f64(), 12.36);
1747 }
1748
1749 #[rstest]
1750 fn test_from_mantissa_exponent_positive_exponent() {
1751 let money = Money::from_mantissa_exponent(5, 2, Currency::USD());
1752 assert_eq!(money.as_f64(), 500.0);
1753 }
1754
1755 #[rstest]
1756 #[should_panic(expected = "Money::from_mantissa_exponent")]
1757 fn test_from_mantissa_exponent_overflow_panics() {
1758 let _ = Money::from_mantissa_exponent(i64::MAX, 9, Currency::USD());
1759 }
1760
1761 #[rstest]
1762 #[should_panic(expected = "exceeds i128 range")]
1763 fn test_from_mantissa_exponent_large_exponent_panics() {
1764 let _ = Money::from_mantissa_exponent(1, 119, Currency::USD());
1765 }
1766
1767 #[rstest]
1768 fn test_from_mantissa_exponent_zero_with_large_exponent() {
1769 let money = Money::from_mantissa_exponent(0, 119, Currency::USD());
1770 assert_eq!(money.as_f64(), 0.0);
1771 }
1772
1773 #[rstest]
1774 fn test_from_mantissa_exponent_very_negative_exponent_rounds_to_zero() {
1775 let money = Money::from_mantissa_exponent(12345, -120, Currency::USD());
1777 assert_eq!(money.as_f64(), 0.0);
1778 }
1779
1780 #[rstest]
1781 #[case(42.0, true, "positive value")]
1782 #[case(0.0, false, "zero value")]
1783 #[case( -13.5, false, "negative value")]
1784 #[expect(
1785 clippy::used_underscore_binding,
1786 reason = "rstest case name documents the parameterized input"
1787 )]
1788 fn test_check_positive_money(
1789 #[case] amount: f64,
1790 #[case] should_succeed: bool,
1791 #[case] _case_name: &str,
1792 ) {
1793 let money = Money::new(amount, Currency::USD());
1794
1795 let res = check_positive_money(money, "money");
1796
1797 if should_succeed {
1798 assert!(res.is_ok(), "expected Ok(..) for {amount}");
1799 } else {
1800 assert!(res.is_err(), "expected Err(..) for {amount}");
1801 let msg = res.unwrap_err().to_string();
1802 assert!(
1803 msg.contains("not positive"),
1804 "error message should mention positivity; got: {msg:?}"
1805 );
1806 }
1807 }
1808
1809 #[rstest]
1810 fn test_check_positive_money_returns_typed_error_with_stable_display() {
1811 let error = check_positive_money(Money::new(0.0, Currency::USD()), "money").unwrap_err();
1812
1813 assert_eq!(
1814 error,
1815 CorrectnessError::NotPositive {
1816 param: "money".to_string(),
1817 value: "0.00 USD".to_string(),
1818 type_name: "`Money`",
1819 }
1820 );
1821 assert_eq!(
1822 error.to_string(),
1823 "invalid `Money` for 'money' not positive, was 0.00 USD"
1824 );
1825 }
1826}
1827
1828#[cfg(test)]
1829mod property_tests {
1830 use proptest::prelude::*;
1831 use rstest::rstest;
1832
1833 use super::*;
1834
1835 fn currency_strategy() -> impl Strategy<Value = Currency> {
1836 prop_oneof![
1837 Just(Currency::USD()),
1838 Just(Currency::EUR()),
1839 Just(Currency::GBP()),
1840 Just(Currency::JPY()),
1841 Just(Currency::AUD()),
1842 Just(Currency::CAD()),
1843 Just(Currency::CHF()),
1844 Just(Currency::BTC()),
1845 Just(Currency::ETH()),
1846 Just(Currency::USDT()),
1847 ]
1848 }
1849
1850 fn money_amount_strategy() -> impl Strategy<Value = f64> {
1851 prop_oneof![
1852 -1000.0..1000.0,
1853 -100_000.0..100_000.0,
1854 -1_000_000.0..1_000_000.0,
1855 Just(0.0),
1856 Just(MONEY_MIN / 2.0),
1857 Just(MONEY_MAX / 2.0),
1858 Just(MONEY_MIN + 1.0),
1859 Just(MONEY_MAX - 1.0),
1860 Just(MONEY_MIN),
1861 Just(MONEY_MAX),
1862 ]
1863 }
1864
1865 fn money_strategy() -> impl Strategy<Value = Money> {
1866 (money_amount_strategy(), currency_strategy())
1867 .prop_filter_map("constructible money", |(amount, currency)| {
1868 Money::new_checked(amount, currency).ok()
1869 })
1870 }
1871
1872 proptest! {
1873 #[rstest]
1874 fn prop_money_construction_roundtrip(
1875 amount in money_amount_strategy(),
1876 currency in currency_strategy()
1877 ) {
1878 if let Ok(money) = Money::new_checked(amount, currency) {
1879 let roundtrip = money.as_f64();
1880 let precision_epsilon = if currency.precision == 0 {
1881 1.0
1882 } else {
1883 let currency_epsilon = 10.0_f64.powi(-i32::from(currency.precision));
1884 let magnitude_epsilon = amount.abs() * 1e-10;
1885 currency_epsilon.max(magnitude_epsilon)
1886 };
1887 prop_assert!((roundtrip - amount).abs() <= precision_epsilon,
1888 "Roundtrip failed: {} -> {} -> {} (precision: {}, epsilon: {})",
1889 amount, money.raw, roundtrip, currency.precision, precision_epsilon);
1890 prop_assert_eq!(money.currency, currency);
1891 }
1892 }
1893
1894 #[rstest]
1895 fn prop_money_addition_commutative(
1896 money1 in money_strategy(),
1897 money2 in money_strategy(),
1898 ) {
1899 if money1.currency == money2.currency
1900 && let (Some(_), Some(_)) = (
1901 money1.raw.checked_add(money2.raw),
1902 money2.raw.checked_add(money1.raw)
1903 )
1904 {
1905 let sum1 = money1 + money2;
1906 let sum2 = money2 + money1;
1907 prop_assert_eq!(sum1, sum2, "Addition should be commutative");
1908 prop_assert_eq!(sum1.currency, money1.currency);
1909 }
1910 }
1911
1912 #[rstest]
1913 fn prop_money_addition_associative(
1914 money1 in money_strategy(),
1915 money2 in money_strategy(),
1916 money3 in money_strategy(),
1917 ) {
1918 if money1.currency == money2.currency
1919 && money2.currency == money3.currency
1920 && let (Some(sum1), Some(sum2)) = (
1921 money1.raw.checked_add(money2.raw),
1922 money2.raw.checked_add(money3.raw)
1923 )
1924 && let (Some(left), Some(right)) = (
1925 sum1.checked_add(money3.raw),
1926 money1.raw.checked_add(sum2)
1927 )
1928 && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&left)
1929 && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&right)
1930 {
1931 let left_result = Money::from_raw(left, money1.currency);
1932 let right_result = Money::from_raw(right, money1.currency);
1933 prop_assert_eq!(left_result, right_result, "Addition should be associative");
1934 }
1935 }
1936
1937 #[rstest]
1938 fn prop_money_subtraction_inverse(
1939 money1 in money_strategy(),
1940 money2 in money_strategy(),
1941 ) {
1942 if money1.currency == money2.currency
1943 && let Some(sum_raw) = money1.raw.checked_add(money2.raw)
1944 && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&sum_raw)
1945 {
1946 let sum = Money::from_raw(sum_raw, money1.currency);
1947 let diff = sum - money2;
1948 prop_assert_eq!(diff, money1, "Subtraction should be inverse of addition");
1949 }
1950 }
1951
1952 #[rstest]
1955 fn prop_money_checked_add_matches_spec(
1956 raw1 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1957 raw2 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1958 currency in currency_strategy(),
1959 ) {
1960 let m1 = Money::from_raw(raw1, currency);
1961 let m2 = Money::from_raw(raw2, currency);
1962 let expected = m1.raw
1963 .checked_add(m2.raw)
1964 .filter(|r| (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(r))
1965 .map(|raw| Money { raw, currency });
1966 prop_assert_eq!(m1.checked_add(m2), expected);
1967 }
1968
1969 #[rstest]
1972 fn prop_money_checked_sub_matches_spec(
1973 raw1 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1974 raw2 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1975 currency in currency_strategy(),
1976 ) {
1977 let m1 = Money::from_raw(raw1, currency);
1978 let m2 = Money::from_raw(raw2, currency);
1979 let expected = m1.raw
1980 .checked_sub(m2.raw)
1981 .filter(|r| (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(r))
1982 .map(|raw| Money { raw, currency });
1983 prop_assert_eq!(m1.checked_sub(m2), expected);
1984 }
1985
1986 #[rstest]
1987 fn prop_money_zero_identity(money in money_strategy()) {
1988 let zero = Money::zero(money.currency);
1989 prop_assert_eq!(money + zero, money, "Zero should be additive identity");
1990 prop_assert_eq!(zero + money, money, "Zero should be additive identity (commutative)");
1991 prop_assert!(zero.is_zero(), "Zero should be recognized as zero");
1992 }
1993
1994 #[rstest]
1995 fn prop_money_negation_inverse(money in money_strategy()) {
1996 let negated = -money;
1997 let double_neg = -negated;
1998 prop_assert_eq!(money, double_neg, "Double negation should equal original");
1999 prop_assert_eq!(negated.currency, money.currency, "Negation preserves currency");
2000
2001 if let Some(sum_raw) = money.raw.checked_add(negated.raw)
2002 && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&sum_raw) {
2003 let sum = Money::from_raw(sum_raw, money.currency);
2004 prop_assert!(sum.is_zero(), "Money + (-Money) should equal zero");
2005 }
2006 }
2007
2008 #[rstest]
2009 fn prop_money_comparison_consistency(
2010 money1 in money_strategy(),
2011 money2 in money_strategy(),
2012 ) {
2013 let eq = money1 == money2;
2014 let lt = money1 < money2;
2015 let gt = money1 > money2;
2016 let le = money1 <= money2;
2017 let ge = money1 >= money2;
2018
2019 let exclusive_count = [eq, lt, gt].iter().filter(|&&x| x).count();
2020 prop_assert_eq!(exclusive_count, 1, "Exactly one of ==, <, > should be true");
2021
2022 prop_assert_eq!(le, eq || lt, "<= should equal == || <");
2023 prop_assert_eq!(ge, eq || gt, ">= should equal == || >");
2024 prop_assert_eq!(lt, money2 > money1, "< should be symmetric with >");
2025 prop_assert_eq!(le, money2 >= money1, "<= should be symmetric with >=");
2026 }
2027
2028 #[rstest]
2029 fn prop_money_decimal_conversion(money in money_strategy()) {
2030 let decimal = money.as_decimal();
2031
2032 prop_assert_eq!(decimal.scale(), u32::from(money.currency.precision));
2034
2035 #[cfg(feature = "defi")]
2036 {
2037 let decimal_f64: f64 = decimal.try_into().unwrap_or(0.0);
2038 prop_assert!(decimal_f64.is_finite(), "Decimal should convert to finite f64");
2039 }
2040 #[cfg(not(feature = "defi"))]
2041 {
2042 let decimal_f64: f64 = decimal.try_into().unwrap_or(0.0);
2043 let original_f64 = money.as_f64();
2044
2045 let base_epsilon = 10.0_f64.powi(-i32::from(money.currency.precision));
2046 let precision_epsilon = if cfg!(feature = "high-precision") {
2047 base_epsilon.max(1e-10)
2048 } else {
2049 base_epsilon
2050 };
2051 let diff = (decimal_f64 - original_f64).abs();
2052 prop_assert!(diff <= precision_epsilon,
2053 "Decimal conversion should preserve value within currency precision: {} vs {} (diff: {}, epsilon: {})",
2054 original_f64, decimal_f64, diff, precision_epsilon);
2055 }
2056 }
2057
2058 #[rstest]
2059 fn prop_money_arithmetic_with_f64(
2060 money in money_strategy(),
2061 factor in -1000.0..1000.0_f64,
2062 ) {
2063 if factor != 0.0 {
2064 let original_f64 = money.as_f64();
2065
2066 let mul_result = money * factor;
2067 let expected_mul = original_f64 * factor;
2068 prop_assert!((mul_result - expected_mul).abs() < 0.01,
2069 "Multiplication with f64 should be accurate");
2070
2071 let div_result = money / factor;
2072 let expected_div = original_f64 / factor;
2073 if expected_div.is_finite() {
2074 prop_assert!((div_result - expected_div).abs() < 0.01,
2075 "Division with f64 should be accurate");
2076 }
2077
2078 let add_result = money + factor;
2079 let expected_add = original_f64 + factor;
2080 prop_assert!((add_result - expected_add).abs() < 0.01,
2081 "Addition with f64 should be accurate");
2082
2083 let sub_result = money - factor;
2084 let expected_sub = original_f64 - factor;
2085 prop_assert!((sub_result - expected_sub).abs() < 0.01,
2086 "Subtraction with f64 should be accurate");
2087 }
2088 }
2089 }
2090}