1use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use std::fmt;
10use std::ops::{Add, AddAssign, Neg, Sub, SubAssign};
11
12use crate::Currency;
13#[cfg(feature = "rkyv")]
14use crate::intern::AsDecimal;
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[cfg_attr(
35 feature = "rkyv",
36 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
37)]
38pub struct Amount {
39 #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
41 pub number: Decimal,
42 pub currency: Currency,
44}
45
46impl Amount {
47 #[must_use]
49 pub fn new(number: Decimal, currency: impl Into<Currency>) -> Self {
50 Self {
51 number,
52 currency: currency.into(),
53 }
54 }
55
56 #[must_use]
58 pub fn zero(currency: impl Into<Currency>) -> Self {
59 Self {
60 number: Decimal::ZERO,
61 currency: currency.into(),
62 }
63 }
64
65 #[must_use]
67 pub const fn is_zero(&self) -> bool {
68 self.number.is_zero()
69 }
70
71 #[must_use]
73 pub const fn is_positive(&self) -> bool {
74 self.number.is_sign_positive() && !self.number.is_zero()
75 }
76
77 #[must_use]
79 pub const fn is_negative(&self) -> bool {
80 self.number.is_sign_negative()
81 }
82
83 #[must_use]
85 pub fn abs(&self) -> Self {
86 Self {
87 number: self.number.abs(),
88 currency: self.currency.clone(),
89 }
90 }
91
92 #[must_use]
94 pub const fn scale(&self) -> u32 {
95 self.number.scale()
96 }
97
98 #[must_use]
105 pub fn inferred_tolerance(&self) -> Decimal {
106 Decimal::new(5, self.number.scale() + 1)
108 }
109
110 #[must_use]
112 pub fn is_near_zero(&self, tolerance: Decimal) -> bool {
113 self.number.abs() <= tolerance
114 }
115
116 #[must_use]
120 pub fn is_near(&self, other: &Self, tolerance: Decimal) -> bool {
121 self.currency == other.currency && (self.number - other.number).abs() <= tolerance
122 }
123
124 #[must_use]
145 pub fn eq_with_tolerance(&self, other: &Self, tolerance: Decimal) -> bool {
146 self.is_near(other, tolerance)
147 }
148
149 #[must_use]
167 pub fn eq_auto_tolerance(&self, other: &Self) -> bool {
168 if self.currency != other.currency {
169 return false;
170 }
171 let tolerance = self.inferred_tolerance().max(other.inferred_tolerance());
172 (self.number - other.number).abs() <= tolerance
173 }
174
175 #[must_use]
177 pub fn round_dp(&self, dp: u32) -> Self {
178 Self {
179 number: self.number.round_dp(dp),
180 currency: self.currency.clone(),
181 }
182 }
183}
184
185impl fmt::Display for Amount {
186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187 write!(f, "{} {}", self.number, self.currency)
188 }
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct AmountParseError {
201 pub input: String,
203 pub reason: AmountParseErrorReason,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum AmountParseErrorReason {
215 NotTwoTokens,
217 InvalidNumber(String),
220 InvalidCurrency(String),
224}
225
226impl fmt::Display for AmountParseError {
227 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228 match &self.reason {
229 AmountParseErrorReason::NotTwoTokens => write!(
230 f,
231 "invalid amount literal {:?}: expected `<number> <currency>` (e.g. \"100 USD\")",
232 self.input,
233 ),
234 AmountParseErrorReason::InvalidNumber(tok) => write!(
235 f,
236 "invalid amount literal {:?}: {:?} doesn't parse as a decimal number",
237 self.input, tok,
238 ),
239 AmountParseErrorReason::InvalidCurrency(tok) => write!(
240 f,
241 "invalid amount literal {:?}: {:?} isn't a valid commodity \
242 (uppercase ASCII, may contain digits/'./_/-, max 24 chars)",
243 self.input, tok,
244 ),
245 }
246 }
247}
248
249impl std::error::Error for AmountParseError {}
250
251impl std::str::FromStr for Amount {
252 type Err = AmountParseError;
253
254 fn from_str(s: &str) -> Result<Self, Self::Err> {
279 let mut iter = s.split_whitespace();
280 let (Some(num_tok), Some(cur_tok), None) = (iter.next(), iter.next(), iter.next()) else {
281 return Err(AmountParseError {
282 input: s.to_string(),
283 reason: AmountParseErrorReason::NotTwoTokens,
284 });
285 };
286
287 let number = Decimal::from_str_exact(num_tok).map_err(|_| AmountParseError {
291 input: s.to_string(),
292 reason: AmountParseErrorReason::InvalidNumber(num_tok.to_string()),
293 })?;
294
295 if !is_valid_commodity(cur_tok) {
296 return Err(AmountParseError {
297 input: s.to_string(),
298 reason: AmountParseErrorReason::InvalidCurrency(cur_tok.to_string()),
299 });
300 }
301
302 Ok(Self::new(number, cur_tok))
303 }
304}
305
306fn is_valid_commodity(s: &str) -> bool {
314 if s.is_empty() || s.len() > 24 {
315 return false;
316 }
317 let mut chars = s.chars();
318 let Some(first) = chars.next() else {
319 return false;
320 };
321 if !first.is_ascii_uppercase() {
322 return false;
323 }
324 chars.all(|c| {
325 c.is_ascii_uppercase() || c.is_ascii_digit() || matches!(c, '\'' | '.' | '_' | '-')
326 })
327}
328
329impl Add for &Amount {
332 type Output = Amount;
333
334 fn add(self, other: &Amount) -> Amount {
335 debug_assert_eq!(
336 self.currency, other.currency,
337 "Cannot add amounts with different currencies"
338 );
339 Amount {
340 number: self.number + other.number,
341 currency: self.currency.clone(),
342 }
343 }
344}
345
346impl Sub for &Amount {
347 type Output = Amount;
348
349 fn sub(self, other: &Amount) -> Amount {
350 debug_assert_eq!(
351 self.currency, other.currency,
352 "Cannot subtract amounts with different currencies"
353 );
354 Amount {
355 number: self.number - other.number,
356 currency: self.currency.clone(),
357 }
358 }
359}
360
361impl Neg for &Amount {
362 type Output = Amount;
363
364 fn neg(self) -> Amount {
365 Amount {
366 number: -self.number,
367 currency: self.currency.clone(),
368 }
369 }
370}
371
372impl Add for Amount {
375 type Output = Self;
376
377 fn add(self, other: Self) -> Self {
378 &self + &other
379 }
380}
381
382impl Sub for Amount {
383 type Output = Self;
384
385 fn sub(self, other: Self) -> Self {
386 &self - &other
387 }
388}
389
390impl Neg for Amount {
391 type Output = Self;
392
393 fn neg(self) -> Self {
394 -&self
395 }
396}
397
398impl AddAssign<&Self> for Amount {
399 fn add_assign(&mut self, other: &Self) {
400 debug_assert_eq!(
401 self.currency, other.currency,
402 "Cannot add amounts with different currencies"
403 );
404 self.number += other.number;
405 }
406}
407
408impl SubAssign<&Self> for Amount {
409 fn sub_assign(&mut self, other: &Self) {
410 debug_assert_eq!(
411 self.currency, other.currency,
412 "Cannot subtract amounts with different currencies"
413 );
414 self.number -= other.number;
415 }
416}
417
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430#[cfg_attr(
431 feature = "rkyv",
432 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
433)]
434pub enum IncompleteAmount {
435 Complete(Amount),
437 NumberOnly(#[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))] Decimal),
439 CurrencyOnly(Currency),
441}
442
443impl IncompleteAmount {
444 #[must_use]
446 pub fn complete(number: Decimal, currency: impl Into<Currency>) -> Self {
447 Self::Complete(Amount::new(number, currency))
448 }
449
450 #[must_use]
452 pub const fn number_only(number: Decimal) -> Self {
453 Self::NumberOnly(number)
454 }
455
456 #[must_use]
458 pub fn currency_only(currency: impl Into<Currency>) -> Self {
459 Self::CurrencyOnly(currency.into())
460 }
461
462 #[must_use]
464 pub const fn number(&self) -> Option<Decimal> {
465 match self {
466 Self::Complete(a) => Some(a.number),
467 Self::NumberOnly(n) => Some(*n),
468 Self::CurrencyOnly(_) => None,
469 }
470 }
471
472 #[must_use]
474 pub fn currency(&self) -> Option<&str> {
475 match self {
476 Self::Complete(a) => Some(&a.currency),
477 Self::NumberOnly(_) => None,
478 Self::CurrencyOnly(c) => Some(c),
479 }
480 }
481
482 #[must_use]
484 pub const fn is_complete(&self) -> bool {
485 matches!(self, Self::Complete(_))
486 }
487
488 #[must_use]
490 pub const fn as_amount(&self) -> Option<&Amount> {
491 match self {
492 Self::Complete(a) => Some(a),
493 _ => None,
494 }
495 }
496
497 #[must_use]
499 pub fn into_amount(self) -> Option<Amount> {
500 match self {
501 Self::Complete(a) => Some(a),
502 _ => None,
503 }
504 }
505}
506
507impl From<Amount> for IncompleteAmount {
508 fn from(amount: Amount) -> Self {
509 Self::Complete(amount)
510 }
511}
512
513impl fmt::Display for IncompleteAmount {
514 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
515 match self {
516 Self::Complete(a) => write!(f, "{a}"),
517 Self::NumberOnly(n) => write!(f, "{n}"),
518 Self::CurrencyOnly(c) => write!(f, "{c}"),
519 }
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use rust_decimal_macros::dec;
527
528 #[test]
529 fn test_new() {
530 let amount = Amount::new(dec!(100.00), "USD");
531 assert_eq!(amount.number, dec!(100.00));
532 assert_eq!(amount.currency, "USD");
533 }
534
535 #[test]
536 fn test_zero() {
537 let amount = Amount::zero("EUR");
538 assert!(amount.is_zero());
539 assert_eq!(amount.currency, "EUR");
540 }
541
542 #[test]
543 fn test_is_positive_negative() {
544 let pos = Amount::new(dec!(100), "USD");
545 let neg = Amount::new(dec!(-100), "USD");
546 let zero = Amount::zero("USD");
547
548 assert!(pos.is_positive());
549 assert!(!pos.is_negative());
550
551 assert!(!neg.is_positive());
552 assert!(neg.is_negative());
553
554 assert!(!zero.is_positive());
555 assert!(!zero.is_negative());
556 }
557
558 #[test]
559 fn test_add() {
560 let a = Amount::new(dec!(100.00), "USD");
561 let b = Amount::new(dec!(50.00), "USD");
562 let sum = &a + &b;
563 assert_eq!(sum.number, dec!(150.00));
564 assert_eq!(sum.currency, "USD");
565 }
566
567 #[test]
568 fn test_sub() {
569 let a = Amount::new(dec!(100.00), "USD");
570 let b = Amount::new(dec!(50.00), "USD");
571 let diff = &a - &b;
572 assert_eq!(diff.number, dec!(50.00));
573 }
574
575 #[test]
576 fn test_neg() {
577 let a = Amount::new(dec!(100.00), "USD");
578 let neg_a = -&a;
579 assert_eq!(neg_a.number, dec!(-100.00));
580 }
581
582 #[test]
583 fn test_add_assign() {
584 let mut a = Amount::new(dec!(100.00), "USD");
585 let b = Amount::new(dec!(50.00), "USD");
586 a += &b;
587 assert_eq!(a.number, dec!(150.00));
588 }
589
590 #[test]
591 fn test_inferred_tolerance() {
592 let a = Amount::new(dec!(100), "USD");
594 assert_eq!(a.inferred_tolerance(), dec!(0.5));
595
596 let b = Amount::new(dec!(100.00), "USD");
598 assert_eq!(b.inferred_tolerance(), dec!(0.005));
599
600 let c = Amount::new(dec!(100.000), "USD");
602 assert_eq!(c.inferred_tolerance(), dec!(0.0005));
603 }
604
605 #[test]
606 fn test_is_near_zero() {
607 let a = Amount::new(dec!(0.004), "USD");
608 assert!(a.is_near_zero(dec!(0.005)));
609 assert!(!a.is_near_zero(dec!(0.003)));
610 }
611
612 #[test]
613 fn test_is_near() {
614 let a = Amount::new(dec!(100.00), "USD");
615 let b = Amount::new(dec!(100.004), "USD");
616 assert!(a.is_near(&b, dec!(0.005)));
617 assert!(!a.is_near(&b, dec!(0.003)));
618
619 let c = Amount::new(dec!(100.00), "EUR");
621 assert!(!a.is_near(&c, dec!(1.0)));
622 }
623
624 #[test]
625 fn test_display() {
626 let a = Amount::new(dec!(1234.56), "USD");
627 assert_eq!(format!("{a}"), "1234.56 USD");
628 }
629
630 #[test]
631 fn test_abs() {
632 let neg = Amount::new(dec!(-100.00), "USD");
633 let abs = neg.abs();
634 assert_eq!(abs.number, dec!(100.00));
635 }
636
637 #[test]
638 fn test_eq_with_tolerance() {
639 let a = Amount::new(dec!(100.00), "USD");
640 let b = Amount::new(dec!(100.004), "USD");
641
642 assert!(a.eq_with_tolerance(&b, dec!(0.005)));
644 assert!(b.eq_with_tolerance(&a, dec!(0.005)));
645
646 assert!(!a.eq_with_tolerance(&b, dec!(0.003)));
648
649 let c = Amount::new(dec!(100.00), "EUR");
651 assert!(!a.eq_with_tolerance(&c, dec!(1.0)));
652
653 let d = Amount::new(dec!(100.00), "USD");
655 assert!(a.eq_with_tolerance(&d, dec!(0.0)));
656 }
657
658 #[test]
659 #[allow(clippy::many_single_char_names)]
660 fn test_eq_auto_tolerance() {
661 let a = Amount::new(dec!(100.00), "USD");
663 let b = Amount::new(dec!(100.004), "USD");
664
665 assert!(a.eq_auto_tolerance(&b));
667
668 let c = Amount::new(dec!(100.000), "USD");
670 let d = Amount::new(dec!(100.001), "USD");
671
672 assert!(!c.eq_auto_tolerance(&d));
674
675 let e = Amount::new(dec!(100.0004), "USD");
677 assert!(c.eq_auto_tolerance(&e)); let f = Amount::new(dec!(100.00), "EUR");
681 assert!(!a.eq_auto_tolerance(&f));
682 }
683
684 use std::str::FromStr;
687
688 #[test]
689 fn amount_from_str_round_trips_display() {
690 for amt in [
695 Amount::new(dec!(100), "USD"),
696 Amount::new(dec!(-50.25), "EUR"),
697 Amount::new(dec!(0), "GBP"),
698 Amount::new(dec!(1234567.89), "JPY"),
699 Amount::new(dec!(0.0001), "USD"),
700 ] {
701 let displayed = amt.to_string();
702 assert_eq!(
703 Amount::from_str(&displayed),
704 Ok(amt.clone()),
705 "round-trip lost data: Display produced {displayed:?}"
706 );
707 }
708 }
709
710 #[test]
711 fn amount_from_str_accepts_canonical_forms() {
712 assert_eq!(
713 Amount::from_str("100 USD"),
714 Ok(Amount::new(dec!(100), "USD"))
715 );
716 assert_eq!(
717 Amount::from_str("-50.25 EUR"),
718 Ok(Amount::new(dec!(-50.25), "EUR"))
719 );
720 assert_eq!(
723 Amount::from_str(" 100 USD "),
724 Ok(Amount::new(dec!(100), "USD"))
725 );
726 assert_eq!(Amount::from_str("1 X"), Ok(Amount::new(dec!(1), "X")));
728 assert_eq!(
731 Amount::from_str("100 RY-2024"),
732 Ok(Amount::new(dec!(100), "RY-2024"))
733 );
734 }
735
736 #[test]
737 fn amount_from_str_rejects_currency_first() {
738 let err = Amount::from_str("USD 100").expect_err("currency-first must reject");
743 assert!(matches!(
744 err.reason,
745 AmountParseErrorReason::InvalidNumber(_)
746 ));
747 }
748
749 #[test]
750 fn amount_from_str_rejects_single_token() {
751 for s in ["", " ", "100", "USD"] {
752 let err = Amount::from_str(s).expect_err("single token must reject");
753 assert!(
754 matches!(err.reason, AmountParseErrorReason::NotTwoTokens),
755 "expected NotTwoTokens for {s:?}, got {:?}",
756 err.reason
757 );
758 }
759 }
760
761 #[test]
762 fn amount_from_str_rejects_extra_tokens() {
763 let err = Amount::from_str("100 USD extra").expect_err("trailing token must reject");
764 assert!(matches!(err.reason, AmountParseErrorReason::NotTwoTokens));
765 }
766
767 #[test]
768 fn amount_from_str_rejects_scientific_notation() {
769 let err = Amount::from_str("1e2 USD").expect_err("scientific must reject");
773 assert!(matches!(
774 err.reason,
775 AmountParseErrorReason::InvalidNumber(_)
776 ));
777 }
778
779 #[test]
780 fn amount_from_str_rejects_thousands_separator() {
781 let err = Amount::from_str("1,000 USD").expect_err("thousands sep must reject");
782 assert!(matches!(
783 err.reason,
784 AmountParseErrorReason::InvalidNumber(_)
785 ));
786 }
787
788 #[test]
789 fn amount_from_str_rejects_lowercase_currency() {
790 let err = Amount::from_str("100 usd").expect_err("lowercase commodity must reject");
791 assert!(matches!(
792 err.reason,
793 AmountParseErrorReason::InvalidCurrency(_)
794 ));
795 }
796
797 #[test]
798 fn amount_from_str_rejects_currency_starting_with_digit() {
799 let err = Amount::from_str("100 1USD").expect_err("digit-first commodity must reject");
801 assert!(matches!(
802 err.reason,
803 AmountParseErrorReason::InvalidCurrency(_)
804 ));
805 }
806
807 #[test]
808 fn amount_from_str_error_message_names_input() {
809 let err = Amount::from_str("oopsie daisy").unwrap_err();
812 let msg = err.to_string();
813 assert!(msg.contains("oopsie daisy"), "error must echo input: {msg}");
814 assert!(msg.contains("doesn't parse"), "error must explain: {msg}");
815 }
816}