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    json::{self, FieldsAsExt as _},
20    number::{self, approx_eq_dec, FromDecimal as _, IsZero, RoundDecimal},
21    schema::{self, Integrity},
22    warning::{self, GatherWarnings as _, IntoCaveat as _},
23    FromSchema, SaturatingAdd as _, Verdict,
24};
25
26/// An item that has a cost.
27pub trait Cost: Copy {
28    /// The cost of this dimension at a certain price.
29    fn cost(&self, money: Money) -> Money;
30}
31
32impl Cost for () {
33    fn cost(&self, money: Money) -> Money {
34        money
35    }
36}
37
38/// The warnings that can happen when parsing or linting a `Price`.
39#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
40pub enum Warning {
41    /// The `excl_vat` field is greater than the `incl_vat` field.
42    ExclusiveVatGreaterThanInclusive,
43
44    /// The JSON value given is not an object.
45    InvalidType { type_found: json::ValueKind },
46
47    /// The `excl_vat` field is required.
48    MissingExclVatField,
49
50    /// Both the `excl_vat` and `incl_vat` fields should be valid numbers.
51    Number(number::Warning),
52}
53
54impl Warning {
55    fn invalid_type(elem: &json::Element<'_>) -> Self {
56        Self::InvalidType {
57            type_found: elem.value().kind(),
58        }
59    }
60}
61
62impl fmt::Display for Warning {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            Self::ExclusiveVatGreaterThanInclusive => write!(
66                f,
67                "The `excl_vat` field is greater than the `incl_vat` field"
68            ),
69            Self::InvalidType { type_found } => {
70                write!(
71                    f,
72                    "The value should be a `Price {{ excl_vat, incl_vat }}` object but is `{type_found}`"
73                )
74            }
75            Self::MissingExclVatField => write!(f, "The `excl_vat` field is required."),
76            Self::Number(kind) => fmt::Display::fmt(kind, f),
77        }
78    }
79}
80
81impl crate::Warning for Warning {
82    fn id(&self) -> warning::Id {
83        match self {
84            Self::ExclusiveVatGreaterThanInclusive => {
85                warning::Id::from_static("exclusive_vat_greater_than_inclusive")
86            }
87            Self::InvalidType { type_found } => {
88                warning::Id::from_string(format!("invalid_type({type_found})"))
89            }
90            Self::MissingExclVatField => warning::Id::from_static("missing_excl_vat_field"),
91            Self::Number(kind) => kind.id(),
92        }
93    }
94}
95
96from_warning_all!(number::Warning => Warning::Number);
97
98/// A price consisting of a value including VAT, and a value excluding VAT.
99#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd)]
100#[cfg_attr(test, derive(serde::Deserialize))]
101pub struct Price {
102    /// The price excluding VAT.
103    pub excl_vat: Money,
104
105    /// The price including VAT.
106    ///
107    /// If no vat is applicable this value will be equal to the `excl_vat`.
108    ///
109    /// If no vat could be determined this value will be `None`.
110    /// The v211 tariffs can't determine VAT.
111    #[cfg_attr(test, serde(default))]
112    pub incl_vat: Option<Money>,
113}
114
115impl RoundDecimal for Price {
116    fn round_to_ocpi_scale(self) -> Self {
117        let Self { excl_vat, incl_vat } = self;
118        Self {
119            excl_vat: excl_vat.round_to_ocpi_scale(),
120            incl_vat: incl_vat.round_to_ocpi_scale(),
121        }
122    }
123}
124
125impl fmt::Display for Price {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        if let Some(incl_vat) = self.incl_vat {
128            if f.alternate() {
129                write!(f, "{{ -vat: {:#}, +vat: {:#} }}", self.excl_vat, incl_vat)
130            } else {
131                write!(f, "{{ -vat: {}, +vat: {} }}", self.excl_vat, incl_vat)
132            }
133        } else {
134            fmt::Display::fmt(&self.excl_vat, f)
135        }
136    }
137}
138
139impl json::FromJson<'_> for Price {
140    type Warning = Warning;
141
142    fn from_json(elem: &json::Element<'_>) -> Verdict<Self, Self::Warning> {
143        let mut warnings = warning::Set::new();
144        let value = elem.as_value();
145
146        let Some(fields) = value.as_object_fields() else {
147            return warnings.bail(elem, Warning::invalid_type(elem));
148        };
149
150        let Some(excl_vat) = fields.find_field("excl_vat") else {
151            return warnings.bail(elem, Warning::MissingExclVatField);
152        };
153
154        let excl_vat = Money::from_json(excl_vat.element())?.gather_warnings_into(&mut warnings);
155
156        let incl_vat = fields
157            .find_field("incl_vat")
158            .map(|f| Money::from_json(f.element()))
159            .transpose()?
160            .gather_warnings_into(&mut warnings);
161
162        if let Some(incl_vat) = incl_vat {
163            if excl_vat > incl_vat {
164                warnings.insert(elem, Warning::ExclusiveVatGreaterThanInclusive);
165            }
166        }
167
168        Ok(Self { excl_vat, incl_vat }.into_caveat(warnings))
169    }
170}
171
172impl<'buf> FromSchema<'buf, schema::v221::Price<'buf>> for Price {
173    type Warning = Warning;
174
175    fn from_schema(source: &schema::v221::Price<'buf>) -> Verdict<Self, Self::Warning> {
176        let mut warnings = warning::Set::new();
177
178        // A bare-number price (the 2.1.1 shape) is just `excl_vat`: there is no `incl_vat`
179        // and thus no excl-vs-incl comparison to make.
180        let (elem, excl_vat, incl_vat) = match source {
181            schema::v221::Price::Number(number) => {
182                let excl_vat = Money::from_schema(number)?.gather_warnings_into(&mut warnings);
183                let price = Self {
184                    excl_vat,
185                    incl_vat: None,
186                };
187                return Ok(price.into_caveat(warnings));
188            }
189            schema::v221::Price::Object {
190                elem,
191                excl_vat,
192                incl_vat,
193            } => (elem, excl_vat, incl_vat),
194        };
195
196        // `excl_vat` is required; without a usable value a `Price` cannot be built. A
197        // missing or wrong-kind field is already reported structurally by the schema
198        // walk, so the bail here is the domain-level signal that the price is unusable.
199        let Integrity::Ok(excl_vat) = excl_vat else {
200            return warnings.bail(elem, Warning::MissingExclVatField);
201        };
202        let excl_vat = Money::from_schema(excl_vat)?.gather_warnings_into(&mut warnings);
203
204        // `incl_vat` is optional. Absent or `null` leaves the price without VAT info; a
205        // wrong-kind value (flagged structurally by the schema) is likewise treated as no
206        // VAT rather than failing the whole price.
207        let incl_vat = match incl_vat {
208            Integrity::Ok(Some(number)) => {
209                Some(Money::from_schema(number)?.gather_warnings_into(&mut warnings))
210            }
211            Integrity::Ok(None) | Integrity::Missing | Integrity::Err => None,
212        };
213
214        if let Some(incl_vat) = incl_vat {
215            if excl_vat > incl_vat {
216                warnings.insert(elem, Warning::ExclusiveVatGreaterThanInclusive);
217            }
218        }
219
220        Ok(Self { excl_vat, incl_vat }.into_caveat(warnings))
221    }
222}
223
224impl IsZero for Price {
225    fn is_zero(&self) -> bool {
226        self.excl_vat.is_zero() && self.incl_vat.is_none_or(|v| v.is_zero())
227    }
228}
229
230impl Price {
231    pub fn zero() -> Self {
232        Self {
233            excl_vat: Money::zero(),
234            incl_vat: Some(Money::zero()),
235        }
236    }
237
238    /// Round this number to the OCPI specified amount of decimals.
239    #[must_use]
240    pub fn rescale(self) -> Self {
241        Self {
242            excl_vat: self.excl_vat.rescale(),
243            incl_vat: self.incl_vat.map(Money::rescale),
244        }
245    }
246
247    /// Saturating addition.
248    #[must_use]
249    pub(crate) fn saturating_add(self, rhs: Self) -> Self {
250        let incl_vat = self
251            .incl_vat
252            .zip(rhs.incl_vat)
253            .map(|(lhs, rhs)| lhs.saturating_add(rhs));
254
255        Self {
256            excl_vat: self.excl_vat.saturating_add(rhs.excl_vat),
257            incl_vat,
258        }
259    }
260
261    #[must_use]
262    pub fn round_dp(self, digits: u32) -> Self {
263        Self {
264            excl_vat: self.excl_vat.round_dp(digits),
265            incl_vat: self.incl_vat.map(|v| v.round_dp(digits)),
266        }
267    }
268
269    /// Display a Price with the given currency.
270    pub fn display_currency(&self, currency: currency::Code) -> DisplayPriceCurrency<'_> {
271        DisplayPriceCurrency {
272            currency,
273            price: self,
274        }
275    }
276}
277
278/// Parses a JSON `Element` into a `Price`.
279///
280/// If the `Element` is a JSON `Number` then the value is set to the `Price::excl_vat` field
281/// leaving the `incl_vat` field as `None`.
282pub(crate) struct PriceOrNumber(Price);
283
284impl PriceOrNumber {
285    pub(crate) fn into_inner(self) -> Price {
286        self.0
287    }
288}
289
290impl json::FromJson<'_> for PriceOrNumber {
291    type Warning = Warning;
292
293    fn from_json(elem: &json::Element<'_>) -> Verdict<Self, Self::Warning> {
294        let mut warnings = warning::Set::new();
295        let value = elem.as_value();
296
297        if value.kind() == json::ValueKind::Number {
298            warnings.insert(elem, Warning::invalid_type(elem));
299
300            let excl_vat = Money::from_json(elem)?.gather_warnings_into(&mut warnings);
301            let price = Price {
302                excl_vat,
303                incl_vat: None,
304            };
305            return Ok(Self(price).into_caveat(warnings));
306        }
307
308        let price = Price::from_json(elem).gather_warnings_into(&mut warnings)?;
309        Ok(Self(price).into_caveat(warnings))
310    }
311}
312
313/// A Display object for displaying a `Price` with an associated currency.
314///
315/// Note: The placement of the currency symbol is always before the amount.
316/// The locale is not used to determine symbol position.
317pub struct DisplayPriceCurrency<'a> {
318    currency: currency::Code,
319    price: &'a Price,
320}
321
322impl fmt::Display for DisplayPriceCurrency<'_> {
323    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
324        if let Some(incl_vat) = self.price.incl_vat {
325            write!(
326                f,
327                "{{ -vat: {:#}, +vat: {:#} }}",
328                self.price.excl_vat, incl_vat
329            )
330        } else {
331            fmt::Display::fmt(&self.price.excl_vat.display_currency(self.currency), f)
332        }
333    }
334}
335
336impl Default for Price {
337    fn default() -> Self {
338        Self::zero()
339    }
340}
341
342/// A monetary amount, the currency is dependent on the specified tariff.
343#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Ord, PartialOrd)]
344#[cfg_attr(test, derive(serde::Deserialize))]
345pub struct Money(Decimal);
346
347impl_dec_newtype!(Money, "ยค");
348
349impl IsZero for Money {
350    fn is_zero(&self) -> bool {
351        const TOLERANCE: Decimal = dec!(0.01);
352
353        approx_eq_dec(&self.0, &Decimal::ZERO, TOLERANCE)
354    }
355}
356
357impl Money {
358    #[must_use]
359    pub(crate) const fn zero() -> Self {
360        Self(Decimal::ZERO)
361    }
362
363    /// Apply a VAT percentage to this monetary amount.
364    #[must_use]
365    pub fn apply_vat(self, vat: Vat) -> Self {
366        const ONE: Decimal = dec!(1);
367
368        let x = vat.as_unit_interval().saturating_add(ONE);
369        Self(self.0.saturating_mul(x))
370    }
371
372    /// Display Money with the given currency.
373    pub fn display_currency(&self, currency: currency::Code) -> DisplayCurrency<'_> {
374        DisplayCurrency {
375            currency,
376            money: self,
377        }
378    }
379}
380
381/// A Display object for displaying `Money` with an associated currency.
382///
383/// Note: The placement of the currency symbol is always before the amount.
384/// The locale is not used to determine symbol position.
385pub struct DisplayCurrency<'a> {
386    currency: currency::Code,
387    money: &'a Money,
388}
389
390impl fmt::Display for DisplayCurrency<'_> {
391    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392        write!(f, "{}{:#}", self.currency.into_symbol(), self.money)
393    }
394}
395
396/// A VAT percentage.
397#[derive(Debug, PartialEq, Eq, Clone, Copy)]
398pub struct Vat(Decimal);
399
400impl_dec_newtype!(Vat, "%");
401
402impl Vat {
403    #[expect(clippy::missing_panics_doc, reason = "The divisor is non-zero")]
404    pub fn as_unit_interval(self) -> Decimal {
405        const PERCENT: Decimal = dec!(100);
406
407        self.0.checked_div(PERCENT).expect("divisor is non-zero")
408    }
409}
410
411/// The origin of a potential VAT percentage.
412#[derive(Clone, Copy, Debug)]
413pub(crate) enum VatOrigin {
414    /// The VAT percentage is unknown as the tariff is v211 and has no `vat` field.
415    ///
416    /// NOTE: All `incl_vat` fields should be `None` in the final calculation.
417    Unknown,
418
419    /// The tariff could have a `vat` field but a value is not provided.
420    ///
421    /// NOTE: The total `incl_vat` should be equal to `excl_vat`.
422    NotProvided,
423
424    /// The tariff could have a `vat` field and a value is provided.
425    Provided(Vat),
426}
427
428impl json::FromJson<'_> for VatOrigin {
429    type Warning = number::Warning;
430
431    fn from_json(elem: &'_ json::Element<'_>) -> Verdict<Self, Self::Warning> {
432        let vat = Decimal::from_json(elem)?;
433        Ok(vat.map(|d| Self::Provided(Vat::from_decimal(d))))
434    }
435}
436
437impl<'buf> FromSchema<'buf, schema::Number<'buf>> for VatOrigin {
438    type Warning = number::Warning;
439
440    fn from_schema(source: &schema::Number<'buf>) -> Verdict<Self, Self::Warning> {
441        let vat = Decimal::from_schema(source)?;
442        Ok(vat.map(|d| Self::Provided(Vat::from_decimal(d))))
443    }
444}