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