Skip to main content

ocpi_tariffs/
cdr.rs

1//! Parse a CDR and price the result with a tariff.
2
3#[cfg(test)]
4mod test_every_field_set;
5
6#[cfg(test)]
7mod test_tariffs;
8
9use std::fmt;
10
11use chrono_tz::Tz;
12
13use crate::{
14    generate, guess, json, price, schema, tariff,
15    warning::{self, Caveat, GatherWarnings as _, IntoCaveat as _},
16    FromSchema as _, Verdict,
17};
18
19/// Infer which OCPI [`Version`] a CDR [`json::Document`] is, without validating it.
20///
21/// Use this when the version of the CDR is not known up front. The [`json::Document`] is obtained
22/// by calling [`json::parse_object`]. The returned [`guess::CdrVersion`] is either
23/// [`Certain`](guess::Version::Certain) or [`Uncertain`](guess::Version::Uncertain) about the version.
24///
25/// To check the CDR against the OCPI schema for a known [`Version`], use [`from_json`].
26///
27/// # Example
28///
29/// ```rust
30/// # use ocpi_tariffs::{cdr, json, Version};
31/// #
32/// # const CDR_JSON: &str = include_str!("cdr.json");
33///
34/// let doc = json::parse_object(CDR_JSON)?;
35/// let cdr = cdr::infer_version(doc).certain_or(Version::V211);
36///
37/// # Ok::<(), json::ParseError>(())
38/// ```
39pub fn infer_version(json: json::Document<'_>) -> guess::CdrVersion<'_> {
40    guess::cdr_version(json)
41}
42
43/// Validate a [`json::Document`] against the OCPI CDR schema for the given [`Version`][^spec-v211][^spec-v221].
44///
45/// The [`json::Document`] is obtained by calling [`json::parse_object`]. Any unexpected, missing,
46/// or wrongly typed fields are reported as a [`warning::Set`](crate::warning::Set) of
47/// [`schema::Warning`]s carried by the returned [`Caveat`].
48///
49/// # Example
50///
51/// ```rust
52/// # use ocpi_tariffs::{cdr, json, Version};
53/// #
54/// # const CDR_JSON: &str = include_str!("cdr.json");
55///
56/// let doc = json::parse_object(CDR_JSON)?;
57/// let (cdr, warnings) = cdr::from_json(doc, Version::V211).into_parts();
58///
59/// if !warnings.is_empty() {
60///     eprintln!("The CDR has `{}` schema warnings.", warnings.len_warnings());
61/// }
62///
63/// # Ok::<(), json::ParseError>(())
64/// ```
65///
66/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_cdrs.md>.
67/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc>.
68pub fn from_json(
69    json: json::Document<'_>,
70    version: crate::Version,
71) -> Caveat<Versioned<'_>, schema::Warning> {
72    let (version, warnings) = match version {
73        crate::Version::V221 => {
74            let (cdr, warnings) = schema::v221::build_cdr(&json).into_parts();
75            (Version::V221(cdr), warnings)
76        }
77        crate::Version::V211 => {
78            let (cdr, warnings) = schema::v211::build_cdr(&json).into_parts();
79            (Version::V211(cdr), warnings)
80        }
81    };
82    let versioned = Versioned { doc: json, version };
83    versioned.into_caveat(warnings)
84}
85
86/// Validate a [`VersionedJson`] against the OCPI CDR schema for its known [`Version`].
87///
88/// Use this when the [`Version`] has already been resolved - for example a
89/// [`VersionedJson`] obtained from [`infer_version`] via [`certain_or`](guess::Version::certain_or).
90pub fn from_versioned_json(json: VersionedJson<'_>) -> Caveat<Versioned<'_>, schema::Warning> {
91    let VersionedJson { doc, version } = json;
92    from_json(doc, version)
93}
94
95/// Generate a [`PartialCdr`](generate::PartialCdr) that can be priced by the given tariff.
96///
97/// The CDR is partial as not all required fields are set as the `cdr_from_tariff` function
98/// does not know anything about the EVSE location or the token used to authenticate the chargesession.
99///
100/// * See: [OCPI spec 2.2.1: CDR](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc>)
101pub fn generate_from_tariff(
102    tariff: &tariff::Versioned<'_>,
103    config: &generate::Config,
104) -> Verdict<generate::Report, generate::Warning> {
105    generate::cdr_from_tariff(tariff, config)
106}
107
108/// Price a single `CDR` and return a [`Report`](price::Report).
109///
110/// The `CDR` is checked for internal consistency before being priced. As pricing a `CDR` with
111/// contradictory data will lead to a difficult to debug [`Report`](price::Report).
112/// An [`Error`](price::Warning) is returned if the `CDR` is deemed to be internally inconsistent.
113///
114/// > **_Note_** Pricing the CDR does not require a spec compliant CDR or tariff.
115/// > A best effort is made to parse the given CDR and tariff JSON.
116///
117/// The [`Report`](price::Report) contains the charge session priced according to the specified
118/// tariff and a selection of fields from the source `CDR` that can be used for comparing the
119/// source `CDR` totals with the calculated totals. The [`Report`](price::Report) also contains
120/// a list of unknown fields to help spot misspelled fields.
121///
122/// The source of the tariffs can be controlled using the [`TariffSource`](price::TariffSource).
123/// The timezone can be found or inferred using the [`timezone::find_or_infer`](crate::timezone::find_or_infer) function.
124///
125/// # Example
126///
127/// ```rust
128/// # use ocpi_tariffs::{cdr, json, price, warning, Version};
129/// #
130/// # const CDR_JSON: &str = include_str!("cdr.json");
131///
132/// let doc = json::parse_object(CDR_JSON)?;
133/// let (cdr, _warnings) = cdr::from_json(doc, Version::V211).into_parts();
134///
135/// let report = cdr::price(&cdr, price::TariffSource::UseCdr, chrono_tz::Tz::Europe__Amsterdam).unwrap();
136/// let (report, warnings) = report.into_parts();
137///
138/// if !warnings.is_empty() {
139///     eprintln!("Pricing the CDR resulted in `{}` warnings", warnings.len_warnings());
140///
141///     for group in warnings {
142///         let (element, warnings) = group.to_parts();
143///         eprintln!("  {}", element.path);
144///
145///         for warning in warnings {
146///             eprintln!("    - {warning}");
147///         }
148///     }
149/// }
150///
151/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
152/// ```
153pub fn price(
154    cdr: &Versioned<'_>,
155    tariff_source: price::TariffSource<'_>,
156    timezone: Tz,
157) -> Verdict<price::Report, price::Warning> {
158    price::cdr(cdr, tariff_source, timezone)
159}
160
161/// A `json::Element` that has been processed by either the [`infer_version`] or [`from_json`]
162/// functions and has been identified as being a certain [`Version`].
163#[derive(Clone)]
164pub struct Versioned<'buf> {
165    /// The parsed JSON.
166    doc: json::Document<'buf>,
167
168    /// The `Version` of the tariff, determined during parsing.
169    version: Version<'buf>,
170}
171
172impl fmt::Debug for Versioned<'_> {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        if f.alternate() {
175            match &self.version {
176                Version::V211(cdr) => fmt::Debug::fmt(&cdr, f),
177                Version::V221(cdr) => fmt::Debug::fmt(&cdr, f),
178            }
179        } else {
180            match &self.version {
181                Version::V211(_) => f.write_str("V211"),
182                Version::V221(_) => f.write_str("V221"),
183            }
184        }
185    }
186}
187
188impl crate::Versioned for Versioned<'_> {
189    fn version(&self) -> crate::Version {
190        match self.version {
191            Version::V211(_) => crate::Version::V211,
192            Version::V221(_) => crate::Version::V221,
193        }
194    }
195}
196
197impl<'buf> Versioned<'buf> {
198    /// Lower the schema IR into the "normalized" `v221` CDR.
199    ///
200    /// A `v211` CDR is parsed as `v211` and then converted, because the two versions differ
201    /// in more than field names.
202    ///
203    /// The two whole-CDR checks live here rather than in the lowering: both compare fields
204    /// against each other, and only this layer has an element to anchor a warning to. A CDR
205    /// with no charging periods is rejected, because there is nothing to price; the empty
206    /// array itself is located by the schema walk's `Cardinality` warning.
207    pub(crate) fn to_v221(&self) -> Verdict<price::v221::Cdr, price::Warning> {
208        let mut warnings = warning::Set::new();
209
210        let cdr = match &self.version {
211            Version::V211(cdr) => {
212                let cdr = price::v211::Cdr::from_schema(cdr)?.gather_warnings_into(&mut warnings);
213
214                price::v221::Cdr::from(cdr)
215            }
216            Version::V221(cdr) => {
217                price::v221::Cdr::from_schema(cdr)?.gather_warnings_into(&mut warnings)
218            }
219        };
220
221        if cdr.charging_periods.is_empty() {
222            return warnings.bail(self.as_element(), price::Warning::NoPeriods);
223        }
224
225        let cdr_range = cdr.start_date_time..cdr.end_date_time;
226
227        // The periods are sorted by the lowering above, so the first and last periods bound the range.
228        let period_range = match cdr.charging_periods.as_slice() {
229            [] => None,
230            [period] => Some(price::PeriodRange::Single(period.start_date_time)),
231            [earliest, .., latest] => Some(price::PeriodRange::Many(
232                earliest.start_date_time..latest.start_date_time,
233            )),
234        };
235
236        let outside = match &period_range {
237            None => false,
238            Some(price::PeriodRange::Single(start)) => !cdr_range.contains(start),
239            Some(price::PeriodRange::Many(range)) => {
240                !(cdr_range.contains(&range.start) && cdr_range.contains(&range.end))
241            }
242        };
243
244        if let (true, Some(period_range)) = (outside, period_range) {
245            warnings.insert(
246                self.as_element(),
247                price::Warning::PeriodsOutsideStartEndDateTime {
248                    cdr_range,
249                    period_range,
250                },
251            );
252        }
253
254        Ok(cdr.into_caveat(warnings))
255    }
256
257    /// Lower the tariffs embedded in the CDR into "normalized" `v221` tariffs.
258    ///
259    /// The embedded tariffs are read as the version of the CDR that carries them. A CDR
260    /// without a `tariffs` field yields no tariffs; whether that can be priced is up to the
261    /// caller.
262    /// Each tariff keeps its own warnings, because they are reported per tariff in the
263    /// pricing report.
264    pub(crate) fn tariffs_to_v221(
265        &self,
266    ) -> Verdict<Vec<Caveat<tariff::v221::Tariff<'buf>, tariff::Warning>>, tariff::Warning> {
267        let mut warnings = warning::Set::new();
268        let mut lowered = Vec::new();
269
270        match &self.version {
271            Version::V211(cdr) => {
272                let tariffs = warnings.ok_or_bail(&cdr.tariffs)?;
273                for tariff in tariffs.iter().flatten() {
274                    let tariff = warnings.ok_or_bail(tariff)?;
275                    let tariff = tariff::v211::Tariff::from_schema(tariff)?;
276
277                    lowered.push(tariff.map(tariff::v221::Tariff::from));
278                }
279            }
280            Version::V221(cdr) => {
281                let tariffs = warnings.ok_or_bail(&cdr.tariffs)?;
282                for tariff in tariffs.iter().flatten() {
283                    let tariff = warnings.ok_or_bail(tariff)?;
284
285                    lowered.push(tariff::v221::Tariff::from_schema(tariff)?);
286                }
287            }
288        }
289
290        // A tariff with no elements prices every session at zero, so it is rejected rather
291        // than used; see `tariff::Versioned::to_v221`. The schema walk's `Cardinality`
292        // warning locates which of the embedded tariffs is empty.
293        if lowered.iter().any(|tariff| tariff.elements.is_empty()) {
294            return warnings.bail(self.as_element(), tariff::Warning::NoElements);
295        }
296
297        Ok(lowered.into_caveat(warnings))
298    }
299
300    /// Borrow the schema IR this CDR was built into.
301    ///
302    /// A feature that needs a field the schema models reads it from here rather than
303    /// walking the JSON, so the version differences stay in one match.
304    pub(crate) fn schema(&self) -> &Version<'buf> {
305        &self.version
306    }
307
308    /// Return the inner [`json::Document`] and discard the version info.
309    pub fn into_doc(self) -> json::Document<'buf> {
310        self.doc
311    }
312
313    /// Return the inner [`json::Element`] and discard the version info.
314    pub fn as_element(&self) -> &json::Element<'buf> {
315        self.doc.root()
316    }
317
318    /// Return the inner [`json::Document`] and discard the version info.
319    pub fn as_doc(&self) -> &json::Document<'buf> {
320        &self.doc
321    }
322
323    /// Return the inner JSON `str` and discard the version info.
324    pub fn as_json_str(&self) -> &'buf str {
325        self.doc.source()
326    }
327}
328
329#[expect(
330    clippy::large_enum_variant,
331    reason = "the v2.1.1 and v2.2.1 CDR IRs differ in size; this short-lived versioned \
332              value is not worth boxing"
333)]
334#[derive(Clone)]
335pub(crate) enum Version<'buf> {
336    V211(schema::v211::Cdr<'buf>),
337    V221(schema::v221::Cdr<'buf>),
338}
339
340/// A `json::Document` that has been processed by [`infer_version`] and has been identified
341/// as being a concrete [`Version`].
342pub struct VersionedJson<'buf> {
343    /// The parsed JSON.
344    doc: json::Document<'buf>,
345
346    /// The `Version` of the CDR, determined during parsing.
347    version: crate::Version,
348}
349
350impl fmt::Debug for VersionedJson<'_> {
351    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
352        if f.alternate() {
353            fmt::Debug::fmt(&self.doc, f)
354        } else {
355            match self.version {
356                crate::Version::V211 => f.write_str("V211"),
357                crate::Version::V221 => f.write_str("V221"),
358            }
359        }
360    }
361}
362
363impl crate::Versioned for VersionedJson<'_> {
364    fn version(&self) -> crate::Version {
365        self.version
366    }
367}
368
369impl<'buf> VersionedJson<'buf> {
370    /// Create a new `Versioned` object.
371    pub(crate) fn new(element: json::Document<'buf>, version: crate::Version) -> Self {
372        Self {
373            doc: element,
374            version,
375        }
376    }
377
378    /// Return the inner [`json::Document`] and discard the version info.
379    pub fn into_doc(self) -> json::Document<'buf> {
380        self.doc
381    }
382
383    /// Return the inner [`json::Element`] and discard the version info.
384    pub fn as_element(&self) -> &json::Element<'buf> {
385        self.doc.root()
386    }
387
388    /// Return a reference to the inner [`json::Document`].
389    pub fn as_doc(&self) -> &json::Document<'buf> {
390        &self.doc
391    }
392
393    /// Return the inner JSON `str` and discard the version info.
394    pub fn as_json_str(&self) -> &'buf str {
395        self.doc.source()
396    }
397}
398
399/// A `json::Document` that has been processed by [`infer_version`] and has been identified
400/// as being a concrete [`Version`].
401#[derive(Debug)]
402pub struct Unversioned<'buf> {
403    /// The root `Element` of the parsed source.
404    doc: json::Document<'buf>,
405}
406
407impl<'buf> Unversioned<'buf> {
408    /// Create an unversioned [`json::Element`].
409    pub(crate) fn new(doc: json::Document<'buf>) -> Self {
410        Self { doc }
411    }
412
413    /// Return the inner [`json::Element`] and discard the version info.
414    pub fn into_doc(self) -> json::Document<'buf> {
415        self.doc
416    }
417
418    /// Return the inner [`json::Element`] and discard the version info.
419    pub fn as_element(&self) -> &json::Element<'buf> {
420        self.doc.root()
421    }
422}
423
424impl<'buf> crate::Unversioned for Unversioned<'buf> {
425    type Versioned = VersionedJson<'buf>;
426
427    fn force_into_versioned(self, version: crate::Version) -> VersionedJson<'buf> {
428        let Self { doc } = self;
429        VersionedJson { doc, version }
430    }
431}