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}