Skip to main content

kasapay_core/
money.rs

1//! Amounts and currencies.
2//!
3//! An amount is held as an integer count of a currency's minor unit — 1050
4//! kuruş rather than 10.50 TRY — because that is what every provider settles
5//! in and because binary floating point cannot hold 10.10 exactly.
6
7use std::cmp::Ordering;
8use std::fmt;
9use std::str::FromStr;
10
11/// Builds [`Currency`] and its four tables from one list.
12///
13/// A macro rather than four hand-kept `match` blocks because there are more
14/// than a hundred entries and four tables that must agree: a code, an ISO
15/// numeric code, a minor-unit exponent, and the two readings back. Kept by
16/// hand, one of them drifts, and the one that matters is the exponent — an
17/// amount read at the wrong number of decimal places is out by a factor of a
18/// hundred.
19macro_rules! currencies {
20    ($($(#[$meta:meta])* $variant:ident => $code:literal, $numeric:literal, $exponent:literal;)*) => {
21        /// A currency kasapay knows how to move money in.
22        ///
23        /// # What is here, and what is not
24        ///
25        /// A currency is named here when ISO 4217 currently defines it, its
26        /// minor unit is **exactly two decimal places**, and at least one
27        /// provider in this workspace settles in it — plus the nine this
28        /// library shipped with, whatever their exponent.
29        ///
30        /// The two-decimal rule is the safety rule rather than a tidiness one.
31        /// Two decimals is the only convention nobody disagrees about. The
32        /// zero- and three-decimal currencies are exactly where a provider's
33        /// reading and ISO's diverge: Stripe treats Icelandic króna as having
34        /// no minor unit and requires its three-decimal currencies to arrive
35        /// as a multiple of ten, and Malagasy ariary is a fifth rather than a
36        /// hundredth of its unit. Each of those needs a reading of that
37        /// provider's own documentation before it can be named, and being
38        /// wrong about one is a payment out by a factor of a hundred. The two
39        /// already here — yen and Kuwaiti dinar — have had that reading.
40        ///
41        /// # It is still exhaustive, and a match may still not guess
42        ///
43        /// Adding one is still a breaking change. What changed is what an
44        /// adapter must do about it: a currency match may carry a wildcard arm
45        /// **only where that arm refuses**. Mapping an unknown currency onto
46        /// something is the thing that was never allowed; refusing it before a
47        /// socket opens is the thing this type exists for, and
48        /// `crates/kasapay/tests/conformance.rs` walks every variant here past
49        /// every adapter to prove each one does one or the other.
50        ///
51        /// The list came from ISO 4217 as the `iso-codes` dataset publishes
52        /// it, intersected with the currencies `async-stripe` names, on
53        /// 2026-08-19.
54        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
55        pub enum Currency {
56            $($(#[$meta])* $variant,)*
57        }
58
59        impl Currency {
60            /// Every currency this library names, in code order.
61            ///
62            /// What a test walks to ask whether an adapter has an answer for
63            /// each one. A caller does not usually want this: the currency a
64            /// payment is in comes from the order, not from a list.
65            pub const KNOWN: &'static [Self] = &[$(Self::$variant,)*];
66
67            /// The ISO 4217 alphabetic code, uppercase.
68            #[must_use]
69            pub const fn code(self) -> &'static str {
70                match self {
71                    $(Self::$variant => $code,)*
72                }
73            }
74
75            /// The ISO 4217 numeric code, three digits, zero-padded.
76            ///
77            /// The standard defines this alongside the alphabetic one and some
78            /// providers answer with it: iyzico's In-Store API reports lira as
79            /// `0949`.
80            #[must_use]
81            pub const fn numeric(self) -> &'static str {
82                match self {
83                    $(Self::$variant => $numeric,)*
84                }
85            }
86
87            /// How many decimal places the currency's minor unit sits at.
88            #[must_use]
89            pub const fn exponent(self) -> u32 {
90                match self {
91                    $(Self::$variant => $exponent,)*
92                }
93            }
94
95            /// Reads an uppercase alphabetic code.
96            fn from_alpha(code: &str) -> Option<Self> {
97                match code {
98                    $($code => Some(Self::$variant),)*
99                    _ => None,
100                }
101            }
102
103            /// Reads a numeric code already padded to three digits.
104            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    /// UAE Dirham.
116    Aed => "AED", "784", 2;
117    /// Afghani.
118    Afn => "AFN", "971", 2;
119    /// Lek.
120    All => "ALL", "008", 2;
121    /// Armenian Dram.
122    Amd => "AMD", "051", 2;
123    /// Netherlands Antillean Guilder.
124    Ang => "ANG", "532", 2;
125    /// Kwanza.
126    Aoa => "AOA", "973", 2;
127    /// Argentine Peso.
128    Ars => "ARS", "032", 2;
129    /// Australian Dollar.
130    Aud => "AUD", "036", 2;
131    /// Aruban Florin.
132    Awg => "AWG", "533", 2;
133    /// Azerbaijan Manat.
134    Azn => "AZN", "944", 2;
135    /// Convertible Mark.
136    Bam => "BAM", "977", 2;
137    /// Barbados Dollar.
138    Bbd => "BBD", "052", 2;
139    /// Taka.
140    Bdt => "BDT", "050", 2;
141    /// Bulgarian Lev.
142    Bgn => "BGN", "975", 2;
143    /// Bermudian Dollar.
144    Bmd => "BMD", "060", 2;
145    /// Brunei Dollar.
146    Bnd => "BND", "096", 2;
147    /// Boliviano.
148    Bob => "BOB", "068", 2;
149    /// Brazilian Real.
150    Brl => "BRL", "986", 2;
151    /// Bahamian Dollar.
152    Bsd => "BSD", "044", 2;
153    /// Pula.
154    Bwp => "BWP", "072", 2;
155    /// Belarusian Ruble.
156    Byn => "BYN", "933", 2;
157    /// Belize Dollar.
158    Bzd => "BZD", "084", 2;
159    /// Canadian Dollar.
160    Cad => "CAD", "124", 2;
161    /// Congolese Franc.
162    Cdf => "CDF", "976", 2;
163    /// Swiss Franc.
164    Chf => "CHF", "756", 2;
165    /// Yuan Renminbi.
166    Cny => "CNY", "156", 2;
167    /// Colombian Peso.
168    Cop => "COP", "170", 2;
169    /// Costa Rican Colon.
170    Crc => "CRC", "188", 2;
171    /// Cabo Verde Escudo.
172    Cve => "CVE", "132", 2;
173    /// Czech Koruna.
174    Czk => "CZK", "203", 2;
175    /// Danish Krone.
176    Dkk => "DKK", "208", 2;
177    /// Dominican Peso.
178    Dop => "DOP", "214", 2;
179    /// Algerian Dinar.
180    Dzd => "DZD", "012", 2;
181    /// Egyptian Pound.
182    Egp => "EGP", "818", 2;
183    /// Ethiopian Birr.
184    Etb => "ETB", "230", 2;
185    /// Euro.
186    Eur => "EUR", "978", 2;
187    /// Fiji Dollar.
188    Fjd => "FJD", "242", 2;
189    /// Falkland Islands Pound.
190    Fkp => "FKP", "238", 2;
191    /// Pound Sterling.
192    Gbp => "GBP", "826", 2;
193    /// Lari.
194    Gel => "GEL", "981", 2;
195    /// Gibraltar Pound.
196    Gip => "GIP", "292", 2;
197    /// Dalasi.
198    Gmd => "GMD", "270", 2;
199    /// Quetzal.
200    Gtq => "GTQ", "320", 2;
201    /// Guyana Dollar.
202    Gyd => "GYD", "328", 2;
203    /// Hong Kong Dollar.
204    Hkd => "HKD", "344", 2;
205    /// Lempira.
206    Hnl => "HNL", "340", 2;
207    /// Kuna.
208    Hrk => "HRK", "191", 2;
209    /// Gourde.
210    Htg => "HTG", "332", 2;
211    /// Forint.
212    Huf => "HUF", "348", 2;
213    /// Rupiah.
214    Idr => "IDR", "360", 2;
215    /// New Israeli Sheqel.
216    Ils => "ILS", "376", 2;
217    /// Indian Rupee.
218    Inr => "INR", "356", 2;
219    /// Jamaican Dollar.
220    Jmd => "JMD", "388", 2;
221    /// Japanese yen, which has no minor unit at all.
222    Jpy => "JPY", "392", 0;
223    /// Kenyan Shilling.
224    Kes => "KES", "404", 2;
225    /// Som.
226    Kgs => "KGS", "417", 2;
227    /// Riel.
228    Khr => "KHR", "116", 2;
229    /// Kuwaiti dinar, whose minor unit is a thousandth.
230    Kwd => "KWD", "414", 3;
231    /// Cayman Islands Dollar.
232    Kyd => "KYD", "136", 2;
233    /// Tenge.
234    Kzt => "KZT", "398", 2;
235    /// Lao Kip.
236    Lak => "LAK", "418", 2;
237    /// Lebanese Pound.
238    Lbp => "LBP", "422", 2;
239    /// Sri Lanka Rupee.
240    Lkr => "LKR", "144", 2;
241    /// Liberian Dollar.
242    Lrd => "LRD", "430", 2;
243    /// Loti.
244    Lsl => "LSL", "426", 2;
245    /// Moroccan Dirham.
246    Mad => "MAD", "504", 2;
247    /// Moldovan Leu.
248    Mdl => "MDL", "498", 2;
249    /// Denar.
250    Mkd => "MKD", "807", 2;
251    /// Kyat.
252    Mmk => "MMK", "104", 2;
253    /// Tugrik.
254    Mnt => "MNT", "496", 2;
255    /// Pataca.
256    Mop => "MOP", "446", 2;
257    /// Mauritius Rupee.
258    Mur => "MUR", "480", 2;
259    /// Rufiyaa.
260    Mvr => "MVR", "462", 2;
261    /// Malawi Kwacha.
262    Mwk => "MWK", "454", 2;
263    /// Mexican Peso.
264    Mxn => "MXN", "484", 2;
265    /// Malaysian Ringgit.
266    Myr => "MYR", "458", 2;
267    /// Mozambique Metical.
268    Mzn => "MZN", "943", 2;
269    /// Namibia Dollar.
270    Nad => "NAD", "516", 2;
271    /// Naira.
272    Ngn => "NGN", "566", 2;
273    /// Cordoba Oro.
274    Nio => "NIO", "558", 2;
275    /// Norwegian Krone.
276    Nok => "NOK", "578", 2;
277    /// Nepalese Rupee.
278    Npr => "NPR", "524", 2;
279    /// New Zealand Dollar.
280    Nzd => "NZD", "554", 2;
281    /// Balboa.
282    Pab => "PAB", "590", 2;
283    /// Sol.
284    Pen => "PEN", "604", 2;
285    /// Kina.
286    Pgk => "PGK", "598", 2;
287    /// Philippine Peso.
288    Php => "PHP", "608", 2;
289    /// Pakistan Rupee.
290    Pkr => "PKR", "586", 2;
291    /// Zloty.
292    Pln => "PLN", "985", 2;
293    /// Qatari Rial.
294    Qar => "QAR", "634", 2;
295    /// Romanian Leu.
296    Ron => "RON", "946", 2;
297    /// Serbian Dinar.
298    Rsd => "RSD", "941", 2;
299    /// Russian Ruble.
300    Rub => "RUB", "643", 2;
301    /// Saudi Riyal.
302    Sar => "SAR", "682", 2;
303    /// Solomon Islands Dollar.
304    Sbd => "SBD", "090", 2;
305    /// Seychelles Rupee.
306    Scr => "SCR", "690", 2;
307    /// Swedish Krona.
308    Sek => "SEK", "752", 2;
309    /// Singapore Dollar.
310    Sgd => "SGD", "702", 2;
311    /// Saint Helena Pound.
312    Shp => "SHP", "654", 2;
313    /// Somali Shilling.
314    Sos => "SOS", "706", 2;
315    /// Surinam Dollar.
316    Srd => "SRD", "968", 2;
317    /// El Salvador Colon.
318    Svc => "SVC", "222", 2;
319    /// Lilangeni.
320    Szl => "SZL", "748", 2;
321    /// Baht.
322    Thb => "THB", "764", 2;
323    /// Somoni.
324    Tjs => "TJS", "972", 2;
325    /// Pa’anga.
326    Top => "TOP", "776", 2;
327    /// Turkish lira.
328    Try => "TRY", "949", 2;
329    /// Trinidad and Tobago Dollar.
330    Ttd => "TTD", "780", 2;
331    /// New Taiwan Dollar.
332    Twd => "TWD", "901", 2;
333    /// Tanzanian Shilling.
334    Tzs => "TZS", "834", 2;
335    /// Hryvnia.
336    Uah => "UAH", "980", 2;
337    /// US Dollar.
338    Usd => "USD", "840", 2;
339    /// Peso Uruguayo.
340    Uyu => "UYU", "858", 2;
341    /// Uzbekistan Sum.
342    Uzs => "UZS", "860", 2;
343    /// Tala.
344    Wst => "WST", "882", 2;
345    /// East Caribbean Dollar.
346    Xcd => "XCD", "951", 2;
347    /// Yemeni Rial.
348    Yer => "YER", "886", 2;
349    /// Rand.
350    Zar => "ZAR", "710", 2;
351    /// Zambian Kwacha.
352    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/// The string was not a currency code kasapay supports.
362#[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    /// Reads either ISO 4217 code, alphabetic or numeric.
370    ///
371    /// Both, because providers answer with both: iyzico's In-Store API reports
372    /// lira as `0949` where every other API of theirs writes `TRY`. The two
373    /// cannot be confused — one is three letters and the other three digits —
374    /// and a numeric code is read whatever it is padded to, since `0949`,
375    /// `949` and ISO's own `008` for the lek are the same number written three
376    /// ways.
377    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/// An amount in one currency, counted in that currency's minor unit.
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub struct Money {
397    minor_units: i64,
398    currency: Currency,
399}
400
401/// A decimal string could not be read as an amount in the given currency.
402#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
403pub enum MoneyError {
404    /// The text was not a plain decimal number.
405    #[error("`{0}` is not a decimal amount")]
406    NotDecimal(String),
407    /// The text carried more decimal places than the currency has.
408    #[error("`{value}` has more than {exponent} decimal places for {currency}")]
409    TooPrecise {
410        /// The amount as it was written.
411        value: String,
412        /// The currency it was to be read in.
413        currency: Currency,
414        /// The number of decimal places the currency allows.
415        exponent: u32,
416    },
417    /// The amount did not fit in an `i64` of minor units.
418    #[error("`{0}` does not fit in 64 bits of minor units")]
419    Overflow(String),
420    /// The amount was zero or negative where a positive one was required.
421    #[error("amount must be positive, got {0}")]
422    NotPositive(i64),
423    /// Two amounts in different currencies were combined.
424    #[error("cannot combine {left} with {right}")]
425    CurrencyMismatch {
426        /// The currency of the amount on the left.
427        left: Currency,
428        /// The currency of the amount on the right.
429        right: Currency,
430    },
431}
432
433impl Money {
434    /// Builds an amount from a count of minor units — 1050 for 10.50 TRY.
435    #[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    /// Reads a plain decimal string such as `"10.50"`.
444    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    /// The amount as a count of minor units.
483    #[must_use]
484    pub const fn minor_units(self) -> i64 {
485        self.minor_units
486    }
487
488    /// The currency the amount is in.
489    #[must_use]
490    pub const fn currency(self) -> Currency {
491        self.currency
492    }
493
494    /// Fails unless the amount is greater than zero.
495    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    /// Adds another amount in the same currency.
504    ///
505    /// Fails on a currency mismatch, and on the overflow that would otherwise
506    /// wrap a total round to a negative one.
507    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    /// Subtracts another amount in the same currency.
519    ///
520    /// The result may be negative, because a ledger needs it to be: an
521    /// over-refund is a number somebody has to see, not one to clamp away.
522    /// [`Money::require_positive`] is what refuses it where it must be refused.
523    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    /// Multiplies by a count — a unit price by how many were bought.
535    ///
536    /// Fails on the overflow that would otherwise wrap a line total round to a
537    /// negative one.
538    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    /// Whether the amount is exactly zero.
549    #[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    /// Renders the amount as a plain decimal string, without the currency code.
566    ///
567    /// This is the form providers that take a decimal amount expect: `10.50`,
568    /// never `10.5` and never `1.05e1`.
569    #[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
587/// Orders two amounts, and refuses to order two currencies.
588///
589/// `partial_cmp` answers `None` across currencies, because ten lira and ten
590/// dollars have no order. Deriving [`Ord`] would invent one out of the
591/// declaration order of [`Currency`], which is how a comparison quietly starts
592/// answering a question nobody asked.
593impl 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        // What iyzico's In-Store API actually answers, zero-padded.
676        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        // Not a number and not a code — neither branch may claim it.
689        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    /// The nine this library shipped with, whose minor unit is settled and
710    /// whose exponent is therefore allowed to be something other than two.
711    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    /// The rule the list is chosen by, asserted rather than trusted.
724    ///
725    /// A currency whose minor unit is not two decimal places is where a
726    /// provider's reading and ISO's diverge, and being wrong about one is a
727    /// payment out by a factor of a hundred. Adding one anyway is allowed —
728    /// it just has to be a decision somebody made, which is what failing here
729    /// forces.
730    #[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(&currency) {
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            // The padding iyzico uses, and the unpadded form ISO's own tables
760            // are sometimes printed in.
761            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    /// Two currencies sharing a code would make one of them unreachable
779    /// through `FromStr`, and which one is whichever the `match` reached first.
780    #[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        // Checking only one direction would pass for a type that had silently
869        // become totally ordered.
870        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        // No `Money::max`: that comes from Ord, which this type deliberately
883        // does not have.
884    }
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}