1use std::cmp::Ordering;
8use std::fmt;
9use std::str::FromStr;
10
11macro_rules! currencies {
20 ($($(#[$meta:meta])* $variant:ident => $code:literal, $numeric:literal, $exponent:literal;)*) => {
21 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
55 pub enum Currency {
56 $($(#[$meta])* $variant,)*
57 }
58
59 impl Currency {
60 pub const KNOWN: &'static [Self] = &[$(Self::$variant,)*];
66
67 #[must_use]
69 pub const fn code(self) -> &'static str {
70 match self {
71 $(Self::$variant => $code,)*
72 }
73 }
74
75 #[must_use]
81 pub const fn numeric(self) -> &'static str {
82 match self {
83 $(Self::$variant => $numeric,)*
84 }
85 }
86
87 #[must_use]
89 pub const fn exponent(self) -> u32 {
90 match self {
91 $(Self::$variant => $exponent,)*
92 }
93 }
94
95 fn from_alpha(code: &str) -> Option<Self> {
97 match code {
98 $($code => Some(Self::$variant),)*
99 _ => None,
100 }
101 }
102
103 fn from_numeric(code: &str) -> Option<Self> {
105 match code {
106 $($numeric => Some(Self::$variant),)*
107 _ => None,
108 }
109 }
110 }
111 };
112}
113
114currencies! {
115 Aed => "AED", "784", 2;
117 Afn => "AFN", "971", 2;
119 All => "ALL", "008", 2;
121 Amd => "AMD", "051", 2;
123 Ang => "ANG", "532", 2;
125 Aoa => "AOA", "973", 2;
127 Ars => "ARS", "032", 2;
129 Aud => "AUD", "036", 2;
131 Awg => "AWG", "533", 2;
133 Azn => "AZN", "944", 2;
135 Bam => "BAM", "977", 2;
137 Bbd => "BBD", "052", 2;
139 Bdt => "BDT", "050", 2;
141 Bgn => "BGN", "975", 2;
143 Bmd => "BMD", "060", 2;
145 Bnd => "BND", "096", 2;
147 Bob => "BOB", "068", 2;
149 Brl => "BRL", "986", 2;
151 Bsd => "BSD", "044", 2;
153 Bwp => "BWP", "072", 2;
155 Byn => "BYN", "933", 2;
157 Bzd => "BZD", "084", 2;
159 Cad => "CAD", "124", 2;
161 Cdf => "CDF", "976", 2;
163 Chf => "CHF", "756", 2;
165 Cny => "CNY", "156", 2;
167 Cop => "COP", "170", 2;
169 Crc => "CRC", "188", 2;
171 Cve => "CVE", "132", 2;
173 Czk => "CZK", "203", 2;
175 Dkk => "DKK", "208", 2;
177 Dop => "DOP", "214", 2;
179 Dzd => "DZD", "012", 2;
181 Egp => "EGP", "818", 2;
183 Etb => "ETB", "230", 2;
185 Eur => "EUR", "978", 2;
187 Fjd => "FJD", "242", 2;
189 Fkp => "FKP", "238", 2;
191 Gbp => "GBP", "826", 2;
193 Gel => "GEL", "981", 2;
195 Gip => "GIP", "292", 2;
197 Gmd => "GMD", "270", 2;
199 Gtq => "GTQ", "320", 2;
201 Gyd => "GYD", "328", 2;
203 Hkd => "HKD", "344", 2;
205 Hnl => "HNL", "340", 2;
207 Hrk => "HRK", "191", 2;
209 Htg => "HTG", "332", 2;
211 Huf => "HUF", "348", 2;
213 Idr => "IDR", "360", 2;
215 Ils => "ILS", "376", 2;
217 Inr => "INR", "356", 2;
219 Jmd => "JMD", "388", 2;
221 Jpy => "JPY", "392", 0;
223 Kes => "KES", "404", 2;
225 Kgs => "KGS", "417", 2;
227 Khr => "KHR", "116", 2;
229 Kwd => "KWD", "414", 3;
231 Kyd => "KYD", "136", 2;
233 Kzt => "KZT", "398", 2;
235 Lak => "LAK", "418", 2;
237 Lbp => "LBP", "422", 2;
239 Lkr => "LKR", "144", 2;
241 Lrd => "LRD", "430", 2;
243 Lsl => "LSL", "426", 2;
245 Mad => "MAD", "504", 2;
247 Mdl => "MDL", "498", 2;
249 Mkd => "MKD", "807", 2;
251 Mmk => "MMK", "104", 2;
253 Mnt => "MNT", "496", 2;
255 Mop => "MOP", "446", 2;
257 Mur => "MUR", "480", 2;
259 Mvr => "MVR", "462", 2;
261 Mwk => "MWK", "454", 2;
263 Mxn => "MXN", "484", 2;
265 Myr => "MYR", "458", 2;
267 Mzn => "MZN", "943", 2;
269 Nad => "NAD", "516", 2;
271 Ngn => "NGN", "566", 2;
273 Nio => "NIO", "558", 2;
275 Nok => "NOK", "578", 2;
277 Npr => "NPR", "524", 2;
279 Nzd => "NZD", "554", 2;
281 Pab => "PAB", "590", 2;
283 Pen => "PEN", "604", 2;
285 Pgk => "PGK", "598", 2;
287 Php => "PHP", "608", 2;
289 Pkr => "PKR", "586", 2;
291 Pln => "PLN", "985", 2;
293 Qar => "QAR", "634", 2;
295 Ron => "RON", "946", 2;
297 Rsd => "RSD", "941", 2;
299 Rub => "RUB", "643", 2;
301 Sar => "SAR", "682", 2;
303 Sbd => "SBD", "090", 2;
305 Scr => "SCR", "690", 2;
307 Sek => "SEK", "752", 2;
309 Sgd => "SGD", "702", 2;
311 Shp => "SHP", "654", 2;
313 Sos => "SOS", "706", 2;
315 Srd => "SRD", "968", 2;
317 Svc => "SVC", "222", 2;
319 Szl => "SZL", "748", 2;
321 Thb => "THB", "764", 2;
323 Tjs => "TJS", "972", 2;
325 Top => "TOP", "776", 2;
327 Try => "TRY", "949", 2;
329 Ttd => "TTD", "780", 2;
331 Twd => "TWD", "901", 2;
333 Tzs => "TZS", "834", 2;
335 Uah => "UAH", "980", 2;
337 Usd => "USD", "840", 2;
339 Uyu => "UYU", "858", 2;
341 Uzs => "UZS", "860", 2;
343 Wst => "WST", "882", 2;
345 Xcd => "XCD", "951", 2;
347 Yer => "YER", "886", 2;
349 Zar => "ZAR", "710", 2;
351 Zmw => "ZMW", "967", 2;
353}
354
355impl fmt::Display for Currency {
356 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
357 f.write_str(self.code())
358 }
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
363#[error("unsupported currency code: {0}")]
364pub struct UnknownCurrency(pub String);
365
366impl FromStr for Currency {
367 type Err = UnknownCurrency;
368
369 fn from_str(s: &str) -> Result<Self, Self::Err> {
378 let trimmed = s.trim();
379 let unknown = || UnknownCurrency(s.to_owned());
380 if !trimmed.is_empty() && trimmed.bytes().all(|b| b.is_ascii_digit()) {
381 let digits = trimmed.trim_start_matches('0');
382 if digits.len() > 3 {
383 return Err(unknown());
384 }
385 let mut padded = String::from("000");
386 padded.truncate(3 - digits.len());
387 padded.push_str(digits);
388 return Self::from_numeric(&padded).ok_or_else(unknown);
389 }
390 Self::from_alpha(&trimmed.to_ascii_uppercase()).ok_or_else(unknown)
391 }
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub struct Money {
397 minor_units: i64,
398 currency: Currency,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
403pub enum MoneyError {
404 #[error("`{0}` is not a decimal amount")]
406 NotDecimal(String),
407 #[error("`{value}` has more than {exponent} decimal places for {currency}")]
409 TooPrecise {
410 value: String,
412 currency: Currency,
414 exponent: u32,
416 },
417 #[error("`{0}` does not fit in 64 bits of minor units")]
419 Overflow(String),
420 #[error("amount must be positive, got {0}")]
422 NotPositive(i64),
423 #[error("cannot combine {left} with {right}")]
425 CurrencyMismatch {
426 left: Currency,
428 right: Currency,
430 },
431}
432
433impl Money {
434 #[must_use]
436 pub const fn from_minor_units(minor_units: i64, currency: Currency) -> Self {
437 Self {
438 minor_units,
439 currency,
440 }
441 }
442
443 pub fn parse(value: &str, currency: Currency) -> Result<Self, MoneyError> {
445 let text = value.trim();
446 let (sign, digits) = match text.strip_prefix('-') {
447 Some(rest) => (-1i64, rest),
448 None => (1i64, text.strip_prefix('+').unwrap_or(text)),
449 };
450 let (whole, frac) = match digits.split_once('.') {
451 Some((w, f)) => (w, f),
452 None => (digits, ""),
453 };
454 let numeric = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
455 if !numeric(whole) || (!frac.is_empty() && !numeric(frac)) {
456 return Err(MoneyError::NotDecimal(value.to_owned()));
457 }
458 let exponent = currency.exponent();
459 let places = u32::try_from(frac.len()).unwrap_or(u32::MAX);
460 if places > exponent {
461 return Err(MoneyError::TooPrecise {
462 value: value.to_owned(),
463 currency,
464 exponent,
465 });
466 }
467 let mut padded = String::with_capacity(whole.len() + frac.len() + 1);
468 padded.push_str(whole);
469 padded.push_str(frac);
470 for _ in 0..(exponent - places) {
471 padded.push('0');
472 }
473 let minor_units: i64 = padded
474 .parse()
475 .map_err(|_| MoneyError::Overflow(value.to_owned()))?;
476 Ok(Self {
477 minor_units: sign * minor_units,
478 currency,
479 })
480 }
481
482 #[must_use]
484 pub const fn minor_units(self) -> i64 {
485 self.minor_units
486 }
487
488 #[must_use]
490 pub const fn currency(self) -> Currency {
491 self.currency
492 }
493
494 pub fn require_positive(self) -> Result<Self, MoneyError> {
496 if self.minor_units > 0 {
497 Ok(self)
498 } else {
499 Err(MoneyError::NotPositive(self.minor_units))
500 }
501 }
502
503 pub fn checked_add(self, other: Self) -> Result<Self, MoneyError> {
508 self.same_currency(other)?;
509 self.minor_units
510 .checked_add(other.minor_units)
511 .map(|minor_units| Self {
512 minor_units,
513 currency: self.currency,
514 })
515 .ok_or_else(|| MoneyError::Overflow(format!("{self} + {other}")))
516 }
517
518 pub fn checked_sub(self, other: Self) -> Result<Self, MoneyError> {
524 self.same_currency(other)?;
525 self.minor_units
526 .checked_sub(other.minor_units)
527 .map(|minor_units| Self {
528 minor_units,
529 currency: self.currency,
530 })
531 .ok_or_else(|| MoneyError::Overflow(format!("{self} - {other}")))
532 }
533
534 pub fn checked_mul(self, count: u32) -> Result<Self, MoneyError> {
539 self.minor_units
540 .checked_mul(i64::from(count))
541 .map(|minor_units| Self {
542 minor_units,
543 currency: self.currency,
544 })
545 .ok_or_else(|| MoneyError::Overflow(format!("{self} x {count}")))
546 }
547
548 #[must_use]
550 pub const fn is_zero(self) -> bool {
551 self.minor_units == 0
552 }
553
554 fn same_currency(self, other: Self) -> Result<(), MoneyError> {
555 if self.currency == other.currency {
556 Ok(())
557 } else {
558 Err(MoneyError::CurrencyMismatch {
559 left: self.currency,
560 right: other.currency,
561 })
562 }
563 }
564
565 #[must_use]
570 pub fn to_decimal_string(self) -> String {
571 let exponent = self.currency.exponent();
572 let scale = 10u64.pow(exponent);
573 let sign = if self.minor_units < 0 { "-" } else { "" };
574 let magnitude = self.minor_units.unsigned_abs();
575 if exponent == 0 {
576 return format!("{sign}{magnitude}");
577 }
578 format!(
579 "{sign}{}.{:0>width$}",
580 magnitude / scale,
581 magnitude % scale,
582 width = usize::try_from(exponent).unwrap_or(usize::MAX)
583 )
584 }
585}
586
587impl PartialOrd for Money {
594 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
595 (self.currency == other.currency).then(|| self.minor_units.cmp(&other.minor_units))
596 }
597}
598
599impl fmt::Display for Money {
600 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
601 write!(f, "{} {}", self.to_decimal_string(), self.currency)
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use std::cmp::Ordering;
608
609 use super::{Currency, Money, MoneyError};
610
611 #[test]
612 fn parses_and_renders_a_two_place_amount() {
613 let money = Money::parse("10.50", Currency::Try).expect("valid amount");
614 assert_eq!(money.minor_units(), 1050);
615 assert_eq!(money.to_decimal_string(), "10.50");
616 }
617
618 #[test]
619 fn pads_a_missing_fractional_part() {
620 assert_eq!(
621 Money::parse("7", Currency::Usd)
622 .expect("valid")
623 .minor_units(),
624 700
625 );
626 assert_eq!(
627 Money::parse("7.5", Currency::Usd)
628 .expect("valid")
629 .minor_units(),
630 750
631 );
632 }
633
634 #[test]
635 fn renders_amounts_below_one_with_a_leading_zero() {
636 let money = Money::from_minor_units(5, Currency::Try);
637 assert_eq!(money.to_decimal_string(), "0.05");
638 }
639
640 #[test]
641 fn rejects_more_precision_than_the_currency_has() {
642 let err = Money::parse("10.505", Currency::Try).expect_err("too precise");
643 assert!(matches!(err, MoneyError::TooPrecise { .. }));
644 }
645
646 #[test]
647 fn rejects_text_that_is_not_a_number() {
648 assert!(matches!(
649 Money::parse("ten", Currency::Try),
650 Err(MoneyError::NotDecimal(_))
651 ));
652 assert!(matches!(
653 Money::parse("", Currency::Try),
654 Err(MoneyError::NotDecimal(_))
655 ));
656 assert!(matches!(
657 Money::parse("1.2.3", Currency::Try),
658 Err(MoneyError::NotDecimal(_))
659 ));
660 }
661
662 #[test]
663 fn a_currency_with_no_minor_unit_never_grows_a_decimal_point() {
664 let money = Money::parse("1200", Currency::Jpy).expect("valid amount");
665 assert_eq!(money.minor_units(), 1200);
666 assert_eq!(money.to_decimal_string(), "1200");
667 assert!(matches!(
668 Money::parse("1200.50", Currency::Jpy),
669 Err(MoneyError::TooPrecise { .. })
670 ));
671 }
672
673 #[test]
674 fn a_numeric_iso_code_reads_as_the_currency_it_names() {
675 assert_eq!("0949".parse(), Ok(Currency::Try));
677 assert_eq!("949".parse(), Ok(Currency::Try));
678 assert_eq!("643".parse(), Ok(Currency::Rub));
679 assert_eq!("TRY".parse(), Ok(Currency::Try));
680 assert_eq!("try".parse(), Ok(Currency::Try));
681 }
682
683 #[test]
684 fn a_number_that_names_no_currency_is_refused_rather_than_guessed() {
685 assert!("999".parse::<Currency>().is_err());
686 assert!("0".parse::<Currency>().is_err());
687 assert!("94".parse::<Currency>().is_err());
688 assert!("9X9".parse::<Currency>().is_err());
690 }
691
692 #[test]
693 fn a_three_place_currency_keeps_all_three() {
694 let money = Money::parse("1.500", Currency::Kwd).expect("valid amount");
695 assert_eq!(money.minor_units(), 1500);
696 assert_eq!(money.to_decimal_string(), "1.500");
697 assert_eq!(
698 Money::parse("1.5", Currency::Kwd)
699 .expect("valid amount")
700 .minor_units(),
701 1500
702 );
703 assert!(matches!(
704 Money::parse("1.5005", Currency::Kwd),
705 Err(MoneyError::TooPrecise { .. })
706 ));
707 }
708
709 const SHIPPED_WITH: &[Currency] = &[
712 Currency::Try,
713 Currency::Usd,
714 Currency::Eur,
715 Currency::Gbp,
716 Currency::Jpy,
717 Currency::Kwd,
718 Currency::Rub,
719 Currency::Chf,
720 Currency::Nok,
721 ];
722
723 #[test]
731 fn nothing_but_the_nine_it_shipped_with_has_an_unusual_minor_unit() {
732 for currency in Currency::KNOWN.iter().copied() {
733 if SHIPPED_WITH.contains(¤cy) {
734 continue;
735 }
736 assert_eq!(
737 currency.exponent(),
738 2,
739 "{currency} has {} decimal places and no reading to say whose",
740 currency.exponent()
741 );
742 }
743 }
744
745 #[test]
746 fn every_currency_reads_back_from_both_of_its_codes() {
747 for currency in Currency::KNOWN.iter().copied() {
748 assert_eq!(
749 currency.code().parse::<Currency>().expect("its own code"),
750 currency
751 );
752 assert_eq!(
753 currency
754 .numeric()
755 .parse::<Currency>()
756 .expect("its own code"),
757 currency
758 );
759 assert_eq!(
762 format!("0{}", currency.numeric())
763 .parse::<Currency>()
764 .expect("padded"),
765 currency
766 );
767 assert_eq!(
768 currency
769 .numeric()
770 .trim_start_matches('0')
771 .parse::<Currency>()
772 .expect("unpadded"),
773 currency
774 );
775 }
776 }
777
778 #[test]
781 fn no_two_currencies_share_a_code() {
782 let mut alpha: Vec<&str> = Currency::KNOWN.iter().map(|c| c.code()).collect();
783 let mut numeric: Vec<&str> = Currency::KNOWN.iter().map(|c| c.numeric()).collect();
784 let total = Currency::KNOWN.len();
785 alpha.sort_unstable();
786 alpha.dedup();
787 numeric.sort_unstable();
788 numeric.dedup();
789 assert_eq!(
790 alpha.len(),
791 total,
792 "two currencies share an alphabetic code"
793 );
794 assert_eq!(numeric.len(), total, "two currencies share a numeric code");
795 }
796
797 #[test]
798 fn every_code_is_the_shape_iso_writes_it_in() {
799 for currency in Currency::KNOWN.iter().copied() {
800 let code = currency.code();
801 assert!(
802 code.len() == 3 && code.bytes().all(|b| b.is_ascii_uppercase()),
803 "{code} is not three uppercase letters"
804 );
805 let numeric = currency.numeric();
806 assert!(
807 numeric.len() == 3 && numeric.bytes().all(|b| b.is_ascii_digit()),
808 "{numeric} is not three digits"
809 );
810 }
811 }
812
813 #[test]
814 fn round_trips_through_its_decimal_form() {
815 for currency in [
816 Currency::Try,
817 Currency::Usd,
818 Currency::Eur,
819 Currency::Gbp,
820 Currency::Jpy,
821 Currency::Kwd,
822 ] {
823 for minor in [1i64, 5, 99, 100, 101, 1050, 123_456_789] {
824 let money = Money::from_minor_units(minor, currency);
825 let back = Money::parse(&money.to_decimal_string(), currency).expect("valid");
826 assert_eq!(back, money);
827 }
828 }
829 }
830
831 #[test]
832 fn amounts_in_one_currency_add_and_subtract() {
833 let ten = Money::parse("10.00", Currency::Try).expect("valid");
834 let three = Money::parse("3.50", Currency::Try).expect("valid");
835 assert_eq!(
836 ten.checked_add(three).expect("same currency"),
837 Money::parse("13.50", Currency::Try).expect("valid")
838 );
839 assert_eq!(
840 ten.checked_sub(three).expect("same currency"),
841 Money::parse("6.50", Currency::Try).expect("valid")
842 );
843 }
844
845 #[test]
846 fn combining_two_currencies_is_an_error_rather_than_a_sum() {
847 let lira = Money::parse("10.00", Currency::Try).expect("valid");
848 let dollars = Money::parse("10.00", Currency::Usd).expect("valid");
849 assert!(matches!(
850 lira.checked_add(dollars),
851 Err(MoneyError::CurrencyMismatch {
852 left: Currency::Try,
853 right: Currency::Usd,
854 })
855 ));
856 assert!(lira.checked_sub(dollars).is_err());
857 }
858
859 #[test]
860 #[expect(
861 clippy::neg_cmp_op_on_partial_ord,
862 reason = "asserting that both directions are false is the whole test"
863 )]
864 fn two_currencies_have_no_order_in_either_direction() {
865 let lira = Money::parse("10.00", Currency::Try).expect("valid");
866 let dollars = Money::parse("10.00", Currency::Usd).expect("valid");
867 assert!(lira.partial_cmp(&dollars).is_none());
868 assert!(!(lira < dollars));
871 assert!(!(lira >= dollars));
872 assert_ne!(lira, dollars);
873 }
874
875 #[test]
876 fn one_currency_orders_by_amount() {
877 let small = Money::parse("3.50", Currency::Try).expect("valid");
878 let large = Money::parse("10.00", Currency::Try).expect("valid");
879 assert!(small < large);
880 assert!(large >= small);
881 assert_eq!(small.partial_cmp(&large), Some(Ordering::Less));
882 }
885
886 #[test]
887 fn subtracting_past_zero_is_negative_and_still_refused_where_it_matters() {
888 let three = Money::parse("3.50", Currency::Try).expect("valid");
889 let ten = Money::parse("10.00", Currency::Try).expect("valid");
890 let owed = three.checked_sub(ten).expect("same currency");
891 assert_eq!(owed.minor_units(), -650);
892 assert_eq!(owed.to_decimal_string(), "-6.50");
893 assert!(owed.require_positive().is_err());
894 }
895
896 #[test]
897 fn overflow_is_an_error_rather_than_a_wrap() {
898 let huge = Money::from_minor_units(i64::MAX, Currency::Try);
899 let one = Money::from_minor_units(1, Currency::Try);
900 assert!(matches!(
901 huge.checked_add(one),
902 Err(MoneyError::Overflow(_))
903 ));
904 let lowest = Money::from_minor_units(i64::MIN, Currency::Try);
905 assert!(matches!(
906 lowest.checked_sub(one),
907 Err(MoneyError::Overflow(_))
908 ));
909 }
910
911 #[test]
912 fn zero_knows_itself() {
913 assert!(Money::from_minor_units(0, Currency::Try).is_zero());
914 assert!(!Money::from_minor_units(-1, Currency::Try).is_zero());
915 }
916
917 #[test]
918 fn require_positive_rejects_zero() {
919 let zero = Money::from_minor_units(0, Currency::Try);
920 assert!(matches!(
921 zero.require_positive(),
922 Err(MoneyError::NotPositive(0))
923 ));
924 }
925}