Skip to main content

ocpi_kit/types/
number.rs

1//! `Number` — the OCPI decimal number type. Never `f64` in a field, never `f64` in arithmetic.
2
3use core::fmt;
4use core::str::FromStr;
5
6use rust_decimal::Decimal;
7use rust_decimal::prelude::ToPrimitive;
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9
10use super::validate::{Validate, Validator, ViolationCode};
11
12/// An OCPI `number`: an exact decimal.
13///
14/// > *Numbers in OCPI are formatted as JSON numbers. Unless mentioned otherwise, numbers use 4
15/// > decimals and a sufficiently large amount of digits.*
16///
17/// # Why not `f64`
18///
19/// Every price, VAT percentage, energy volume and tax amount in OCPI ends up on an invoice. A
20/// binary float cannot represent `0.10` and cannot add a column of cents without drift, so every
21/// arithmetic operation in this crate — the whole [`tariffs`](crate::tariffs) engine included —
22/// runs on [`rust_decimal::Decimal`]. `f32`/`f64` are denied by lint in the modules where money
23/// lives, and no public field of any OCPI object in this crate is a float.
24///
25/// # The JSON boundary
26///
27/// The spec requires these values to be JSON *numbers*, not strings. `serde_json` represents a
28/// fractional JSON number as an `f64` unless its `arbitrary_precision` feature is enabled — a
29/// feature that changes `serde_json::Value` globally for every crate in the build, so
30/// `ocpi-kit` does not impose it. The boundary therefore behaves as follows:
31///
32/// * Integral values pass through exactly, as JSON integers.
33/// * Fractional values with at most 15 significant decimal digits — which covers OCPI's entire
34///   domain of prices, energies and percentages with room to spare — pass through exactly,
35///   because the shortest decimal that round-trips an `f64` *is* the original decimal.
36/// * Beyond that, a round-trip rounds to the nearest `f64`. [`Number::json_round_trips`] says
37///   whether a given value is affected and [`Validate::validate`] reports it as
38///   [`ViolationCode::Imprecise`], so this can never happen silently.
39///
40/// A peer that sends a number as a JSON *string* (`"0.25"`) is tolerated on input and parsed
41/// exactly; output is always a JSON number.
42///
43/// ```
44/// use ocpi_kit::types::Number;
45///
46/// let price: Number = "0.2500".parse().unwrap();
47/// assert_eq!(serde_json::to_string(&price).unwrap(), "0.25");
48/// let vat: Number = serde_json::from_str("20").unwrap();
49/// assert_eq!(serde_json::to_string(&vat).unwrap(), "20");
50/// ```
51///
52/// Spec: 2.3.0 §types_number_type
53#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
54pub struct Number(Decimal);
55
56impl Number {
57    /// Zero.
58    pub const ZERO: Self = Self(Decimal::ZERO);
59    /// One.
60    pub const ONE: Self = Self(Decimal::ONE);
61
62    /// Wraps a [`Decimal`].
63    #[must_use]
64    pub const fn new(value: Decimal) -> Self {
65        Self(value)
66    }
67
68    /// The underlying [`Decimal`].
69    #[must_use]
70    pub const fn get(self) -> Decimal {
71        self.0
72    }
73
74    /// The number of digits after the decimal point.
75    #[must_use]
76    pub fn scale(self) -> u32 {
77        self.0.scale()
78    }
79
80    /// Rounds to `dp` decimal places, half away from zero.
81    #[must_use]
82    pub fn round_dp(self, dp: u32) -> Self {
83        Self(self.0.round_dp_with_strategy(dp, rust_decimal::RoundingStrategy::MidpointAwayFromZero))
84    }
85
86    /// Whether this value survives a JSON round-trip unchanged.
87    ///
88    /// See [the type documentation](Self#the-json-boundary). `false` only for values with more
89    /// significant digits than an `f64` can carry.
90    #[must_use]
91    pub fn json_round_trips(self) -> bool {
92        if self.0.is_integer() && self.0.to_i64().is_some() {
93            return true;
94        }
95        self.0.to_f64().and_then(decimal_from_f64).is_some_and(|d| d == self.0.normalize())
96    }
97
98    /// Whether the value is zero.
99    #[must_use]
100    pub fn is_zero(self) -> bool {
101        self.0.is_zero()
102    }
103
104    /// Whether the value is strictly negative.
105    #[must_use]
106    pub fn is_negative(self) -> bool {
107        self.0.is_sign_negative() && !self.0.is_zero()
108    }
109}
110
111impl Validate for Number {
112    fn validate_in(&self, v: &mut Validator) {
113        if !self.json_round_trips() {
114            v.report(
115                ViolationCode::Imprecise,
116                format!("{} carries more significant digits than a JSON number round-trip preserves", self.0),
117            );
118        }
119    }
120}
121
122impl fmt::Display for Number {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        fmt::Display::fmt(&self.0, f)
125    }
126}
127
128impl fmt::Debug for Number {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        write!(f, "Number({})", self.0)
131    }
132}
133
134impl From<Decimal> for Number {
135    fn from(value: Decimal) -> Self {
136        Self(value)
137    }
138}
139impl From<Number> for Decimal {
140    fn from(value: Number) -> Self {
141        value.0
142    }
143}
144
145macro_rules! from_int {
146    ($($t:ty),*) => {$(
147        impl From<$t> for Number {
148            fn from(value: $t) -> Self { Self(Decimal::from(value)) }
149        }
150    )*};
151}
152from_int!(i8, i16, i32, i64, u8, u16, u32, u64);
153
154impl core::ops::Add for Number {
155    type Output = Self;
156    fn add(self, rhs: Self) -> Self {
157        Self(self.0 + rhs.0)
158    }
159}
160impl core::ops::Sub for Number {
161    type Output = Self;
162    fn sub(self, rhs: Self) -> Self {
163        Self(self.0 - rhs.0)
164    }
165}
166impl core::ops::Mul for Number {
167    type Output = Self;
168    fn mul(self, rhs: Self) -> Self {
169        Self(self.0 * rhs.0)
170    }
171}
172impl core::ops::Div for Number {
173    type Output = Self;
174    fn div(self, rhs: Self) -> Self {
175        Self(self.0 / rhs.0)
176    }
177}
178impl core::ops::Neg for Number {
179    type Output = Self;
180    fn neg(self) -> Self {
181        Self(-self.0)
182    }
183}
184impl core::iter::Sum for Number {
185    fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
186        iter.fold(Self::ZERO, core::ops::Add::add)
187    }
188}
189
190/// The exact decimal that an `f64` came from.
191///
192/// `serde_json` hands a fractional JSON number over as an `f64`, so this is the one place where
193/// a float touches a value that will end up on an invoice. Getting it back to a decimal has to
194/// be exact.
195///
196/// The obvious route, `Decimal::try_from(f64)`, is **not** exact: it is wrong for roughly one in
197/// two thousand four-decimal values in OCPI's ordinary range, turning `4106.9638` into
198/// `4106.963800000001`. Rust's `{}` for `f64` instead prints the *shortest decimal string that
199/// round-trips the value*, which for anything serde_json could have parsed from at most 15
200/// significant digits is exactly the decimal the peer wrote. Parsing that string exactly is
201/// therefore both correct and total.
202///
203/// Returns `None` for a value no `Decimal` can hold — infinities, NaN, and magnitudes beyond
204/// 96 bits — none of which are OCPI numbers.
205fn decimal_from_f64(value: f64) -> Option<Decimal> {
206    if !value.is_finite() {
207        return None;
208    }
209    // `f64::to_string` never uses exponent notation, so this is always a plain decimal literal.
210    Decimal::from_str_exact(&value.to_string()).ok()
211}
212
213/// Why a decimal could not be parsed.
214#[derive(Clone, Debug, PartialEq, Eq)]
215pub struct InvalidNumber(String);
216
217impl fmt::Display for InvalidNumber {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        write!(f, "invalid OCPI number: {}", self.0)
220    }
221}
222impl std::error::Error for InvalidNumber {}
223
224impl FromStr for Number {
225    type Err = InvalidNumber;
226    fn from_str(s: &str) -> Result<Self, Self::Err> {
227        Decimal::from_str_exact(s).map(Self).map_err(|e| InvalidNumber(format!("{s:?}: {e}")))
228    }
229}
230
231impl Serialize for Number {
232    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
233        use serde::ser::Error as _;
234        if self.0.is_integer() {
235            if let Some(i) = self.0.to_i64() {
236                return serializer.serialize_i64(i);
237            }
238            if let Some(u) = self.0.to_u64() {
239                return serializer.serialize_u64(u);
240            }
241        }
242        let f = self
243            .0
244            .to_f64()
245            .ok_or_else(|| S::Error::custom(format!("{} is not representable as a JSON number", self.0)))?;
246        serializer.serialize_f64(f)
247    }
248}
249
250impl<'de> Deserialize<'de> for Number {
251    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
252        struct V;
253        impl serde::de::Visitor<'_> for V {
254            type Value = Number;
255            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256                f.write_str("a JSON number")
257            }
258            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Number, E> {
259                Ok(Number(Decimal::from(v)))
260            }
261            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Number, E> {
262                Ok(Number(Decimal::from(v)))
263            }
264            fn visit_i128<E: serde::de::Error>(self, v: i128) -> Result<Number, E> {
265                Ok(Number(Decimal::from(v)))
266            }
267            fn visit_u128<E: serde::de::Error>(self, v: u128) -> Result<Number, E> {
268                Ok(Number(Decimal::from(v)))
269            }
270            fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Number, E> {
271                decimal_from_f64(v)
272                    .map(Number)
273                    .ok_or_else(|| E::custom(format!("{v} is not representable as an OCPI number")))
274            }
275            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Number, E> {
276                // Tolerated: some peers quote their numbers. Parsed exactly, emitted unquoted.
277                Number::from_str(v).map_err(E::custom)
278            }
279        }
280        deserializer.deserialize_any(V)
281    }
282}
283
284#[cfg(feature = "schema")]
285impl schemars::JsonSchema for Number {
286    fn schema_name() -> std::borrow::Cow<'static, str> {
287        "Number".into()
288    }
289    fn json_schema(_g: &mut schemars::SchemaGenerator) -> schemars::Schema {
290        schemars::json_schema!({
291            "type": "number",
292            "description": "OCPI number: an exact decimal, serialised as a JSON number",
293        })
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    fn n(s: &str) -> Number {
302        s.parse().unwrap()
303    }
304
305    #[test]
306    fn integers_stay_integers_on_the_wire() {
307        assert_eq!(serde_json::to_string(&n("20")).unwrap(), "20");
308        assert_eq!(serde_json::to_string(&n("0")).unwrap(), "0");
309        assert_eq!(serde_json::to_string(&n("-7")).unwrap(), "-7");
310    }
311
312    #[test]
313    fn realistic_ocpi_values_round_trip_exactly() {
314        for text in ["0.25", "0.0295", "2.5", "20.45", "0.0002", "123456789.1234", "-1.05"] {
315            let parsed: Number = serde_json::from_str(text).unwrap();
316            assert_eq!(serde_json::to_string(&parsed).unwrap(), text, "round-trip of {text}");
317            assert!(parsed.json_round_trips());
318            assert!(parsed.validate().is_ok());
319        }
320    }
321
322    #[test]
323    fn trailing_zeros_are_normalised_away() {
324        // 0.2500 and 0.25 are the same number; JSON has no way to distinguish them.
325        let parsed: Number = "0.2500".parse().unwrap();
326        assert_eq!(serde_json::to_string(&parsed).unwrap(), "0.25");
327        assert_eq!(parsed, n("0.25"));
328    }
329
330    #[test]
331    fn a_fractional_number_decodes_to_exactly_what_the_peer_wrote() {
332        // `Decimal::try_from(f64)` renders these as `…000000001`. They are ordinary prices, and
333        // getting them wrong would put a spurious digit on an invoice — and, because
334        // `json_round_trips` used the same conversion, would also report them as imprecise.
335        for text in ["4106.9638", "4112.654", "4130.8379", "4136.529", "4163.9629", "4291.154"] {
336            let parsed: Number = serde_json::from_str(text).unwrap();
337            assert_eq!(parsed, n(text), "decoding {text}");
338            assert_eq!(serde_json::to_string(&parsed).unwrap(), text, "re-encoding {text}");
339            assert!(parsed.json_round_trips(), "{text} does survive a round-trip");
340            assert!(parsed.validate().is_ok(), "{text} is a perfectly ordinary number");
341        }
342    }
343
344    #[test]
345    fn every_four_decimal_value_in_ocpi_range_survives_the_boundary() {
346        // A sweep rather than an example, because the failures are sparse: about one in two
347        // thousand. Anything that regresses this conversion will trip here.
348        let mut mantissa = 1i64;
349        while mantissa < 100_000_000 {
350            let value = Number::new(Decimal::new(mantissa, 4));
351            let json = serde_json::to_string(&value).unwrap();
352            let back: Number = serde_json::from_str(&json).unwrap();
353            assert_eq!(back, value, "{value} round-tripped through {json} as {back}");
354            assert!(value.json_round_trips(), "{value}");
355            mantissa += 1237;
356        }
357    }
358
359    #[test]
360    fn a_value_that_is_not_a_number_is_refused() {
361        assert!(decimal_from_f64(f64::NAN).is_none());
362        assert!(decimal_from_f64(f64::INFINITY).is_none());
363        assert!(decimal_from_f64(f64::MAX).is_none(), "beyond what a Decimal can hold");
364        assert_eq!(decimal_from_f64(0.0), Some(Decimal::ZERO));
365    }
366
367    #[test]
368    fn excess_precision_is_flagged_rather_than_hidden() {
369        let precise = n("0.123456789012345678901234");
370        assert!(!precise.json_round_trips());
371        assert_eq!(precise.validate().unwrap_err().as_slice()[0].code, ViolationCode::Imprecise);
372    }
373
374    #[test]
375    fn quoted_numbers_are_tolerated_on_input() {
376        let parsed: Number = serde_json::from_str("\"0.25\"").unwrap();
377        assert_eq!(parsed, n("0.25"));
378        assert_eq!(serde_json::to_string(&parsed).unwrap(), "0.25");
379    }
380
381    #[test]
382    fn arithmetic_is_exact() {
383        let sum: Number = ["0.1", "0.2"].into_iter().map(n).sum();
384        assert_eq!(sum, n("0.3"), "0.1 + 0.2 is exactly 0.3 in decimal");
385    }
386}