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::intern::InternedStr;
13#[cfg(feature = "rkyv")]
14use crate::intern::{AsDecimal, AsInternedStr};
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    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
44    pub currency: InternedStr,
45}
46
47impl Amount {
48    /// Create a new amount.
49    #[must_use]
50    pub fn new(number: Decimal, currency: impl Into<InternedStr>) -> Self {
51        Self {
52            number,
53            currency: currency.into(),
54        }
55    }
56
57    /// Create a zero amount with the given currency.
58    #[must_use]
59    pub fn zero(currency: impl Into<InternedStr>) -> Self {
60        Self {
61            number: Decimal::ZERO,
62            currency: currency.into(),
63        }
64    }
65
66    /// Check if the amount is zero.
67    #[must_use]
68    pub const fn is_zero(&self) -> bool {
69        self.number.is_zero()
70    }
71
72    /// Check if the amount is positive.
73    #[must_use]
74    pub const fn is_positive(&self) -> bool {
75        self.number.is_sign_positive() && !self.number.is_zero()
76    }
77
78    /// Check if the amount is negative.
79    #[must_use]
80    pub const fn is_negative(&self) -> bool {
81        self.number.is_sign_negative()
82    }
83
84    /// Get the absolute value of this amount.
85    #[must_use]
86    pub fn abs(&self) -> Self {
87        Self {
88            number: self.number.abs(),
89            currency: self.currency.clone(),
90        }
91    }
92
93    /// Get the scale (number of decimal places) of this amount.
94    #[must_use]
95    pub const fn scale(&self) -> u32 {
96        self.number.scale()
97    }
98
99    /// Calculate the inferred tolerance for this amount.
100    ///
101    /// Tolerance is `0.5 * 10^(-scale)`, so:
102    /// - scale 0 (integer) → tolerance 0.5
103    /// - scale 1 → tolerance 0.05
104    /// - scale 2 → tolerance 0.005
105    #[must_use]
106    pub fn inferred_tolerance(&self) -> Decimal {
107        // tolerance = 5 * 10^(-(scale+1)) = 0.5 * 10^(-scale)
108        Decimal::new(5, self.number.scale() + 1)
109    }
110
111    /// Check if this amount is near zero within tolerance.
112    #[must_use]
113    pub fn is_near_zero(&self, tolerance: Decimal) -> bool {
114        self.number.abs() <= tolerance
115    }
116
117    /// Check if this amount is near another amount within tolerance.
118    ///
119    /// Returns `false` if currencies don't match.
120    #[must_use]
121    pub fn is_near(&self, other: &Self, tolerance: Decimal) -> bool {
122        self.currency == other.currency && (self.number - other.number).abs() <= tolerance
123    }
124
125    /// Check if this amount equals another within the given tolerance.
126    ///
127    /// This is an alias for `is_near()` with a more explicit name for equality comparison.
128    /// Returns `false` if currencies don't match.
129    ///
130    /// # Example
131    ///
132    /// ```
133    /// use rustledger_core::Amount;
134    /// use rust_decimal_macros::dec;
135    ///
136    /// let a = Amount::new(dec!(100.00), "USD");
137    /// let b = Amount::new(dec!(100.004), "USD");
138    ///
139    /// // Within tolerance of 0.005
140    /// assert!(a.eq_with_tolerance(&b, dec!(0.005)));
141    ///
142    /// // Outside tolerance of 0.003
143    /// assert!(!a.eq_with_tolerance(&b, dec!(0.003)));
144    /// ```
145    #[must_use]
146    pub fn eq_with_tolerance(&self, other: &Self, tolerance: Decimal) -> bool {
147        self.is_near(other, tolerance)
148    }
149
150    /// Check if this amount equals another using auto-inferred tolerance.
151    ///
152    /// The tolerance is computed as the maximum of both amounts' inferred tolerances,
153    /// which is based on their decimal precision (scale).
154    ///
155    /// # Example
156    ///
157    /// ```
158    /// use rustledger_core::Amount;
159    /// use rust_decimal_macros::dec;
160    ///
161    /// let a = Amount::new(dec!(100.00), "USD");  // scale 2 -> tolerance 0.005
162    /// let b = Amount::new(dec!(100.004), "USD"); // scale 3 -> tolerance 0.0005
163    ///
164    /// // Uses max tolerance (0.005), so these are equal
165    /// assert!(a.eq_auto_tolerance(&b));
166    /// ```
167    #[must_use]
168    pub fn eq_auto_tolerance(&self, other: &Self) -> bool {
169        if self.currency != other.currency {
170            return false;
171        }
172        let tolerance = self.inferred_tolerance().max(other.inferred_tolerance());
173        (self.number - other.number).abs() <= tolerance
174    }
175
176    /// Round this amount to the given number of decimal places.
177    #[must_use]
178    pub fn round_dp(&self, dp: u32) -> Self {
179        Self {
180            number: self.number.round_dp(dp),
181            currency: self.currency.clone(),
182        }
183    }
184}
185
186impl fmt::Display for Amount {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        write!(f, "{} {}", self.number, self.currency)
189    }
190}
191
192// Arithmetic operations on references
193
194impl Add for &Amount {
195    type Output = Amount;
196
197    fn add(self, other: &Amount) -> Amount {
198        debug_assert_eq!(
199            self.currency, other.currency,
200            "Cannot add amounts with different currencies"
201        );
202        Amount {
203            number: self.number + other.number,
204            currency: self.currency.clone(),
205        }
206    }
207}
208
209impl Sub for &Amount {
210    type Output = Amount;
211
212    fn sub(self, other: &Amount) -> Amount {
213        debug_assert_eq!(
214            self.currency, other.currency,
215            "Cannot subtract amounts with different currencies"
216        );
217        Amount {
218            number: self.number - other.number,
219            currency: self.currency.clone(),
220        }
221    }
222}
223
224impl Neg for &Amount {
225    type Output = Amount;
226
227    fn neg(self) -> Amount {
228        Amount {
229            number: -self.number,
230            currency: self.currency.clone(),
231        }
232    }
233}
234
235// Arithmetic operations on owned values
236
237impl Add for Amount {
238    type Output = Self;
239
240    fn add(self, other: Self) -> Self {
241        &self + &other
242    }
243}
244
245impl Sub for Amount {
246    type Output = Self;
247
248    fn sub(self, other: Self) -> Self {
249        &self - &other
250    }
251}
252
253impl Neg for Amount {
254    type Output = Self;
255
256    fn neg(self) -> Self {
257        -&self
258    }
259}
260
261impl AddAssign<&Self> for Amount {
262    fn add_assign(&mut self, other: &Self) {
263        debug_assert_eq!(
264            self.currency, other.currency,
265            "Cannot add amounts with different currencies"
266        );
267        self.number += other.number;
268    }
269}
270
271impl SubAssign<&Self> for Amount {
272    fn sub_assign(&mut self, other: &Self) {
273        debug_assert_eq!(
274            self.currency, other.currency,
275            "Cannot subtract amounts with different currencies"
276        );
277        self.number -= other.number;
278    }
279}
280
281/// An incomplete amount specification used in postings before interpolation.
282///
283/// In Beancount, postings can have incomplete amount specifications that
284/// will be filled in by the interpolation algorithm:
285///
286/// - `100.00 USD` - Complete amount
287/// - `USD` - Currency only, number to be interpolated
288/// - `100.00` - Number only, currency to be inferred
289/// - (nothing) - Entire amount to be interpolated
290///
291/// This type represents all these cases before the interpolation phase.
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
293#[cfg_attr(
294    feature = "rkyv",
295    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
296)]
297pub enum IncompleteAmount {
298    /// Complete amount with both number and currency
299    Complete(Amount),
300    /// Only number specified, currency to be inferred from context (cost, price, or other postings)
301    NumberOnly(#[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))] Decimal),
302    /// Only currency specified, number to be interpolated to balance the transaction
303    CurrencyOnly(#[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))] InternedStr),
304}
305
306impl IncompleteAmount {
307    /// Create a complete amount.
308    #[must_use]
309    pub fn complete(number: Decimal, currency: impl Into<InternedStr>) -> Self {
310        Self::Complete(Amount::new(number, currency))
311    }
312
313    /// Create a number-only incomplete amount.
314    #[must_use]
315    pub const fn number_only(number: Decimal) -> Self {
316        Self::NumberOnly(number)
317    }
318
319    /// Create a currency-only incomplete amount.
320    #[must_use]
321    pub fn currency_only(currency: impl Into<InternedStr>) -> Self {
322        Self::CurrencyOnly(currency.into())
323    }
324
325    /// Get the number if present.
326    #[must_use]
327    pub const fn number(&self) -> Option<Decimal> {
328        match self {
329            Self::Complete(a) => Some(a.number),
330            Self::NumberOnly(n) => Some(*n),
331            Self::CurrencyOnly(_) => None,
332        }
333    }
334
335    /// Get the currency if present.
336    #[must_use]
337    pub fn currency(&self) -> Option<&str> {
338        match self {
339            Self::Complete(a) => Some(&a.currency),
340            Self::NumberOnly(_) => None,
341            Self::CurrencyOnly(c) => Some(c),
342        }
343    }
344
345    /// Check if this is a complete amount.
346    #[must_use]
347    pub const fn is_complete(&self) -> bool {
348        matches!(self, Self::Complete(_))
349    }
350
351    /// Get as a complete Amount if possible.
352    #[must_use]
353    pub const fn as_amount(&self) -> Option<&Amount> {
354        match self {
355            Self::Complete(a) => Some(a),
356            _ => None,
357        }
358    }
359
360    /// Convert to a complete Amount, consuming self.
361    #[must_use]
362    pub fn into_amount(self) -> Option<Amount> {
363        match self {
364            Self::Complete(a) => Some(a),
365            _ => None,
366        }
367    }
368}
369
370impl From<Amount> for IncompleteAmount {
371    fn from(amount: Amount) -> Self {
372        Self::Complete(amount)
373    }
374}
375
376impl fmt::Display for IncompleteAmount {
377    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
378        match self {
379            Self::Complete(a) => write!(f, "{a}"),
380            Self::NumberOnly(n) => write!(f, "{n}"),
381            Self::CurrencyOnly(c) => write!(f, "{c}"),
382        }
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use rust_decimal_macros::dec;
390
391    #[test]
392    fn test_new() {
393        let amount = Amount::new(dec!(100.00), "USD");
394        assert_eq!(amount.number, dec!(100.00));
395        assert_eq!(amount.currency, "USD");
396    }
397
398    #[test]
399    fn test_zero() {
400        let amount = Amount::zero("EUR");
401        assert!(amount.is_zero());
402        assert_eq!(amount.currency, "EUR");
403    }
404
405    #[test]
406    fn test_is_positive_negative() {
407        let pos = Amount::new(dec!(100), "USD");
408        let neg = Amount::new(dec!(-100), "USD");
409        let zero = Amount::zero("USD");
410
411        assert!(pos.is_positive());
412        assert!(!pos.is_negative());
413
414        assert!(!neg.is_positive());
415        assert!(neg.is_negative());
416
417        assert!(!zero.is_positive());
418        assert!(!zero.is_negative());
419    }
420
421    #[test]
422    fn test_add() {
423        let a = Amount::new(dec!(100.00), "USD");
424        let b = Amount::new(dec!(50.00), "USD");
425        let sum = &a + &b;
426        assert_eq!(sum.number, dec!(150.00));
427        assert_eq!(sum.currency, "USD");
428    }
429
430    #[test]
431    fn test_sub() {
432        let a = Amount::new(dec!(100.00), "USD");
433        let b = Amount::new(dec!(50.00), "USD");
434        let diff = &a - &b;
435        assert_eq!(diff.number, dec!(50.00));
436    }
437
438    #[test]
439    fn test_neg() {
440        let a = Amount::new(dec!(100.00), "USD");
441        let neg_a = -&a;
442        assert_eq!(neg_a.number, dec!(-100.00));
443    }
444
445    #[test]
446    fn test_add_assign() {
447        let mut a = Amount::new(dec!(100.00), "USD");
448        let b = Amount::new(dec!(50.00), "USD");
449        a += &b;
450        assert_eq!(a.number, dec!(150.00));
451    }
452
453    #[test]
454    fn test_inferred_tolerance() {
455        // scale 0 -> 0.5
456        let a = Amount::new(dec!(100), "USD");
457        assert_eq!(a.inferred_tolerance(), dec!(0.5));
458
459        // scale 2 -> 0.005
460        let b = Amount::new(dec!(100.00), "USD");
461        assert_eq!(b.inferred_tolerance(), dec!(0.005));
462
463        // scale 3 -> 0.0005
464        let c = Amount::new(dec!(100.000), "USD");
465        assert_eq!(c.inferred_tolerance(), dec!(0.0005));
466    }
467
468    #[test]
469    fn test_is_near_zero() {
470        let a = Amount::new(dec!(0.004), "USD");
471        assert!(a.is_near_zero(dec!(0.005)));
472        assert!(!a.is_near_zero(dec!(0.003)));
473    }
474
475    #[test]
476    fn test_is_near() {
477        let a = Amount::new(dec!(100.00), "USD");
478        let b = Amount::new(dec!(100.004), "USD");
479        assert!(a.is_near(&b, dec!(0.005)));
480        assert!(!a.is_near(&b, dec!(0.003)));
481
482        // Different currencies
483        let c = Amount::new(dec!(100.00), "EUR");
484        assert!(!a.is_near(&c, dec!(1.0)));
485    }
486
487    #[test]
488    fn test_display() {
489        let a = Amount::new(dec!(1234.56), "USD");
490        assert_eq!(format!("{a}"), "1234.56 USD");
491    }
492
493    #[test]
494    fn test_abs() {
495        let neg = Amount::new(dec!(-100.00), "USD");
496        let abs = neg.abs();
497        assert_eq!(abs.number, dec!(100.00));
498    }
499
500    #[test]
501    fn test_eq_with_tolerance() {
502        let a = Amount::new(dec!(100.00), "USD");
503        let b = Amount::new(dec!(100.004), "USD");
504
505        // Within tolerance
506        assert!(a.eq_with_tolerance(&b, dec!(0.005)));
507        assert!(b.eq_with_tolerance(&a, dec!(0.005)));
508
509        // Outside tolerance
510        assert!(!a.eq_with_tolerance(&b, dec!(0.003)));
511
512        // Different currencies
513        let c = Amount::new(dec!(100.00), "EUR");
514        assert!(!a.eq_with_tolerance(&c, dec!(1.0)));
515
516        // Exact match
517        let d = Amount::new(dec!(100.00), "USD");
518        assert!(a.eq_with_tolerance(&d, dec!(0.0)));
519    }
520
521    #[test]
522    #[allow(clippy::many_single_char_names)]
523    fn test_eq_auto_tolerance() {
524        // scale 2 (0.005 tolerance) vs scale 3 (0.0005 tolerance)
525        let a = Amount::new(dec!(100.00), "USD");
526        let b = Amount::new(dec!(100.004), "USD");
527
528        // Uses max tolerance (0.005), difference is 0.004, so equal
529        assert!(a.eq_auto_tolerance(&b));
530
531        // scale 3 vs scale 3 -> tolerance 0.0005
532        let c = Amount::new(dec!(100.000), "USD");
533        let d = Amount::new(dec!(100.001), "USD");
534
535        // Difference 0.001 > tolerance 0.0005, not equal
536        assert!(!c.eq_auto_tolerance(&d));
537
538        // scale 3 vs scale 3, small difference
539        let e = Amount::new(dec!(100.0004), "USD");
540        assert!(c.eq_auto_tolerance(&e)); // 0.0004 <= 0.0005
541
542        // Different currencies
543        let f = Amount::new(dec!(100.00), "EUR");
544        assert!(!a.eq_auto_tolerance(&f));
545    }
546}