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`](crate::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`](crate::Version), use [`from_json`].
26///
27/// # Examples
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`](crate::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`] of [`schema::Warning`]s carried by
47/// the returned [`Caveat`].
48///
49/// # Examples
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`](crate::Version).
87///
88/// Use this when the [`Version`](crate::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/// # Examples
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 Ok(report) = cdr::price(&cdr, price::TariffSource::UseCdr, chrono_tz::Tz::Europe__Amsterdam)
136/// else {
137///     return Err("The CDR could not be priced.".into());
138/// };
139/// let (report, warnings) = report.into_parts();
140///
141/// if !warnings.is_empty() {
142///     eprintln!("Pricing the CDR resulted in `{}` warnings", warnings.len_warnings());
143///
144///     for group in warnings {
145///         let (element, warnings) = group.to_parts();
146///         eprintln!("  {}", element.path);
147///
148///         for warning in warnings {
149///             eprintln!("    - {warning}");
150///         }
151///     }
152/// }
153///
154/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
155/// ```
156pub fn price(
157    cdr: &Versioned<'_>,
158    tariff_source: price::TariffSource<'_>,
159    timezone: Tz,
160) -> Verdict<price::Report, price::Warning> {
161    price::cdr(cdr, tariff_source, timezone)
162}
163
164/// A `json::Element` that has been processed by either the [`infer_version`] or [`from_json`]
165/// functions and has been identified as being a certain [`Version`](crate::Version).
166#[derive(Clone)]
167pub struct Versioned<'buf> {
168    /// The parsed JSON.
169    doc: json::Document<'buf>,
170
171    /// The `Version` of the tariff, determined during parsing.
172    version: Version<'buf>,
173}
174
175impl fmt::Debug for Versioned<'_> {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        if f.alternate() {
178            match &self.version {
179                Version::V211(cdr) => fmt::Debug::fmt(&cdr, f),
180                Version::V221(cdr) => fmt::Debug::fmt(&cdr, f),
181            }
182        } else {
183            match &self.version {
184                Version::V211(_) => f.write_str("V211"),
185                Version::V221(_) => f.write_str("V221"),
186            }
187        }
188    }
189}
190
191impl crate::Versioned for Versioned<'_> {
192    fn version(&self) -> crate::Version {
193        match self.version {
194            Version::V211(_) => crate::Version::V211,
195            Version::V221(_) => crate::Version::V221,
196        }
197    }
198}
199
200impl<'buf> Versioned<'buf> {
201    /// Lower the schema IR into the "normalized" `v221` CDR.
202    ///
203    /// A `v211` CDR is parsed as `v211` and then converted, because the two versions differ
204    /// in more than field names.
205    ///
206    /// The two whole-CDR checks live here rather than in the lowering: both compare fields
207    /// against each other, and only this layer has an element to anchor a warning to. A CDR
208    /// with no charging periods is rejected, because there is nothing to price; the empty
209    /// array itself is located by the schema walk's `Cardinality` warning.
210    pub(crate) fn to_v221(&self) -> Verdict<price::v221::Cdr, price::Warning> {
211        let mut warnings = warning::Set::new();
212
213        let cdr = match &self.version {
214            Version::V211(cdr) => {
215                let cdr = price::v211::Cdr::from_schema(cdr)?.gather_warnings_into(&mut warnings);
216
217                price::v221::Cdr::from(cdr)
218            }
219            Version::V221(cdr) => {
220                price::v221::Cdr::from_schema(cdr)?.gather_warnings_into(&mut warnings)
221            }
222        };
223
224        if cdr.charging_periods.is_empty() {
225            return warnings.bail(self.as_element(), price::Warning::NoPeriods);
226        }
227
228        let cdr_range = cdr.start_date_time..cdr.end_date_time;
229
230        // The periods are sorted by the lowering above, so the first and last periods bound the range.
231        let period_range = match cdr.charging_periods.as_slice() {
232            [] => None,
233            [period] => Some(price::PeriodRange::Single(period.start_date_time)),
234            [earliest, .., latest] => Some(price::PeriodRange::Many(
235                earliest.start_date_time..latest.start_date_time,
236            )),
237        };
238
239        let outside = match &period_range {
240            None => false,
241            Some(price::PeriodRange::Single(start)) => !cdr_range.contains(start),
242            Some(price::PeriodRange::Many(range)) => {
243                !(cdr_range.contains(&range.start) && cdr_range.contains(&range.end))
244            }
245        };
246
247        if let (true, Some(period_range)) = (outside, period_range) {
248            warnings.insert(
249                self.as_element(),
250                price::Warning::PeriodsOutsideStartEndDateTime {
251                    cdr_range,
252                    period_range,
253                },
254            );
255        }
256
257        Ok(cdr.into_caveat(warnings))
258    }
259
260    /// Lower the tariffs embedded in the CDR into "normalized" `v221` tariffs.
261    ///
262    /// The embedded tariffs are read as the version of the CDR that carries them. A CDR
263    /// without a `tariffs` field yields no tariffs; whether that can be priced is up to the
264    /// caller.
265    /// Each tariff keeps its own warnings, because they are reported per tariff in the
266    /// pricing report.
267    pub(crate) fn tariffs_to_v221(
268        &self,
269    ) -> Verdict<Vec<Caveat<tariff::v221::Tariff<'buf>, tariff::Warning>>, tariff::Warning> {
270        let mut warnings = warning::Set::new();
271        let mut lowered = Vec::new();
272
273        match &self.version {
274            Version::V211(cdr) => {
275                let tariffs = warnings.ok_or_bail(&cdr.tariffs)?;
276                for tariff in tariffs.iter().flatten() {
277                    let tariff = warnings.ok_or_bail(tariff)?;
278                    let tariff = tariff::v211::Tariff::from_schema(tariff)?;
279
280                    lowered.push(tariff.map(tariff::v221::Tariff::from));
281                }
282            }
283            Version::V221(cdr) => {
284                let tariffs = warnings.ok_or_bail(&cdr.tariffs)?;
285                for tariff in tariffs.iter().flatten() {
286                    let tariff = warnings.ok_or_bail(tariff)?;
287
288                    lowered.push(tariff::v221::Tariff::from_schema(tariff)?);
289                }
290            }
291        }
292
293        // A tariff with no elements prices every session at zero, so it is rejected rather
294        // than used; see `tariff::Versioned::to_v221`. The schema walk's `Cardinality`
295        // warning locates which of the embedded tariffs is empty.
296        if lowered.iter().any(|tariff| tariff.elements.is_empty()) {
297            return warnings.bail(self.as_element(), tariff::Warning::NoElements);
298        }
299
300        Ok(lowered.into_caveat(warnings))
301    }
302
303    /// Borrow the schema IR this CDR was built into.
304    ///
305    /// A feature that needs a field the schema models reads it from here rather than
306    /// walking the JSON, so the version differences stay in one match.
307    pub(crate) fn schema(&self) -> &Version<'buf> {
308        &self.version
309    }
310
311    /// Return the inner [`json::Document`] and discard the version info.
312    pub fn into_doc(self) -> json::Document<'buf> {
313        self.doc
314    }
315
316    /// Return the inner [`json::Element`] and discard the version info.
317    pub fn as_element(&self) -> &json::Element<'buf> {
318        self.doc.root()
319    }
320
321    /// Return the inner [`json::Document`] and discard the version info.
322    pub fn as_doc(&self) -> &json::Document<'buf> {
323        &self.doc
324    }
325
326    /// Return the inner JSON `str` and discard the version info.
327    pub fn as_json_str(&self) -> &'buf str {
328        self.doc.source()
329    }
330}
331
332#[expect(
333    clippy::large_enum_variant,
334    reason = "the v2.1.1 and v2.2.1 CDR IRs differ in size; this short-lived versioned \
335              value is not worth boxing"
336)]
337#[derive(Clone)]
338pub(crate) enum Version<'buf> {
339    V211(schema::v211::Cdr<'buf>),
340    V221(schema::v221::Cdr<'buf>),
341}
342
343/// A `json::Document` that has been processed by [`infer_version`] and has been identified
344/// as being a concrete [`Version`](crate::Version).
345pub struct VersionedJson<'buf> {
346    /// The parsed JSON.
347    doc: json::Document<'buf>,
348
349    /// The `Version` of the CDR, determined during parsing.
350    version: crate::Version,
351}
352
353impl fmt::Debug for VersionedJson<'_> {
354    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
355        if f.alternate() {
356            fmt::Debug::fmt(&self.doc, f)
357        } else {
358            match self.version {
359                crate::Version::V211 => f.write_str("V211"),
360                crate::Version::V221 => f.write_str("V221"),
361            }
362        }
363    }
364}
365
366impl crate::Versioned for VersionedJson<'_> {
367    fn version(&self) -> crate::Version {
368        self.version
369    }
370}
371
372impl<'buf> VersionedJson<'buf> {
373    /// Create a new `Versioned` object.
374    pub(crate) fn new(element: json::Document<'buf>, version: crate::Version) -> Self {
375        Self {
376            doc: element,
377            version,
378        }
379    }
380
381    /// Return the inner [`json::Document`] and discard the version info.
382    pub fn into_doc(self) -> json::Document<'buf> {
383        self.doc
384    }
385
386    /// Return the inner [`json::Element`] and discard the version info.
387    pub fn as_element(&self) -> &json::Element<'buf> {
388        self.doc.root()
389    }
390
391    /// Return a reference to the inner [`json::Document`].
392    pub fn as_doc(&self) -> &json::Document<'buf> {
393        &self.doc
394    }
395
396    /// Return the inner JSON `str` and discard the version info.
397    pub fn as_json_str(&self) -> &'buf str {
398        self.doc.source()
399    }
400}
401
402/// A `json::Document` that has been processed by [`infer_version`] and has been identified
403/// as being a concrete [`Version`](crate::Version).
404#[derive(Debug)]
405pub struct Unversioned<'buf> {
406    /// The root `Element` of the parsed source.
407    doc: json::Document<'buf>,
408}
409
410impl<'buf> Unversioned<'buf> {
411    /// Create an unversioned [`json::Element`].
412    pub(crate) fn new(doc: json::Document<'buf>) -> Self {
413        Self { doc }
414    }
415
416    /// Return the inner [`json::Element`] and discard the version info.
417    pub fn into_doc(self) -> json::Document<'buf> {
418        self.doc
419    }
420
421    /// Return the inner [`json::Element`] and discard the version info.
422    pub fn as_element(&self) -> &json::Element<'buf> {
423        self.doc.root()
424    }
425}
426
427impl<'buf> crate::Unversioned for Unversioned<'buf> {
428    type Versioned = VersionedJson<'buf>;
429
430    fn force_into_versioned(self, version: crate::Version) -> VersionedJson<'buf> {
431        let Self { doc } = self;
432        VersionedJson { doc, version }
433    }
434}