Skip to main content

ocpi_tariffs/
money.rs

1//! Various monetary types.
2use std::{borrow::Cow, fmt};
3
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6
7use crate::{
8    from_warning_all, impl_dec_newtype, into_caveat,
9    json::{self, FieldsAsExt as _},
10    number::{self, FromDecimal as _, RoundDecimal},
11    warning::{self, GatherWarnings as _, IntoCaveat},
12    SaturatingAdd as _, Verdict,
13};
14
15/// An item that has a cost.
16pub trait Cost: Copy {
17    /// The cost of this dimension at a certain price.
18    fn cost(&self, money: Money) -> Money;
19}
20
21impl Cost for () {
22    fn cost(&self, money: Money) -> Money {
23        money
24    }
25}
26
27/// The warnings that can happen when parsing or linting a monetary value.
28#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
29pub enum WarningKind {
30    /// The `excl_vat` field is greater than the `incl_vat` field.
31    ExclusiveVatGreaterThanInclusive,
32
33    /// The JSON value given is not an object.
34    InvalidType,
35
36    /// The `excl_vat` field is required.
37    MissingExclVatField,
38
39    /// Both the `excl_vat` and `incl_vat` fields should be valid numbers.
40    Number(number::WarningKind),
41}
42
43impl fmt::Display for WarningKind {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            WarningKind::ExclusiveVatGreaterThanInclusive => write!(
47                f,
48                "The `excl_vat` field is greater than the `incl_vat` field"
49            ),
50            WarningKind::InvalidType => write!(f, "The value should be a number."),
51            WarningKind::MissingExclVatField => write!(f, "The `excl_vat` field is required."),
52            WarningKind::Number(kind) => fmt::Display::fmt(kind, f),
53        }
54    }
55}
56
57impl warning::Kind for WarningKind {
58    fn id(&self) -> Cow<'static, str> {
59        match self {
60            WarningKind::ExclusiveVatGreaterThanInclusive => {
61                "exclusive_vat_greater_than_inclusive".into()
62            }
63            WarningKind::InvalidType => "invalid_type".into(),
64            WarningKind::MissingExclVatField => "missing_excl_vat_field".into(),
65            WarningKind::Number(kind) => kind.id(),
66        }
67    }
68}
69
70from_warning_all!(number::WarningKind => WarningKind::Number);
71
72/// A price consisting of a value including VAT, and a value excluding VAT.
73#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd)]
74#[cfg_attr(test, derive(serde::Deserialize))]
75pub struct Price {
76    /// The price excluding VAT.
77    pub excl_vat: Money,
78
79    /// The price including VAT.
80    ///
81    /// If no vat is applicable this value will be equal to the `excl_vat`.
82    ///
83    /// If no vat could be determined this value will be `None`.
84    /// The v211 tariffs can't determine VAT.
85    #[cfg_attr(test, serde(default))]
86    pub incl_vat: Option<Money>,
87}
88
89impl RoundDecimal for Price {
90    fn round_to_ocpi_scale(self) -> Self {
91        let Self { excl_vat, incl_vat } = self;
92        Self {
93            excl_vat: excl_vat.round_to_ocpi_scale(),
94            incl_vat: incl_vat.round_to_ocpi_scale(),
95        }
96    }
97}
98
99impl fmt::Display for Price {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        if let Some(incl_vat) = self.incl_vat {
102            write!(f, "{{ -vat: {}, +vat: {} }}", self.excl_vat, incl_vat)
103        } else {
104            fmt::Display::fmt(&self.excl_vat, f)
105        }
106    }
107}
108
109impl json::FromJson<'_, '_> for Price {
110    type WarningKind = WarningKind;
111
112    fn from_json(elem: &json::Element<'_>) -> Verdict<Self, Self::WarningKind> {
113        let mut warnings = warning::Set::new();
114        let value = elem.as_value();
115
116        let Some(fields) = value.as_object_fields() else {
117            return warnings.bail(WarningKind::InvalidType, elem);
118        };
119
120        let Some(excl_vat) = fields.find_field("excl_vat") else {
121            return warnings.bail(WarningKind::MissingExclVatField, elem);
122        };
123
124        let excl_vat = Money::from_json(excl_vat.element())?.gather_warnings_into(&mut warnings);
125
126        let incl_vat = fields
127            .find_field("incl_vat")
128            .map(|f| Money::from_json(f.element()))
129            .transpose()?
130            .gather_warnings_into(&mut warnings);
131
132        if let Some(incl_vat) = incl_vat {
133            if excl_vat > incl_vat {
134                warnings.with_elem(WarningKind::ExclusiveVatGreaterThanInclusive, elem);
135            }
136        }
137
138        Ok(Self { excl_vat, incl_vat }.into_caveat(warnings))
139    }
140}
141
142into_caveat!(Price);
143
144impl Price {
145    pub fn zero() -> Self {
146        Self {
147            excl_vat: Money::zero(),
148            incl_vat: Some(Money::zero()),
149        }
150    }
151
152    pub fn is_zero(&self) -> bool {
153        self.excl_vat.is_zero() && self.incl_vat.is_none_or(|v| v.is_zero())
154    }
155
156    /// Round this number to the OCPI specified amount of decimals.
157    #[must_use]
158    pub fn rescale(self) -> Self {
159        Self {
160            excl_vat: self.excl_vat.rescale(),
161            incl_vat: self.incl_vat.map(Money::rescale),
162        }
163    }
164
165    /// Saturating addition.
166    #[must_use]
167    pub(crate) fn saturating_add(self, rhs: Self) -> Self {
168        let incl_vat = self
169            .incl_vat
170            .zip(rhs.incl_vat)
171            .map(|(lhs, rhs)| lhs.saturating_add(rhs));
172
173        Self {
174            excl_vat: self.excl_vat.saturating_add(rhs.excl_vat),
175            incl_vat,
176        }
177    }
178
179    #[must_use]
180    pub fn round_dp(self, digits: u32) -> Self {
181        Self {
182            excl_vat: self.excl_vat.round_dp(digits),
183            incl_vat: self.incl_vat.map(|v| v.round_dp(digits)),
184        }
185    }
186}
187
188impl Default for Price {
189    fn default() -> Self {
190        Self::zero()
191    }
192}
193
194/// A monetary amount, the currency is dependent on the specified tariff.
195#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Ord, PartialOrd)]
196#[cfg_attr(test, derive(serde::Deserialize))]
197pub struct Money(Decimal);
198
199impl_dec_newtype!(Money, "ยค");
200
201impl Money {
202    /// Apply a VAT percentage to this monetary amount.
203    #[must_use]
204    pub fn apply_vat(self, vat: Vat) -> Self {
205        const ONE: Decimal = dec!(1);
206
207        let x = vat.as_unit_interval().saturating_add(ONE);
208        Self(self.0.saturating_mul(x))
209    }
210}
211
212/// A VAT percentage.
213#[derive(Debug, PartialEq, Eq, Clone, Copy)]
214pub struct Vat(Decimal);
215
216impl_dec_newtype!(Vat, "%");
217
218impl Vat {
219    #[expect(clippy::missing_panics_doc, reason = "The divisor is non-zero")]
220    pub fn as_unit_interval(self) -> Decimal {
221        const PERCENT: Decimal = dec!(100);
222
223        self.0.checked_div(PERCENT).expect("divisor is non-zero")
224    }
225}
226
227/// A VAT percentage with embedded information about whether it's applicable, inapplicable or unknown.
228#[derive(Clone, Copy, Debug)]
229pub enum VatApplicable {
230    /// The VAT percentage is not known.
231    ///
232    /// All `incl_vat` fields should be `None` in the final calculation.
233    Unknown,
234
235    /// The VAT is known but not applicable.
236    ///
237    /// The total `incl_vat` should be equal to `excl_vat`.
238    Inapplicable,
239
240    /// The VAT us known and applicable.
241    Applicable(Vat),
242}
243
244impl json::FromJson<'_, '_> for VatApplicable {
245    type WarningKind = number::WarningKind;
246
247    fn from_json(elem: &'_ json::Element<'_>) -> Verdict<Self, Self::WarningKind> {
248        let vat = Decimal::from_json(elem)?;
249        Ok(vat.map(|d| Self::Applicable(Vat::from_decimal(d))))
250    }
251}
252
253#[cfg(test)]
254mod test {
255    use crate::test::ApproxEq;
256
257    use super::Price;
258
259    impl ApproxEq for Price {
260        fn approx_eq(&self, other: &Self) -> bool {
261            let incl_eq = match (self.incl_vat, other.incl_vat) {
262                (Some(a), Some(b)) => a.approx_eq(&b),
263                (None, None) => true,
264                _ => return false,
265            };
266
267            incl_eq && self.excl_vat.approx_eq(&other.excl_vat)
268        }
269    }
270}
271
272#[cfg(test)]
273mod test_price {
274    use assert_matches::assert_matches;
275    use rust_decimal::Decimal;
276    use rust_decimal_macros::dec;
277
278    use crate::json::{self, FromJson as _};
279
280    use super::{Price, WarningKind};
281
282    #[test]
283    fn should_create_from_json_with_only_excl_vat_field() {
284        const JSON: &str = r#"{
285            "excl_vat": 10.2
286        }"#;
287
288        let elem = json::parse(JSON).unwrap();
289        let price = Price::from_json(&elem).unwrap().unwrap();
290
291        assert!(price.incl_vat.is_none());
292        assert_eq!(Decimal::from(price.excl_vat), dec!(10.2));
293    }
294
295    #[test]
296    fn should_create_from_json_with_excl_and_incl_vat_fields() {
297        const JSON: &str = r#"{
298            "excl_vat": 10.2,
299            "incl_vat": 12.3
300        }"#;
301
302        let elem = json::parse(JSON).unwrap();
303        let price = Price::from_json(&elem).unwrap().unwrap();
304
305        assert_eq!(Decimal::from(price.incl_vat.unwrap()), dec!(12.3));
306        assert_eq!(Decimal::from(price.excl_vat), dec!(10.2));
307    }
308
309    #[test]
310    fn should_fail_to_create_from_non_object_json() {
311        const JSON: &str = "12.3";
312
313        let elem = json::parse(JSON).unwrap();
314        let warnings = Price::from_json(&elem).unwrap_err().into_kind_vec();
315
316        assert_matches!(*warnings, [WarningKind::InvalidType]);
317    }
318
319    #[test]
320    fn should_fail_to_create_from_json_as_excl_vat_is_required() {
321        const JSON: &str = r#"{
322            "incl_vat": 12.3
323        }"#;
324
325        let elem = json::parse(JSON).unwrap();
326        let warnings = Price::from_json(&elem).unwrap_err().into_kind_vec();
327
328        assert_matches!(*warnings, [WarningKind::MissingExclVatField]);
329    }
330
331    #[test]
332    fn should_create_from_json_and_warn_about_excl_vat_greater_than_incl_vat() {
333        const JSON: &str = r#"{
334            "excl_vat": 12.3,
335            "incl_vat": 10.2
336        }"#;
337
338        let elem = json::parse(JSON).unwrap();
339        let (_price, warnings) = Price::from_json(&elem).unwrap().into_parts();
340        let warnings = warnings.into_kind_vec();
341
342        assert_matches!(*warnings, [WarningKind::ExclusiveVatGreaterThanInclusive]);
343    }
344}