1use decimal_money::{Currency, CurrencyAmount, MoneyError};
2use rust_decimal::Decimal;
3
4#[derive(Debug, thiserror::Error)]
6pub enum PriceError {
7 #[error("tax rate must be between 0 and 100, got {0}")]
9 InvalidTaxRate(Decimal),
10 #[error(transparent)]
12 Money(#[from] MoneyError),
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Price {
32 pub net: CurrencyAmount,
34 pub tax_rate: Decimal,
36}
37
38impl Price {
39 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 #[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 #[must_use]
62 pub fn gross(&self) -> CurrencyAmount {
63 (self.net.clone() + self.tax_amount()).expect("same currency")
65 }
66
67 #[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 #[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}