Skip to main content

rustledger_core/
amount.rs

1//! Amount type representing a decimal number with a currency.
2//!
3//! An [`Amount`] is the fundamental unit of value in Beancount, combining a decimal
4//! number with a currency code. It supports arithmetic operations and tolerance-based
5//! comparison for balance checking.
6
7use 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/// An amount is a quantity paired with a currency.
17///
18/// # Examples
19///
20/// ```
21/// use rustledger_core::Amount;
22/// use rust_decimal_macros::dec;
23///
24/// let amount = Amount::new(dec!(100.00), "USD");
25/// assert_eq!(amount.number, dec!(100.00));
26/// assert_eq!(amount.currency, "USD");
27///
28/// // Arithmetic operations
29/// let other = Amount::new(dec!(50.00), "USD");
30/// let sum = &amount + &other;
31/// assert_eq!(sum.number, dec!(150.00));
32/// ```
33#[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    /// The decimal quantity
40    #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
41    pub number: Decimal,
42    /// The currency code (e.g., "USD", "EUR", "AAPL")
43    pub currency: Currency,
44}
45
46impl Amount {
47    /// Create a new amount.
48    #[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    /// Create a zero amount with the given currency.
57    #[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    /// Check if the amount is zero.
66    #[must_use]
67    pub const fn is_zero(&self) -> bool {
68        self.number.is_zero()
69    }
70
71    /// Check if the amount is positive.
72    #[must_use]
73    pub const fn is_positive(&self) -> bool {
74        self.number.is_sign_positive() && !self.number.is_zero()
75    }
76
77    /// Check if the amount is negative.
78    #[must_use]
79    pub const fn is_negative(&self) -> bool {
80        self.number.is_sign_negative()
81    }
82
83    /// Get the absolute value of this amount.
84    #[must_use]
85    pub fn abs(&self) -> Self {
86        Self {
87            number: self.number.abs(),
88            currency: self.currency.clone(),
89        }
90    }
91
92    /// Get the scale (number of decimal places) of this amount.
93    #[must_use]
94    pub const fn scale(&self) -> u32 {
95        self.number.scale()
96    }
97
98    /// Calculate the inferred tolerance for this amount.
99    ///
100    /// Tolerance is `0.5 * 10^(-scale)`, so:
101    /// - scale 0 (integer) → tolerance 0.5
102    /// - scale 1 → tolerance 0.05
103    /// - scale 2 → tolerance 0.005
104    #[must_use]
105    pub fn inferred_tolerance(&self) -> Decimal {
106        // tolerance = 5 * 10^(-(scale+1)) = 0.5 * 10^(-scale)
107        Decimal::new(5, self.number.scale() + 1)
108    }
109
110    /// Check if this amount is near zero within tolerance.
111    #[must_use]
112    pub fn is_near_zero(&self, tolerance: Decimal) -> bool {
113        self.number.abs() <= tolerance
114    }
115
116    /// Check if this amount is near another amount within tolerance.
117    ///
118    /// Returns `false` if currencies don't match.
119    #[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    /// Check if this amount equals another within the given tolerance.
125    ///
126    /// This is an alias for `is_near()` with a more explicit name for equality comparison.
127    /// Returns `false` if currencies don't match.
128    ///
129    /// # Example
130    ///
131    /// ```
132    /// use rustledger_core::Amount;
133    /// use rust_decimal_macros::dec;
134    ///
135    /// let a = Amount::new(dec!(100.00), "USD");
136    /// let b = Amount::new(dec!(100.004), "USD");
137    ///
138    /// // Within tolerance of 0.005
139    /// assert!(a.eq_with_tolerance(&b, dec!(0.005)));
140    ///
141    /// // Outside tolerance of 0.003
142    /// assert!(!a.eq_with_tolerance(&b, dec!(0.003)));
143    /// ```
144    #[must_use]
145    pub fn eq_with_tolerance(&self, other: &Self, tolerance: Decimal) -> bool {
146        self.is_near(other, tolerance)
147    }
148
149    /// Check if this amount equals another using auto-inferred tolerance.
150    ///
151    /// The tolerance is computed as the maximum of both amounts' inferred tolerances,
152    /// which is based on their decimal precision (scale).
153    ///
154    /// # Example
155    ///
156    /// ```
157    /// use rustledger_core::Amount;
158    /// use rust_decimal_macros::dec;
159    ///
160    /// let a = Amount::new(dec!(100.00), "USD");  // scale 2 -> tolerance 0.005
161    /// let b = Amount::new(dec!(100.004), "USD"); // scale 3 -> tolerance 0.0005
162    ///
163    /// // Uses max tolerance (0.005), so these are equal
164    /// assert!(a.eq_auto_tolerance(&b));
165    /// ```
166    #[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    /// Round this amount to the given number of decimal places.
176    #[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/// Error produced by the [`FromStr`](std::str::FromStr) impl on
192/// [`Amount`] when the input doesn't match the `<number> <currency>`
193/// shape that [`fmt::Display`] emits.
194///
195/// Carries the offending input so callers can surface an actionable
196/// message ("you wrote X, expected Y") rather than a generic parse
197/// failure. The wire format is strict on purpose: see the `FromStr`
198/// docstring for the supported shape.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct AmountParseError {
201    /// The original input string the caller passed.
202    pub input: String,
203    /// Why the parse failed (caller-displayable, no internal jargon).
204    pub reason: AmountParseErrorReason,
205}
206
207/// Distinguishes the failure modes of [`Amount`]'s
208/// [`FromStr`](std::str::FromStr) impl.
209///
210/// Separate from [`AmountParseError`] so callers can match on the
211/// category (e.g. for distinct error codes) without parsing the
212/// message string.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum AmountParseErrorReason {
215    /// Input had fewer or more than 2 whitespace-separated tokens.
216    NotTwoTokens,
217    /// The number token didn't parse as a [`Decimal`] (carries the
218    /// offending token, not the full input).
219    InvalidNumber(String),
220    /// The currency token isn't a valid beancount commodity (must be
221    /// uppercase ASCII letters, digits, `'`, `.`, `_`, `-`, starting
222    /// with an uppercase letter; max 24 chars).
223    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    /// Parse `<number> <currency>` — the exact shape produced by
255    /// [`fmt::Display`]. Round-trip is lossless:
256    /// `Amount::from_str(&amt.to_string()) == Ok(amt)`.
257    ///
258    /// Strict by design — there is intentionally no Python beancount
259    /// equivalent of this parser, so we set the wire contract. Accepts:
260    /// - Any whitespace (one or more spaces/tabs) between the number
261    ///   and currency.
262    /// - Leading or trailing whitespace around the whole string.
263    /// - Negative numbers (`-100 USD`).
264    /// - Fractional decimals (`100.50 USD`).
265    ///
266    /// Rejects (typed error rather than silent fallback):
267    /// - Currency-first form (`"USD 100"`).
268    /// - Number-only (`"100"`) or currency-only (`"USD"`).
269    /// - Scientific notation (`"1e2 USD"`).
270    /// - Thousands separators (`"1,000 USD"`).
271    /// - Lowercase commodity (`"100 usd"`).
272    /// - Empty / whitespace-only strings.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`AmountParseError`] describing which axis of the
277    /// expected shape was violated; see [`AmountParseErrorReason`].
278    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        // `from_str_exact` rejects scientific notation, thousands
288        // separators, embedded whitespace — exactly what we want for
289        // strict parsing.
290        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
306/// Beancount commodity validation: uppercase letter first, then
307/// uppercase letters, digits, `'`, `.`, `_`, or `-`. 1–24 chars.
308///
309/// Matches the parser's commodity-token rule (see
310/// `rustledger-parser::lexer`). Kept inline here rather than reaching
311/// into the parser to avoid a `rustledger-core → rustledger-parser`
312/// dep cycle (parser already depends on core).
313fn 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
329// Arithmetic operations on references
330
331impl 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
372// Arithmetic operations on owned values
373
374impl 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/// An incomplete amount specification used in postings before interpolation.
419///
420/// In Beancount, postings can have incomplete amount specifications that
421/// will be filled in by the interpolation algorithm:
422///
423/// - `100.00 USD` - Complete amount
424/// - `USD` - Currency only, number to be interpolated
425/// - `100.00` - Number only, currency to be inferred
426/// - (nothing) - Entire amount to be interpolated
427///
428/// This type represents all these cases before the interpolation phase.
429#[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 with both number and currency
436    Complete(Amount),
437    /// Only number specified, currency to be inferred from context (cost, price, or other postings)
438    NumberOnly(#[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))] Decimal),
439    /// Only currency specified, number to be interpolated to balance the transaction
440    CurrencyOnly(Currency),
441}
442
443impl IncompleteAmount {
444    /// Create a complete amount.
445    #[must_use]
446    pub fn complete(number: Decimal, currency: impl Into<Currency>) -> Self {
447        Self::Complete(Amount::new(number, currency))
448    }
449
450    /// Create a number-only incomplete amount.
451    #[must_use]
452    pub const fn number_only(number: Decimal) -> Self {
453        Self::NumberOnly(number)
454    }
455
456    /// Create a currency-only incomplete amount.
457    #[must_use]
458    pub fn currency_only(currency: impl Into<Currency>) -> Self {
459        Self::CurrencyOnly(currency.into())
460    }
461
462    /// Get the number if present.
463    #[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    /// Get the currency if present.
473    #[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    /// Check if this is a complete amount.
483    #[must_use]
484    pub const fn is_complete(&self) -> bool {
485        matches!(self, Self::Complete(_))
486    }
487
488    /// Get as a complete Amount if possible.
489    #[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    /// Convert to a complete Amount, consuming self.
498    #[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        // scale 0 -> 0.5
593        let a = Amount::new(dec!(100), "USD");
594        assert_eq!(a.inferred_tolerance(), dec!(0.5));
595
596        // scale 2 -> 0.005
597        let b = Amount::new(dec!(100.00), "USD");
598        assert_eq!(b.inferred_tolerance(), dec!(0.005));
599
600        // scale 3 -> 0.0005
601        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        // Different currencies
620        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        // Within tolerance
643        assert!(a.eq_with_tolerance(&b, dec!(0.005)));
644        assert!(b.eq_with_tolerance(&a, dec!(0.005)));
645
646        // Outside tolerance
647        assert!(!a.eq_with_tolerance(&b, dec!(0.003)));
648
649        // Different currencies
650        let c = Amount::new(dec!(100.00), "EUR");
651        assert!(!a.eq_with_tolerance(&c, dec!(1.0)));
652
653        // Exact match
654        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        // scale 2 (0.005 tolerance) vs scale 3 (0.0005 tolerance)
662        let a = Amount::new(dec!(100.00), "USD");
663        let b = Amount::new(dec!(100.004), "USD");
664
665        // Uses max tolerance (0.005), difference is 0.004, so equal
666        assert!(a.eq_auto_tolerance(&b));
667
668        // scale 3 vs scale 3 -> tolerance 0.0005
669        let c = Amount::new(dec!(100.000), "USD");
670        let d = Amount::new(dec!(100.001), "USD");
671
672        // Difference 0.001 > tolerance 0.0005, not equal
673        assert!(!c.eq_auto_tolerance(&d));
674
675        // scale 3 vs scale 3, small difference
676        let e = Amount::new(dec!(100.0004), "USD");
677        assert!(c.eq_auto_tolerance(&e)); // 0.0004 <= 0.0005
678
679        // Different currencies
680        let f = Amount::new(dec!(100.00), "EUR");
681        assert!(!a.eq_auto_tolerance(&f));
682    }
683
684    // ===== FromStr tests (#1179) =====
685
686    use std::str::FromStr;
687
688    #[test]
689    fn amount_from_str_round_trips_display() {
690        // Load-bearing invariant: `from_str(&amt.to_string()) == Ok(amt)`.
691        // If `Display` or `FromStr` ever drifts apart, this catches it
692        // before silent breakage. Cover positive, negative, fractional,
693        // zero, and large magnitudes.
694        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        // Extra whitespace around tokens is fine — we use
721        // split_whitespace, which collapses runs of spaces/tabs.
722        assert_eq!(
723            Amount::from_str("  100   USD  "),
724            Ok(Amount::new(dec!(100), "USD"))
725        );
726        // Single character commodity is legal.
727        assert_eq!(Amount::from_str("1 X"), Ok(Amount::new(dec!(1), "X")));
728        // Commodity with allowed special chars (`'`, `.`, `_`, `-`,
729        // digits after the first character).
730        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        // `"USD 100"` looks like a unit-only form to humans but isn't
739        // what Display emits. Reject so users don't get silent wrong
740        // results — `USD` would parse as the number token and fail at
741        // `Decimal::from_str_exact`.
742        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        // `Decimal::from_str_exact` rejects `1e2` — we want that strict
770        // behavior here so `CONVERT('1e2 USD', 'EUR')` fails loudly
771        // rather than parsing incorrectly or coercing.
772        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        // Beancount commodities must start with an uppercase letter.
800        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        // Plugin/BQL callers surface this Display to users; it must
810        // name what they wrote so they can fix it without guessing.
811        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}