Skip to main content

billing_kit/
price.rs

1use decimal_money::{Currency, CurrencyAmount, MoneyError};
2use rust_decimal::Decimal;
3
4/// Errors for [`Price`] construction.
5#[derive(Debug, thiserror::Error)]
6pub enum PriceError {
7    /// Tax rate outside 0..=100.
8    #[error("tax rate must be between 0 and 100, got {0}")]
9    InvalidTaxRate(Decimal),
10    /// Currency mismatch between net and tax operations.
11    #[error(transparent)]
12    Money(#[from] MoneyError),
13}
14
15/// A net price plus a percentage tax rate (e.g. VAT 20%).
16///
17/// All amounts share a single [`Currency`]; operations return
18/// [`MoneyError::CurrencyMismatch`] if currencies diverge.
19///
20/// Mirrors `ecom_core::Money::percentage_of` — `tax = net * rate / 100`
21/// — but surfaces the rate explicitly for invoicing.
22///
23/// ```rust
24/// use billing_kit::{Price, Currency};
25/// use rust_decimal_macros::dec;
26/// let p = Price::new(dec!(100), Currency::GBP, dec!(20)).unwrap();
27/// assert_eq!(p.tax_amount().amount, dec!(20));
28/// assert_eq!(p.gross().amount, dec!(120));
29/// ```
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Price {
32    /// Net (pre-tax) amount.
33    pub net: CurrencyAmount,
34    /// Tax rate as percentage (0-100).
35    pub tax_rate: Decimal,
36}
37
38impl Price {
39    /// Create a new `Price`.
40    ///
41    /// # Errors
42    /// Returns [`PriceError::InvalidTaxRate`] if `tax_rate` is not in `0..=100`.
43    pub fn new(net_amount: impl Into<Decimal>, currency: Currency, tax_rate: Decimal) -> Result<Self, PriceError> {
44        if tax_rate < Decimal::ZERO || tax_rate > Decimal::from(100) {
45            return Err(PriceError::InvalidTaxRate(tax_rate));
46        }
47        Ok(Self {
48            net: CurrencyAmount::new(net_amount, currency),
49            tax_rate,
50        })
51    }
52
53    /// Tax amount = `net * tax_rate / 100`.
54    #[must_use]
55    pub fn tax_amount(&self) -> CurrencyAmount {
56        let tax = self.net.amount * self.tax_rate / Decimal::from(100);
57        CurrencyAmount::new(tax, self.net.currency)
58    }
59
60    /// Gross amount = `net + tax`.
61    #[must_use]
62    pub fn gross(&self) -> CurrencyAmount {
63        // unwrap: same currency, decimal addition cannot mismatch.
64        (self.net.clone() + self.tax_amount()).expect("same currency")
65    }
66
67    /// Discount amount at `percent`% of gross (0-100).
68    #[must_use]
69    pub fn discount_of_gross(&self, percent: Decimal) -> CurrencyAmount {
70        let gross = self.gross();
71        CurrencyAmount::new(gross.amount * percent / Decimal::from(100), gross.currency)
72    }
73
74    /// Apply a promo discount to gross, returning the discounted gross.
75    ///
76    /// Clamps `percent` to 0..=100 and never returns a negative amount.
77    #[must_use]
78    pub fn gross_minus_discount(&self, percent: Decimal) -> CurrencyAmount {
79        let gross = self.gross();
80        let pct = percent.clamp(Decimal::ZERO, Decimal::from(100));
81        let discount = gross.amount * pct / Decimal::from(100);
82        let discounted = (gross.amount - discount).max(Decimal::ZERO);
83        CurrencyAmount::new(discounted, gross.currency)
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use rust_decimal_macros::dec;
91
92    #[test]
93    fn vat_20() {
94        let p = Price::new(dec!(100), Currency::GBP, dec!(20)).unwrap();
95        assert_eq!(p.tax_amount().amount, dec!(20));
96        assert_eq!(p.gross().amount, dec!(120));
97    }
98
99    #[test]
100    fn zero_tax() {
101        let p = Price::new(dec!(50), Currency::GBP, dec!(0)).unwrap();
102        assert_eq!(p.gross().amount, dec!(50));
103    }
104
105    #[test]
106    fn invalid_tax_rejected() {
107        assert!(Price::new(dec!(10), Currency::GBP, dec!(101)).is_err());
108        assert!(Price::new(dec!(10), Currency::GBP, dec!(-1)).is_err());
109    }
110
111    #[test]
112    fn discount_of_gross() {
113        let p = Price::new(dec!(100), Currency::GBP, dec!(0)).unwrap();
114        assert_eq!(p.discount_of_gross(dec!(10)).amount, dec!(10));
115    }
116
117    #[test]
118    fn gross_minus_discount_clamps() {
119        let p = Price::new(dec!(100), Currency::GBP, dec!(0)).unwrap();
120        assert_eq!(p.gross_minus_discount(dec!(100)).amount, dec!(0));
121        assert_eq!(p.gross_minus_discount(dec!(200)).amount, dec!(0));
122    }
123}