Skip to main content

ocpi_tariffs/
money.rs

1//! Various monetary types.
2
3#[cfg(test)]
4mod test;
5
6#[cfg(test)]
7mod test_from_schema;
8
9#[cfg(test)]
10mod test_price;
11
12use std::fmt;
13
14use rust_decimal::Decimal;
15use rust_decimal_macros::dec;
16
17use crate::{
18    currency, from_warning_all, impl_dec_newtype,
19    number::{self, approx_eq_dec, FromDecimal as _, IsZero, RoundDecimal},
20    schema,
21    warning::{self, GatherWarnings as _, IntoCaveat as _},
22    FromSchema, SaturatingAdd as _, Verdict,
23};
24
25/// An item that has a cost.
26pub trait Cost: Copy {
27    /// The cost of this dimension at a certain price.
28    fn cost(&self, money: Money) -> Money;
29}
30
31impl Cost for () {
32    fn cost(&self, money: Money) -> Money {
33        money
34    }
35}
36
37/// The warnings that can happen when parsing or linting a `Price`.
38#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
39pub enum Warning {
40    /// The `excl_vat` field is greater than the `incl_vat` field.
41    ExclusiveVatGreaterThanInclusive,
42
43    /// Both the `excl_vat` and `incl_vat` fields should be valid numbers.
44    Number(number::Warning),
45
46    /// A feature rejected the schema IR for a `Price` because a required field was missing or
47    /// invalid. The located cause is reported by the schema validation warnings; this is a
48    /// content-free marker (see [`warning::Rejected`]).
49    Rejected,
50}
51
52impl fmt::Display for Warning {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            Self::ExclusiveVatGreaterThanInclusive => write!(
56                f,
57                "The `excl_vat` field is greater than the `incl_vat` field"
58            ),
59            Self::Number(kind) => fmt::Display::fmt(kind, f),
60            Self::Rejected => f.write_str(
61                "The schema IR for a `Price` was rejected; see the schema validation warnings.",
62            ),
63        }
64    }
65}
66
67impl crate::Warning for Warning {
68    fn id(&self) -> warning::Id {
69        match self {
70            Self::ExclusiveVatGreaterThanInclusive => {
71                warning::Id::from_static("exclusive_vat_greater_than_inclusive")
72            }
73            Self::Number(kind) => kind.id(),
74            Self::Rejected => warning::Id::from_static("rejected"),
75        }
76    }
77
78    fn is_rejected(&self) -> bool {
79        matches!(self, Self::Rejected)
80    }
81}
82
83impl From<warning::Rejected> for Warning {
84    fn from(_: warning::Rejected) -> Self {
85        Self::Rejected
86    }
87}
88
89from_warning_all!(number::Warning => Warning::Number);
90
91/// A price consisting of a value including VAT, and a value excluding VAT.
92#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd)]
93#[cfg_attr(test, derive(serde::Deserialize))]
94pub struct Price {
95    /// The price excluding VAT.
96    pub excl_vat: Money,
97
98    /// The price including VAT.
99    ///
100    /// If no vat is applicable this value will be equal to the `excl_vat`.
101    ///
102    /// If no vat could be determined this value will be `None`.
103    /// The v211 tariffs can't determine VAT.
104    #[cfg_attr(test, serde(default))]
105    pub incl_vat: Option<Money>,
106}
107
108impl RoundDecimal for Price {
109    fn round_to_ocpi_scale(self) -> Self {
110        let Self { excl_vat, incl_vat } = self;
111        Self {
112            excl_vat: excl_vat.round_to_ocpi_scale(),
113            incl_vat: incl_vat.round_to_ocpi_scale(),
114        }
115    }
116}
117
118impl fmt::Display for Price {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        if let Some(incl_vat) = self.incl_vat {
121            if f.alternate() {
122                write!(f, "{{ -vat: {:#}, +vat: {:#} }}", self.excl_vat, incl_vat)
123            } else {
124                write!(f, "{{ -vat: {}, +vat: {} }}", self.excl_vat, incl_vat)
125            }
126        } else {
127            fmt::Display::fmt(&self.excl_vat, f)
128        }
129    }
130}
131
132impl<'buf> FromSchema<'buf, schema::v221::Price<'buf>> for Price {
133    type Warning = Warning;
134
135    fn from_schema(source: &schema::v221::Price<'buf>) -> Verdict<Self, Self::Warning> {
136        let mut warnings = warning::Set::new();
137
138        // A bare-number price (the 2.1.1 shape) is just `excl_vat`: there is no `incl_vat`
139        // and thus no excl-vs-incl comparison to make.
140        let (elem, excl_vat, incl_vat) = match source {
141            schema::v221::Price::Number(number) => {
142                let excl_vat = Money::from_schema(number)?.gather_warnings_into(&mut warnings);
143                let price = Self {
144                    excl_vat,
145                    incl_vat: None,
146                };
147                return Ok(price.into_caveat(warnings));
148            }
149            schema::v221::Price::Object {
150                elem,
151                excl_vat,
152                incl_vat,
153            } => (elem, excl_vat, incl_vat),
154        };
155
156        // `excl_vat` is required; without a usable value a `Price` cannot be built. A
157        // missing or wrong-kind field is already reported structurally by the schema
158        // walk, so the bail here is the content-free `Rejected` signal.
159        let excl_vat = warnings.ok_or_bail(excl_vat)?;
160        let excl_vat = Money::from_schema(excl_vat)?.gather_warnings_into(&mut warnings);
161
162        // `incl_vat` is optional. Absent or `null` leaves the price without VAT info; a
163        // wrong-kind value (flagged structurally by the schema) is likewise treated as no
164        // VAT rather than failing the whole price.
165        let incl_vat = incl_vat
166            .map_some(Money::from_schema)
167            .transpose()?
168            .gather_warnings_into(&mut warnings);
169
170        if let Some(incl_vat) = incl_vat {
171            if excl_vat > incl_vat {
172                warnings.insert(elem, Warning::ExclusiveVatGreaterThanInclusive);
173            }
174        }
175
176        Ok(Self { excl_vat, incl_vat }.into_caveat(warnings))
177    }
178}
179
180impl IsZero for Price {
181    fn is_zero(&self) -> bool {
182        self.excl_vat.is_zero() && self.incl_vat.is_none_or(|v| v.is_zero())
183    }
184}
185
186impl Price {
187    pub fn zero() -> Self {
188        Self {
189            excl_vat: Money::zero(),
190            incl_vat: Some(Money::zero()),
191        }
192    }
193
194    /// Round this number to the OCPI specified amount of decimals.
195    #[must_use]
196    pub fn rescale(self) -> Self {
197        Self {
198            excl_vat: self.excl_vat.rescale(),
199            incl_vat: self.incl_vat.map(Money::rescale),
200        }
201    }
202
203    /// Saturating addition.
204    #[must_use]
205    pub(crate) fn saturating_add(self, rhs: Self) -> Self {
206        let incl_vat = self
207            .incl_vat
208            .zip(rhs.incl_vat)
209            .map(|(lhs, rhs)| lhs.saturating_add(rhs));
210
211        Self {
212            excl_vat: self.excl_vat.saturating_add(rhs.excl_vat),
213            incl_vat,
214        }
215    }
216
217    #[must_use]
218    pub fn round_dp(self, digits: u32) -> Self {
219        Self {
220            excl_vat: self.excl_vat.round_dp(digits),
221            incl_vat: self.incl_vat.map(|v| v.round_dp(digits)),
222        }
223    }
224
225    /// Display a Price with the given currency.
226    pub fn display_currency(&self, currency: currency::Code) -> DisplayPriceCurrency<'_> {
227        DisplayPriceCurrency {
228            currency,
229            price: self,
230        }
231    }
232}
233
234/// A Display object for displaying a `Price` with an associated currency.
235///
236/// Note: The placement of the currency symbol is always before the amount.
237/// The locale is not used to determine symbol position.
238pub struct DisplayPriceCurrency<'a> {
239    currency: currency::Code,
240    price: &'a Price,
241}
242
243impl fmt::Display for DisplayPriceCurrency<'_> {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        if let Some(incl_vat) = self.price.incl_vat {
246            write!(
247                f,
248                "{{ -vat: {:#}, +vat: {:#} }}",
249                self.price.excl_vat, incl_vat
250            )
251        } else {
252            fmt::Display::fmt(&self.price.excl_vat.display_currency(self.currency), f)
253        }
254    }
255}
256
257impl Default for Price {
258    fn default() -> Self {
259        Self::zero()
260    }
261}
262
263/// A monetary amount, the currency is dependent on the specified tariff.
264#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Ord, PartialOrd)]
265#[cfg_attr(test, derive(serde::Deserialize))]
266pub struct Money(Decimal);
267
268impl_dec_newtype!(Money, "ยค");
269
270impl IsZero for Money {
271    fn is_zero(&self) -> bool {
272        const TOLERANCE: Decimal = dec!(0.01);
273
274        approx_eq_dec(&self.0, &Decimal::ZERO, TOLERANCE)
275    }
276}
277
278impl Money {
279    #[must_use]
280    pub(crate) const fn zero() -> Self {
281        Self(Decimal::ZERO)
282    }
283
284    /// Apply a VAT percentage to this monetary amount.
285    #[must_use]
286    pub fn apply_vat(self, vat: Vat) -> Self {
287        const ONE: Decimal = dec!(1);
288
289        let x = vat.as_unit_interval().saturating_add(ONE);
290        Self(self.0.saturating_mul(x))
291    }
292
293    /// Display Money with the given currency.
294    pub fn display_currency(&self, currency: currency::Code) -> DisplayCurrency<'_> {
295        DisplayCurrency {
296            currency,
297            money: self,
298        }
299    }
300}
301
302/// A Display object for displaying `Money` with an associated currency.
303///
304/// Note: The placement of the currency symbol is always before the amount.
305/// The locale is not used to determine symbol position.
306pub struct DisplayCurrency<'a> {
307    currency: currency::Code,
308    money: &'a Money,
309}
310
311impl fmt::Display for DisplayCurrency<'_> {
312    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313        write!(f, "{}{:#}", self.currency.into_symbol(), self.money)
314    }
315}
316
317/// A VAT percentage.
318#[derive(Debug, PartialEq, Eq, Clone, Copy)]
319pub struct Vat(Decimal);
320
321impl_dec_newtype!(Vat, "%");
322
323impl Vat {
324    #[expect(clippy::missing_panics_doc, reason = "The divisor is non-zero")]
325    pub fn as_unit_interval(self) -> Decimal {
326        const PERCENT: Decimal = dec!(100);
327
328        self.0.checked_div(PERCENT).expect("divisor is non-zero")
329    }
330}
331
332/// The origin of a potential VAT percentage.
333#[derive(Clone, Copy, Debug)]
334pub(crate) enum VatOrigin {
335    /// The VAT percentage is unknown as the tariff is v211 and has no `vat` field.
336    ///
337    /// NOTE: All `incl_vat` fields should be `None` in the final calculation.
338    Unknown,
339
340    /// The tariff could have a `vat` field but a value is not provided.
341    ///
342    /// NOTE: The total `incl_vat` should be equal to `excl_vat`.
343    NotProvided,
344
345    /// The tariff could have a `vat` field and a value is provided.
346    Provided(Vat),
347}
348
349impl<'buf> FromSchema<'buf, schema::Number<'buf>> for VatOrigin {
350    type Warning = number::Warning;
351
352    fn from_schema(source: &schema::Number<'buf>) -> Verdict<Self, Self::Warning> {
353        let vat = Decimal::from_schema(source)?;
354        Ok(vat.map(|d| Self::Provided(Vat::from_decimal(d))))
355    }
356}