Skip to main content

ocpi_kit/v2_3_0/
cdrs.rs

1//! The *CDRs* module of OCPI 2.3.0: sealed records of what a session cost.
2//!
3//! *Module Identifier: `cdrs`* — Data owner: CPO.
4//!
5//! > *The CDR … can be thought of as sealed, preserving the information valid at the moment in
6//! > time the underlying session was started. This is a requirement of the main use case for
7//! > CDRs, namely invoicing.*
8//!
9//! Spec: 2.3.0 §mod_cdrs_cdrs_module
10
11use bon::Builder;
12use serde::{Deserialize, Serialize};
13
14use crate::ocpi_enum;
15use crate::types::validate_fields;
16use crate::types::{
17    CiString, ContractId, CountryCode, Currency, DateTime, EvseId, Extensions, Number, OcpiString, PartyId,
18    PartyRef, Validate, Validator, ViolationCode,
19};
20
21use super::locations::{ConnectorFormat, ConnectorType, GeoLocation, PowerType};
22use super::tariffs::Tariff;
23use super::tokens::TokenType;
24use super::types::Price;
25
26/// The maximum length of a normal, non-credit CDR id.
27///
28/// > *This field is longer than the usual 36 characters to allow for credit CDRs to have
29/// > something appended to the original ID. Normal (non-credit) CDRs SHALL only have an ID with
30/// > a maximum length of 36.*
31pub const NON_CREDIT_ID_MAX_LEN: usize = 36;
32
33/// A Charge Detail Record: one charging session and its costs.
34///
35/// Spec: 2.3.0 §mod_cdrs_cdr_object
36#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
37#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
38#[builder(on(_, into))]
39pub struct Cdr {
40    /// ISO-3166 alpha-2 country code of the CPO that 'owns' this CDR.
41    pub country_code: CountryCode,
42    /// ID of the CPO that 'owns' this CDR.
43    pub party_id: PartyId,
44    /// Uniquely identifies the CDR, unique per `country_code`/`party_id` combination.
45    pub id: CiString<39>,
46    /// Start of the charging session, or of the reservation when there was no session.
47    pub start_date_time: DateTime,
48    /// When the session was completed. Charging may have finished earlier.
49    pub end_date_time: DateTime,
50    /// The Session this CDR belongs to.
51    ///
52    /// > *Is only allowed to be omitted when the CPO has not implemented the Sessions module or
53    /// > this CDR is the result of a reservation that never became a charging session.*
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub session_id: Option<CiString<36>>,
56    /// Token used to start this charging session.
57    pub cdr_token: CdrToken,
58    /// Method used for authentication. The **last** method used during the session.
59    pub auth_method: AuthMethod,
60    /// Reference to the authorization given by the eMSP.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub authorization_reference: Option<CiString<36>>,
63    /// The Booking this CDR also belongs to.
64    ///
65    /// > *Is only allowed to be omitted when the Session was reserved.*
66    ///
67    /// Added by the OCPI 2.3.0 `bookings` release branch, so it is behind the `bookings` feature.
68    ///
69    /// Spec: 2.3.0-bookings §mod_cdrs_cdr_object
70    #[cfg(feature = "bookings")]
71    #[cfg_attr(docsrs, doc(cfg(feature = "bookings")))]
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub booking_id: Option<CiString<36>>,
74    /// Where the charging session took place.
75    pub cdr_location: CdrLocation,
76    /// Identification of the meter inside the Charge Point.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub meter_id: Option<OcpiString<255>>,
79    /// Currency of the CDR in ISO 4217 code.
80    pub currency: Currency,
81    /// Relevant Tariffs, as they were at the start of the session.
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    #[builder(default)]
84    pub tariffs: Vec<Tariff>,
85    /// Charging Periods that make up this session. Cardinality `+`.
86    pub charging_periods: Vec<ChargingPeriod>,
87    /// Signed metering data belonging to this session.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub signed_data: Option<SignedData>,
90    /// Total sum of all the costs of this transaction.
91    pub total_cost: Price,
92    /// Total of the fixed costs, except fixed price components of parking and reservation.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub total_fixed_cost: Option<Price>,
95    /// Total energy charged, in kWh.
96    pub total_energy: Number,
97    /// Total cost of all the energy used.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub total_energy_cost: Option<Price>,
100    /// Total duration of the charging session, charging and not charging, in hours.
101    pub total_time: Number,
102    /// Total cost related to the duration of charging.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub total_time_cost: Option<Price>,
105    /// Total duration during which the EV was not charging, in hours.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub total_parking_time: Option<Number>,
108    /// Total cost related to parking, including fixed price components.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub total_parking_cost: Option<Price>,
111    /// Total cost related to a reservation, including fixed price components.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub total_reservation_cost: Option<Price>,
114    /// Human-readable remark, e.g. the reason a transaction was stopped.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub remark: Option<OcpiString<255>>,
117    /// Reference to an invoice that will later be sent for this CDR.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub invoice_reference_id: Option<CiString<39>>,
120    /// Whether this is a Credit CDR. Requires `credit_reference_id`.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub credit: Option<bool>,
123    /// The `id` of the CDR this Credit CDR corrects.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub credit_reference_id: Option<CiString<39>>,
126    /// Whether the energy cost of this home-charging session is compensated to the EV driver.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub home_charging_compensation: Option<bool>,
129    /// Timestamp when this CDR was last updated (or created).
130    pub last_updated: DateTime,
131    /// Undocumented JSON fields, preserved verbatim.
132    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
133    #[builder(default)]
134    pub extensions: Extensions,
135}
136
137impl Cdr {
138    /// The CPO that owns this CDR.
139    #[must_use]
140    pub fn owner_party(&self) -> PartyRef {
141        PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
142    }
143
144    /// Whether this is a Credit CDR.
145    #[must_use]
146    pub fn is_credit(&self) -> bool {
147        self.credit.unwrap_or(false)
148    }
149
150    /// The time the EV was actually charging, in hours.
151    ///
152    /// > *The actual charging duration … can be calculated:
153    /// > `total_charging_time = total_time - total_parking_time`.*
154    #[must_use]
155    pub fn total_charging_time(&self) -> Number {
156        self.total_time - self.total_parking_time.unwrap_or(Number::ZERO)
157    }
158
159    /// The total volume of one dimension across every charging period.
160    #[must_use]
161    pub fn dimension_total(&self, dimension: CdrDimensionType) -> Number {
162        self.charging_periods
163            .iter()
164            .flat_map(|p| p.dimensions.iter())
165            .filter(|d| d.dimension_type == dimension)
166            .map(|d| d.volume)
167            .sum()
168    }
169
170    /// Every charging period with the interval it actually covers.
171    ///
172    /// A [`ChargingPeriod`] carries only its `start_date_time`: *"A period ends when the next one
173    /// starts"*, and the last one ends at the CDR's `end_date_time`. Deriving that is three lines
174    /// and an off-by-one, and every consumer that needs energy over time writes it.
175    ///
176    /// ```
177    /// # use ocpi_kit::v2_3_0::cdrs::{Cdr, CdrDimensionType};
178    /// # fn f(cdr: &Cdr) {
179    /// for span in cdr.period_spans() {
180    ///     if let Some(kwh) = span.volume(CdrDimensionType::Energy) {
181    ///         println!("{} → {}: {kwh} kWh", span.start, span.end);
182    ///     }
183    /// }
184    /// # }
185    /// ```
186    ///
187    /// # What a period is, and is not
188    ///
189    /// A period is a **total, not a curve**. It says 4.3 kWh flowed between two instants and
190    /// nothing about how. Re-cutting these intervals onto a finer grid — quarter hours, say —
191    /// therefore needs an assumption the CDR does not carry, and the specification declines to
192    /// make it: it puts the obligation on the CPO to start a new period *"every moment/event that
193    /// has relevance for the total costs"* instead. Apportioning by elapsed time is the usual
194    /// choice and is usually close, but it is the caller's assumption to make and to record, not
195    /// this crate's to hide. [`tariffs`](crate::tariffs) takes the same position, and reports a
196    /// `PeriodSpansPriceChange` note when a period outlasts the price that governs it.
197    ///
198    /// Periods are yielded in the order the CDR gives them. `validate()` reports a CDR whose
199    /// periods are out of order, so check that first if the ordering matters — which for an
200    /// interval it does.
201    ///
202    /// # Only on a CDR
203    ///
204    /// [`Session`](crate::v2_3_0::sessions::Session) carries the same periods and does not get
205    /// this, on purpose. A running session has no `end_date_time`, so its final period has no
206    /// honest end; and its whole list is provisional — *"any `charging_periods` from the existing
207    /// object SHALL be replaced by the `charging_periods` from the newly received Session
208    /// object"*. A CDR is the record that stops changing, which is what an interval needs.
209    ///
210    /// Spec: 2.3.0 §mod_cdrs_chargingperiod_class
211    pub fn period_spans(&self) -> impl Iterator<Item = PeriodSpan<'_>> {
212        self.charging_periods.iter().enumerate().map(move |(i, period)| PeriodSpan {
213            start: period.start_date_time,
214            end: self.charging_periods.get(i + 1).map_or(self.end_date_time, |next| next.start_date_time),
215            period,
216        })
217    }
218
219    /// How long after the session ended this CDR was written, in seconds.
220    ///
221    /// A CDR may arrive well after the session it records, and a consumer with a filing deadline
222    /// needs to know by how much. `last_updated` is the moment to measure from because a CDR has
223    /// no later one: *"Because a CDR is for billing purposes, it cannot be changed or replaced
224    /// once sent to the eMSP. Changes are simply not allowed."* So on a CDR — unlike every other
225    /// OCPI object — `last_updated` is when it was created.
226    ///
227    /// `None` when the CDR carries the `1970-1-1T00:00:00Z` placeholder timestamps the
228    /// specification permits, which would otherwise report half a century of latency and poison
229    /// an average. See [`has_placeholder_timestamps`](Self::has_placeholder_timestamps).
230    ///
231    /// Negative values are returned as they are. They mean the CPO's clock disagrees with the
232    /// session it recorded, which is worth seeing rather than clamping away.
233    ///
234    /// Spec: 2.3.0 §mod_cdrs_cdr_object
235    #[must_use]
236    pub fn delivery_latency_seconds(&self) -> Option<i64> {
237        if self.has_placeholder_timestamps() {
238            return None;
239        }
240        Some(self.last_updated.unix_timestamp() - self.end_date_time.unix_timestamp())
241    }
242
243    /// Whether the timestamps are the `1970-1-1T00:00:00Z` placeholder the spec permits.
244    ///
245    /// > *If the MSP and CPO both agree that they accept CDRs that miss either or both the
246    /// > `start_date_time` and `end_date_time` … the CPO could send a CDR where the
247    /// > `start_date_time` and/or `end_date_time` are set to "1970-1-1T00:00:00Z".*
248    #[must_use]
249    pub fn has_placeholder_timestamps(&self) -> bool {
250        self.start_date_time.unix_timestamp() == 0 || self.end_date_time.unix_timestamp() == 0
251    }
252}
253
254impl Validate for Cdr {
255    fn validate_in(&self, v: &mut Validator) {
256        validate_fields!(
257            self,
258            v,
259            country_code,
260            party_id,
261            id,
262            start_date_time,
263            end_date_time,
264            session_id,
265            cdr_token,
266            auth_method,
267            authorization_reference,
268            cdr_location,
269            meter_id,
270            currency,
271            tariffs,
272            charging_periods,
273            signed_data,
274            total_cost,
275            total_fixed_cost,
276            total_energy,
277            total_energy_cost,
278            total_time,
279            total_time_cost,
280            total_parking_time,
281            total_parking_cost,
282            total_reservation_cost,
283            remark,
284            invoice_reference_id,
285            credit_reference_id,
286            last_updated,
287        );
288
289        if self.charging_periods.is_empty() {
290            v.report_at(
291                "charging_periods",
292                ViolationCode::EmptyRequiredList,
293                "a CDR has cardinality `+` charging_periods: at least one is required",
294            );
295        }
296
297        // "Normal (non-credit) CDRs SHALL only have an ID with a maximum length of 36."
298        if !self.is_credit() && self.id.len() > NON_CREDIT_ID_MAX_LEN {
299            v.report_at(
300                "id",
301                ViolationCode::TooLong,
302                format!(
303                    "a non-credit CDR id may be at most {NON_CREDIT_ID_MAX_LEN} characters; \
304                     the extra length is reserved for credit CDRs"
305                ),
306            );
307        }
308
309        // "When set to true, this is a Credit CDR, and the field credit_reference_id needs to be
310        //  set as well."
311        if self.is_credit() && self.credit_reference_id.is_none() {
312            v.report_at(
313                "credit_reference_id",
314                ViolationCode::MissingConditional,
315                "is required to be set for a Credit CDR",
316            );
317        }
318        if !self.is_credit() && self.credit_reference_id.is_some() {
319            v.report_at(
320                "credit",
321                ViolationCode::Inconsistent,
322                "credit_reference_id is set, so `credit` should be true",
323            );
324        }
325
326        if !self.has_placeholder_timestamps() && self.end_date_time < self.start_date_time {
327            v.report_at(
328                "end_date_time",
329                ViolationCode::Inconsistent,
330                "a session cannot end before it starts",
331            );
332        }
333
334        // The energy total has to agree with the metered ENERGY dimensions.
335        let metered = self.dimension_total(CdrDimensionType::Energy);
336        if !self.charging_periods.is_empty()
337            && self
338                .charging_periods
339                .iter()
340                .any(|p| p.dimensions.iter().any(|d| d.dimension_type == CdrDimensionType::Energy))
341            && metered != self.total_energy
342        {
343            v.report_at(
344                "total_energy",
345                ViolationCode::Inconsistent,
346                format!(
347                    "is {}, but the ENERGY dimensions of the charging periods add up to {metered}",
348                    self.total_energy
349                ),
350            );
351        }
352
353        validate_period_sequence(
354            &self.charging_periods.iter().map(|p| p.start_date_time).collect::<Vec<_>>(),
355            self.start_date_time,
356            Some(self.end_date_time),
357            v,
358        );
359
360        if self.total_parking_time.is_some_and(|p| p > self.total_time) {
361            v.report_at(
362                "total_parking_time",
363                ViolationCode::Inconsistent,
364                "cannot exceed total_time, of which it is a part",
365            );
366        }
367
368        // "SHALL only be used in Sessions" — these dimensions have no meaning in a CDR.
369        for (i, period) in self.charging_periods.iter().enumerate() {
370            for (j, dim) in period.dimensions.iter().enumerate() {
371                if dim.dimension_type.is_session_only() {
372                    v.enter("charging_periods");
373                    v.enter(&i.to_string());
374                    v.enter("dimensions");
375                    v.enter(&j.to_string());
376                    v.report_at(
377                        "type",
378                        ViolationCode::Inconsistent,
379                        format!(
380                            "{} is marked \"Session Only\" and SHALL NOT appear in a CDR",
381                            dim.dimension_type
382                        ),
383                    );
384                    v.leave();
385                    v.leave();
386                    v.leave();
387                    v.leave();
388                }
389            }
390        }
391    }
392}
393
394/// The token that started a session, as recorded in a CDR or Session.
395///
396/// Spec: 2.3.0 §mod_cdrs_cdr_token_object
397#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
398#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
399#[builder(on(_, into))]
400pub struct CdrToken {
401    /// ISO-3166 alpha-2 country code of the MSP that 'owns' this Token.
402    pub country_code: CountryCode,
403    /// ID of the eMSP that 'owns' this Token.
404    pub party_id: PartyId,
405    /// Unique ID by which this Token can be identified by the CPO's system.
406    pub uid: CiString<36>,
407    /// Type of the token.
408    #[serde(rename = "type")]
409    pub token_type: TokenType,
410    /// Uniquely identifies the EV driver contract token within the eMSP's platform.
411    pub contract_id: ContractId,
412    /// Undocumented JSON fields, preserved verbatim.
413    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
414    #[builder(default)]
415    pub extensions: Extensions,
416}
417
418impl CdrToken {
419    /// The eMSP that owns this Token.
420    #[must_use]
421    pub fn owner_party(&self) -> PartyRef {
422        PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
423    }
424}
425
426impl Validate for CdrToken {
427    fn validate_in(&self, v: &mut Validator) {
428        validate_fields!(self, v, country_code, party_id, uid, token_type as "type", contract_id);
429    }
430}
431
432/// The parts of a Location that a CDR needs, frozen at the start of the session.
433///
434/// Spec: 2.3.0 §mod_cdrs_cdr_location_class
435#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
436#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
437#[builder(on(_, into))]
438pub struct CdrLocation {
439    /// Uniquely identifies the location within the CPO's platform.
440    pub id: CiString<36>,
441    /// Display name of the location.
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub name: Option<OcpiString<255>>,
444    /// Street/block name and house number if available.
445    pub address: OcpiString<45>,
446    /// City or town.
447    pub city: OcpiString<45>,
448    /// Postal code of the location.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub postal_code: Option<OcpiString<10>>,
451    /// State, only to be used when relevant.
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub state: Option<OcpiString<20>>,
454    /// ISO 3166-1 alpha-3 code for the country of this location.
455    pub country: OcpiString<3>,
456    /// Coordinates of the location.
457    pub coordinates: GeoLocation,
458    /// The EVSE's technical identifier. May be `#NA` for a reservation that never charged.
459    pub evse_uid: CiString<36>,
460    /// The EVSE's human-readable ID. May be `#NA` for a reservation that never charged.
461    pub evse_id: EvseId,
462    /// Identifier of the connector within the EVSE. May be `#NA`.
463    pub connector_id: CiString<36>,
464    /// The standard of the installed connector.
465    pub connector_standard: ConnectorType,
466    /// The format (socket/cable) of the installed connector.
467    pub connector_format: ConnectorFormat,
468    /// Whether the connector supplies AC or DC, and on how many phases.
469    pub connector_power_type: PowerType,
470    /// Undocumented JSON fields, preserved verbatim.
471    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
472    #[builder(default)]
473    pub extensions: Extensions,
474}
475
476impl CdrLocation {
477    /// Whether this CDR records a reservation that never became a charging session.
478    ///
479    /// The spec marks `evse_uid`, `evse_id` and `connector_id` as *"allowed to be set to `#NA`
480    /// when this CDR is created for a reservation that never resulted in a charging session"*,
481    /// in which case the connector fields *"can be set to any value and should be ignored"*.
482    #[must_use]
483    pub fn is_reservation_only(&self) -> bool {
484        self.evse_uid.is_not_available()
485            || self.evse_id.is_not_available()
486            || self.connector_id.is_not_available()
487    }
488}
489
490impl Validate for CdrLocation {
491    fn validate_in(&self, v: &mut Validator) {
492        validate_fields!(
493            self,
494            v,
495            id,
496            name,
497            address,
498            city,
499            postal_code,
500            state,
501            country,
502            coordinates,
503            evse_uid,
504            evse_id,
505            connector_id,
506            connector_standard,
507            connector_format,
508            connector_power_type,
509        );
510    }
511}
512
513/// A period of a session during which the values that influence its cost were stable.
514///
515/// > *A CPO SHALL at least start (and add) a ChargingPeriod every moment/event that has relevance
516/// > for the total costs of a CDR.*
517///
518/// Spec: 2.3.0 §mod_cdrs_chargingperiod_class
519#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
520#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
521#[builder(on(_, into))]
522pub struct ChargingPeriod {
523    /// Start of the charging period. A period ends when the next one starts.
524    pub start_date_time: DateTime,
525    /// Relevant values for this charging period. Cardinality `+`.
526    pub dimensions: Vec<CdrDimension>,
527    /// The Tariff relevant during this period. When absent, no Tariff is relevant.
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    pub tariff_id: Option<CiString<36>>,
530    /// Undocumented JSON fields, preserved verbatim.
531    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
532    #[builder(default)]
533    pub extensions: Extensions,
534}
535
536impl ChargingPeriod {
537    /// The volume recorded for one dimension in this period.
538    #[must_use]
539    pub fn volume(&self, dimension: CdrDimensionType) -> Option<Number> {
540        self.dimensions.iter().find(|d| d.dimension_type == dimension).map(|d| d.volume)
541    }
542}
543
544/// A [`ChargingPeriod`] together with the interval it covers.
545///
546/// Produced by [`Cdr::period_spans`], which is where the boundary rule is explained.
547#[derive(Clone, Copy, Debug, PartialEq)]
548pub struct PeriodSpan<'a> {
549    /// The period's own `start_date_time`.
550    pub start: DateTime,
551    /// The next period's start, or the CDR's `end_date_time` for the last one.
552    pub end: DateTime,
553    /// The period itself.
554    pub period: &'a ChargingPeriod,
555}
556
557impl PeriodSpan<'_> {
558    /// The volume recorded for one dimension in this period.
559    #[must_use]
560    pub fn volume(&self, dimension: CdrDimensionType) -> Option<Number> {
561        self.period.volume(dimension)
562    }
563
564    /// How long the interval is, in seconds. Negative if the CDR's periods are out of order.
565    #[must_use]
566    pub fn duration_seconds(&self) -> i64 {
567        self.end.unix_timestamp() - self.start.unix_timestamp()
568    }
569}
570
571impl Validate for ChargingPeriod {
572    fn validate_in(&self, v: &mut Validator) {
573        validate_fields!(self, v, start_date_time, dimensions, tariff_id);
574        if self.dimensions.is_empty() {
575            v.report_at(
576                "dimensions",
577                ViolationCode::EmptyRequiredList,
578                "a ChargingPeriod has cardinality `+` dimensions: at least one is required",
579            );
580        }
581        let mut seen: Vec<&CdrDimensionType> = Vec::new();
582        for d in &self.dimensions {
583            if seen.contains(&&d.dimension_type) {
584                v.report_at(
585                    "dimensions",
586                    ViolationCode::Inconsistent,
587                    format!("the dimension {} appears more than once in one period", d.dimension_type),
588                );
589            }
590            seen.push(&d.dimension_type);
591        }
592    }
593}
594
595/// Checks that a list of Charging Periods is a sequence a session could actually have had.
596///
597/// # Why this is worth checking
598///
599/// Nothing in the property tables says the periods are ordered, but everything built on them
600/// assumes it. `step_size` is defined in terms of *"the last relevant PriceComponent"* and
601/// *"the last time-based period"*; a period's duration is only knowable as the gap to the next
602/// one; and a pricing engine reading them out of order will quietly bill the wrong rate.
603///
604/// It is also the failure that shows up in practice. Charging periods arrive from a CSMS through
605/// a CPO's own aggregation, and a merge that loses the sort is invisible in every field-by-field
606/// check — the objects are all individually valid.
607///
608/// So this reports three things, each as a [`ViolationCode::Inconsistent`] at the offending
609/// index: a period that does not start after the one before it, one that starts before the
610/// session did, and one that starts at or after the session ended.
611///
612/// Spec: 2.3.0 §mod_cdrs_cdr_object, §mod_cdrs_step_size
613pub fn validate_period_sequence(
614    starts: &[DateTime],
615    session_start: DateTime,
616    session_end: Option<DateTime>,
617    v: &mut Validator,
618) {
619    let mut previous: Option<DateTime> = None;
620    for (i, start) in starts.iter().copied().enumerate() {
621        let at = |v: &mut Validator, message: String| {
622            v.enter("charging_periods");
623            v.enter(&i.to_string());
624            v.report_at("start_date_time", ViolationCode::Inconsistent, message);
625            v.leave();
626            v.leave();
627        };
628        if let Some(previous) = previous
629            && start <= previous
630        {
631            at(
632                v,
633                format!(
634                    "is {start}, which is not after the previous period's {previous}; \
635                     charging periods have to be in order for `step_size` and for a period's \
636                     own duration to mean anything"
637                ),
638            );
639        }
640        if start < session_start {
641            at(v, format!("is {start}, before the session started at {session_start}"));
642        }
643        if let Some(end) = session_end
644            && start >= end
645        {
646            at(v, format!("is {start}, at or after the session ended at {end}"));
647        }
648        previous = Some(start);
649    }
650}
651
652/// One measured quantity within a [`ChargingPeriod`].
653///
654/// Spec: 2.3.0 §mod_cdrs_cdrdimension_class
655#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
656#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
657pub struct CdrDimension {
658    /// Type of CDR dimension.
659    #[serde(rename = "type")]
660    pub dimension_type: CdrDimensionType,
661    /// Volume of the dimension consumed, measured according to the dimension type.
662    pub volume: Number,
663    /// Undocumented JSON fields, preserved verbatim.
664    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
665    pub extensions: Extensions,
666}
667
668impl CdrDimension {
669    /// Creates a dimension measurement.
670    #[must_use]
671    pub fn new(dimension_type: CdrDimensionType, volume: Number) -> Self {
672        Self { dimension_type, volume, extensions: Extensions::new() }
673    }
674}
675
676impl Validate for CdrDimension {
677    fn validate_in(&self, v: &mut Validator) {
678        validate_fields!(self, v, dimension_type as "type", volume);
679        if self.dimension_type == CdrDimensionType::StateOfCharge {
680            let pct = self.volume;
681            if pct < Number::ZERO || pct > Number::from(100u32) {
682                v.report_at(
683                    "volume",
684                    ViolationCode::OutOfRange,
685                    "STATE_OF_CHARGE is a percentage: values allowed are 0 to 100",
686                );
687            }
688        }
689        if !self.dimension_type.may_be_negative() && self.volume.is_negative() {
690            v.report_at(
691                "volume",
692                ViolationCode::OutOfRange,
693                format!("{} cannot be negative", self.dimension_type),
694            );
695        }
696    }
697}
698
699/// Signed metering data, for German *Eichrecht* and comparable regimes.
700///
701/// Spec: 2.3.0 §mod_cdrs_signed_data_class
702#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
703#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
704#[builder(on(_, into))]
705pub struct SignedData {
706    /// The name of the encoding used, as given by a company or group of companies.
707    ///
708    /// Known implementations include `OCMF`, `Alfen Eichrecht`, `EDL40 E-Mobility Extension` and
709    /// `EDL40 Mennekes`.
710    pub encoding_method: CiString<36>,
711    /// Version of the encoding method, when applicable.
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub encoding_method_version: Option<i32>,
714    /// Public key used to sign the data, base64 encoded.
715    #[serde(default, skip_serializing_if = "Option::is_none")]
716    pub public_key: Option<OcpiString<512>>,
717    /// One or more signed values. Cardinality `+`.
718    pub signed_values: Vec<SignedValue>,
719    /// URL where an EV driver can check the signed data of a charging session.
720    ///
721    /// A `string(512)`, not the `URL` type every other URL-shaped field in OCPI uses — which is
722    /// `string(255)`. Modelling it as a [`Url`](crate::types::Url) would report a conformant
723    /// 300-character link as `TooLong` and refuse to construct one, so it is the string the
724    /// specification says it is. [`Url::new_lenient`](crate::types::Url::new_lenient) turns it
725    /// into one when a caller wants that.
726    #[serde(default, skip_serializing_if = "Option::is_none")]
727    pub url: Option<OcpiString<512>>,
728    /// Undocumented JSON fields, preserved verbatim.
729    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
730    #[builder(default)]
731    pub extensions: Extensions,
732}
733
734impl SignedData {
735    /// The signed value recorded for one `nature`, compared case-insensitively.
736    ///
737    /// > *Possible values at moment of writing: Start, End, Intermediate. Others might be added
738    /// > later.*
739    ///
740    /// Open by design, so this takes a `&str` rather than an enum: a peer is free to record a
741    /// nature this crate has never heard of, and losing it would defeat the point of the object.
742    #[must_use]
743    pub fn value_for(&self, nature: &str) -> Option<&SignedValue> {
744        self.signed_values.iter().find(|v| v.nature.eq_ignore_case(nature))
745    }
746
747    /// The `Start` reading, if the CPO recorded one.
748    #[must_use]
749    pub fn start_value(&self) -> Option<&SignedValue> {
750        self.value_for("Start")
751    }
752
753    /// The `End` reading, if the CPO recorded one.
754    #[must_use]
755    pub fn end_value(&self) -> Option<&SignedValue> {
756        self.value_for("End")
757    }
758}
759
760impl Validate for SignedData {
761    fn validate_in(&self, v: &mut Validator) {
762        validate_fields!(self, v, encoding_method, public_key, signed_values, url,);
763        if self.signed_values.is_empty() {
764            v.report_at(
765                "signed_values",
766                ViolationCode::EmptyRequiredList,
767                "SignedData has cardinality `+` signed_values: at least one is required",
768            );
769        }
770    }
771}
772
773/// One signed and plain value pair.
774///
775/// Spec: 2.3.0 §mod_cdrs_signed_value_class
776#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
777#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
778pub struct SignedValue {
779    /// Nature of the value: the event it belongs to.
780    ///
781    /// > *Possible values at moment of writing: Start, End, Intermediate. Others might be added
782    /// > later.*
783    pub nature: CiString<32>,
784    /// The un-encoded string of data. Its format depends on the encoding method.
785    ///
786    /// NOTE: earlier releases of the OCPI 2.3.0 documentation mistakenly gave a maximum of 512.
787    pub plain_data: OcpiString<5000>,
788    /// Blob of signed data, base64 encoded.
789    ///
790    /// **Carried verbatim, whatever its length.** A signed record is evidence: it is worth
791    /// nothing if a byte moves, and an OCMF blob from a real meter routinely runs past the
792    /// `string(5000)` the specification gives. This crate's governing rule applies — the value
793    /// arrives intact and `validate()` reports the length as a
794    /// [`crate::types::ViolationCode::TooLong`] — so a decode and re-encode
795    /// round trip reproduces the original bytes exactly. `tests/fixtures.rs` asserts it.
796    pub signed_data: OcpiString<5000>,
797    /// Undocumented JSON fields, preserved verbatim.
798    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
799    pub extensions: Extensions,
800}
801
802impl Validate for SignedValue {
803    fn validate_in(&self, v: &mut Validator) {
804        validate_fields!(self, v, nature, plain_data, signed_data);
805    }
806}
807
808ocpi_enum! {
809    /// How the driver was authenticated for a session.
810    ///
811    /// Spec: 2.3.0 §mod_cdrs_authmethod_enum
812    pub enum AuthMethod {
813        /// An authentication request was sent to the eMSP.
814        AuthRequest = "AUTH_REQUEST",
815        /// A command such as `StartSession` or `ReserveNow` started the session.
816        Command = "COMMAND",
817        /// A whitelist was used; no request to the eMSP was performed.
818        Whitelist = "WHITELIST",
819    }
820}
821
822ocpi_enum! {
823    /// The quantities a [`ChargingPeriod`] can record.
824    ///
825    /// Some values are marked *Session Only* in the spec and must not appear in a CDR; see
826    /// [`CdrDimensionType::is_session_only`].
827    ///
828    /// Spec: 2.3.0 §mod_cdrs_cdrdimensiontype_enum
829    pub enum CdrDimensionType {
830        /// Average charging current during this period, in A. Negative flows to the grid.
831        Current = "CURRENT",
832        /// Total energy (dis-)charged during this period, in kWh. Default `step_size` is 1.
833        Energy = "ENERGY",
834        /// Total energy fed back into the grid, in kWh.
835        EnergyExport = "ENERGY_EXPORT",
836        /// Total energy charged, in kWh.
837        EnergyImport = "ENERGY_IMPORT",
838        /// Sum of the maximum current over all phases reached during this period, in A.
839        MaxCurrent = "MAX_CURRENT",
840        /// Sum of the minimum current over all phases reached during this period, in A.
841        MinCurrent = "MIN_CURRENT",
842        /// Maximum power reached during this period, in kW.
843        MaxPower = "MAX_POWER",
844        /// Minimum power reached during this period, in kW.
845        MinPower = "MIN_POWER",
846        /// Time during which the vehicle is not requesting power, in hours.
847        ///
848        /// > *NOTE: Earlier versions of the OCPI 2.3.0 specification document mistakenly defined
849        /// > PARKING_TIME as "Time during this ChargingPeriod not charging".*
850        ParkingTime = "PARKING_TIME",
851        /// Average power during this period, in kW. Negative flows to the grid.
852        Power = "POWER",
853        /// Time the EVSE has been reserved and not yet in use for this customer, in hours.
854        ReservationTime = "RESERVATION_TIME",
855        /// Time a reservation was held that then **expired**, in hours.
856        ///
857        /// From the 2.3.0 `bookings` branch, which core 2.3.0 does not have. Declared
858        /// unconditionally rather than behind the feature because this enum is **closed**: a
859        /// booking-aware CPO sending it would otherwise make the whole CDR undecodable.
860        ///
861        /// Spec: 2.3.0-bookings §mod_cdrs_cdrdimensiontype_enum
862        ReservationExpires = "RESERVATION_EXPIRES",
863        /// Time the session continued **after** the reserved slot ended, in hours.
864        ///
865        /// Also from the `bookings` branch, and unconditional for the same reason.
866        ///
867        /// Spec: 2.3.0-bookings §mod_cdrs_cdrdimensiontype_enum
868        ReservationOvertime = "RESERVATION_OVERTIME",
869        /// Current state of charge of the EV, in percent, 0 to 100.
870        StateOfCharge = "STATE_OF_CHARGE",
871        /// Time charging in this period, in hours.
872        Time = "TIME",
873    }
874}
875
876impl CdrDimensionType {
877    /// Whether the spec marks this dimension *Session Only*.
878    ///
879    /// > *Some of these values are not useful for CDRs, and SHALL therefore only be used in
880    /// > Sessions.*
881    ///
882    /// Spec: 2.3.0 §mod_cdrs_cdrdimensiontype_enum
883    #[must_use]
884    pub const fn is_session_only(self) -> bool {
885        matches!(
886            self,
887            Self::Current | Self::EnergyExport | Self::EnergyImport | Self::Power | Self::StateOfCharge
888        )
889    }
890
891    /// Whether a negative volume is meaningful for this dimension.
892    ///
893    /// The spec says so explicitly for the bidirectional quantities: *"When negative, the current
894    /// is flowing from the EV to the grid."*
895    #[must_use]
896    pub const fn may_be_negative(self) -> bool {
897        matches!(self, Self::Current | Self::Energy | Self::MinCurrent | Self::MinPower | Self::Power)
898    }
899
900    /// The unit the volume is measured in.
901    #[must_use]
902    pub const fn unit(self) -> &'static str {
903        match self {
904            Self::Current | Self::MaxCurrent | Self::MinCurrent => "A",
905            Self::Energy | Self::EnergyExport | Self::EnergyImport => "kWh",
906            Self::MaxPower | Self::MinPower | Self::Power => "kW",
907            Self::ParkingTime
908            | Self::ReservationTime
909            | Self::ReservationExpires
910            | Self::ReservationOvertime
911            | Self::Time => "h",
912            Self::StateOfCharge => "%",
913        }
914    }
915}
916
917#[cfg(test)]
918mod cdr_helper_tests {
919    use super::*;
920
921    fn dt(s: &str) -> DateTime {
922        s.parse().expect("a valid timestamp")
923    }
924
925    fn period(start: &str, kwh: &str) -> ChargingPeriod {
926        ChargingPeriod::builder()
927            .start_date_time(dt(start))
928            .dimensions(vec![CdrDimension {
929                dimension_type: CdrDimensionType::Energy,
930                volume: kwh.parse().expect("a number"),
931                extensions: Extensions::new(),
932            }])
933            .build()
934    }
935
936    /// Built here rather than from `testkit`, which is a feature these tests must not require.
937    fn cdr_with(periods: Vec<ChargingPeriod>, end: &str, last_updated: &str) -> Cdr {
938        use crate::types::CiString;
939        let energy: Number = periods.iter().filter_map(|p| p.volume(CdrDimensionType::Energy)).sum();
940        Cdr::builder()
941            .country_code(CiString::new("NL").expect("valid"))
942            .party_id(CiString::new("TNM").expect("valid"))
943            .id(CiString::new("CDR1").expect("valid"))
944            .start_date_time(dt("2024-01-15T10:00:00Z"))
945            .end_date_time(dt(end))
946            .session_id(CiString::new("SESS1").expect("valid"))
947            .cdr_token(CdrToken {
948                country_code: CiString::new("DE").expect("valid"),
949                party_id: CiString::new("ABC").expect("valid"),
950                uid: CiString::new("012345678").expect("valid"),
951                token_type: TokenType::Rfid,
952                contract_id: CiString::new("DE8AACA2B3C4D5N").expect("valid"),
953                extensions: Extensions::new(),
954            })
955            .auth_method(AuthMethod::Whitelist)
956            .cdr_location(cdr_location())
957            .currency("EUR")
958            .charging_periods(periods)
959            .total_cost(crate::v2_3_0::types::Price::new("1.00".parse().expect("a number")))
960            .total_energy(energy)
961            .total_time("1".parse::<Number>().expect("a number"))
962            .last_updated(dt(last_updated))
963            .build()
964    }
965
966    fn cdr_location() -> CdrLocation {
967        use crate::types::CiString;
968        CdrLocation::builder()
969            .id(CiString::new("LOC1").expect("valid"))
970            .address("F.Rooseveltlaan 3A")
971            .city("Gent")
972            .country("BEL")
973            .coordinates(
974                crate::v2_3_0::locations::GeoLocation::new("3.729944", "51.047599")
975                    .expect("valid coordinates"),
976            )
977            .evse_uid(CiString::new("3256").expect("valid"))
978            .evse_id(CiString::new("BE*BEC*E041503001").expect("valid"))
979            .connector_id(CiString::new("1").expect("valid"))
980            .connector_standard(crate::v2_3_0::locations::ConnectorType::Iec62196T2)
981            .connector_format(crate::v2_3_0::locations::ConnectorFormat::Socket)
982            .connector_power_type(crate::v2_3_0::locations::PowerType::Ac3Phase)
983            .build()
984    }
985
986    /// A period ends where the next one starts, and the last one at the CDR's own end.
987    #[test]
988    fn a_period_span_runs_to_the_next_period_and_the_last_to_the_cdrs_end() {
989        let cdr = cdr_with(
990            vec![period("2024-01-15T10:00:00Z", "4.3"), period("2024-01-15T10:30:00Z", "1.1")],
991            "2024-01-15T11:00:00Z",
992            "2024-01-15T11:05:00Z",
993        );
994        let spans: Vec<_> = cdr.period_spans().collect();
995        assert_eq!(spans.len(), 2);
996        assert_eq!(spans[0].end, dt("2024-01-15T10:30:00Z"), "the next period's start");
997        assert_eq!(spans[1].end, dt("2024-01-15T11:00:00Z"), "the CDR's end");
998        assert_eq!(spans[0].duration_seconds(), 1800);
999        assert_eq!(spans[1].duration_seconds(), 1800);
1000        assert_eq!(spans[0].volume(CdrDimensionType::Energy).map(|v| v.to_string()), Some("4.3".into()));
1001        assert!(spans[0].volume(CdrDimensionType::ParkingTime).is_none());
1002
1003        // The spans partition the session: they meet end-to-start and cover it exactly.
1004        assert_eq!(spans[0].start, cdr.start_date_time);
1005        assert_eq!(spans[0].end, spans[1].start);
1006        assert_eq!(spans.last().expect("a span").end, cdr.end_date_time);
1007    }
1008
1009    #[test]
1010    fn a_single_period_spans_the_whole_session() {
1011        let cdr = cdr_with(
1012            vec![period("2024-01-15T10:00:00Z", "5.4")],
1013            "2024-01-15T11:00:00Z",
1014            "2024-01-15T11:00:00Z",
1015        );
1016        let spans: Vec<_> = cdr.period_spans().collect();
1017        assert_eq!(spans.len(), 1);
1018        assert_eq!(spans[0].duration_seconds(), 3600);
1019    }
1020
1021    /// The latency a consumer with a filing deadline measures — and the one case that would
1022    /// otherwise report half a century.
1023    #[test]
1024    fn delivery_latency_is_measured_from_last_updated_and_skips_placeholder_timestamps() {
1025        let cdr = cdr_with(
1026            vec![period("2024-01-15T10:00:00Z", "1")],
1027            "2024-01-15T11:00:00Z",
1028            "2024-01-17T09:00:00Z",
1029        );
1030        assert_eq!(cdr.delivery_latency_seconds(), Some(2 * 86_400 - 2 * 3600));
1031
1032        // "the CPO could send a CDR where the start_date_time and/or end_date_time are set to
1033        //  1970-1-1T00:00:00Z" — a latency of 54 years is not a measurement.
1034        let mut placeholder = cdr.clone();
1035        placeholder.start_date_time = dt("1970-01-01T00:00:00Z");
1036        placeholder.end_date_time = dt("1970-01-01T00:00:00Z");
1037        assert!(placeholder.has_placeholder_timestamps());
1038        assert_eq!(placeholder.delivery_latency_seconds(), None);
1039
1040        // A CPO whose clock disagrees with its own session is shown, not clamped.
1041        let mut skewed = cdr;
1042        skewed.last_updated = dt("2024-01-15T10:59:00Z");
1043        assert_eq!(skewed.delivery_latency_seconds(), Some(-60));
1044    }
1045
1046    /// The signed record is evidence: it survives a round trip byte for byte, over-length or not.
1047    #[test]
1048    fn an_over_length_signed_blob_survives_a_round_trip_exactly() {
1049        // Real OCMF payloads run past the `string(5000)` the specification gives.
1050        let blob = "O".repeat(6000);
1051        let json = format!(r#"{{"nature":"End","plain_data":"{blob}","signed_data":"{blob}"}}"#);
1052        let value: SignedValue = serde_json::from_str(&json).expect("decodes");
1053        assert_eq!(value.signed_data.as_str(), blob, "not a byte moved");
1054        assert_eq!(serde_json::to_string(&value).expect("encodes"), json, "and it goes back out the same");
1055        assert_eq!(
1056            value.validate().expect_err("the length is still reported").as_slice()[0].code,
1057            crate::types::ViolationCode::TooLong,
1058        );
1059    }
1060
1061    /// `SignedData.url` is a `string(512)`, not the `string(255)` `URL` type.
1062    ///
1063    /// Modelled as a `Url` it reported a conformant link as `TooLong`, and — because
1064    /// `ClientConfig::validate_outgoing` is on by default — a client could not send the CDR
1065    /// carrying it.
1066    #[test]
1067    fn a_signed_data_url_may_run_past_the_length_of_an_ocpi_url() {
1068        use crate::types::Validate;
1069        let long = format!("https://e.com/{}", "a".repeat(300));
1070        assert!(long.len() > 255 && long.len() <= 512);
1071        let json = format!(
1072            r#"{{"encoding_method":"OCMF","signed_values":[{{"nature":"End","plain_data":"p","signed_data":"s"}}],"url":"{long}"}}"#
1073        );
1074        let data: SignedData = serde_json::from_str(&json).expect("decodes");
1075        assert_eq!(data.url.as_ref().expect("present").as_str(), long);
1076        data.validate().expect("a 314-character signed-data URL is conformant");
1077    }
1078
1079    #[test]
1080    fn signed_values_are_reachable_by_nature() {
1081        let value = |nature: &str| SignedValue {
1082            nature: crate::types::CiString::new(nature).expect("valid"),
1083            plain_data: crate::types::OcpiString::new_lenient("plain"),
1084            signed_data: crate::types::OcpiString::new_lenient("signed"),
1085            extensions: Extensions::new(),
1086        };
1087        let data = SignedData::builder()
1088            .encoding_method(crate::types::CiString::<36>::new("OCMF").expect("valid"))
1089            .signed_values(vec![value("Start"), value("End")])
1090            .build();
1091        assert!(data.start_value().is_some());
1092        assert!(data.end_value().is_some());
1093        // "Others might be added later", and the nature is a CiString.
1094        assert!(data.value_for("end").is_some(), "natures compare case-insensitively");
1095        assert!(data.value_for("Intermediate").is_none());
1096    }
1097}
1098
1099#[cfg(test)]
1100mod dimension_tests {
1101    use super::*;
1102
1103    /// The `bookings` branch's two reservation dimensions, on a **closed** enum: a missing value
1104    /// here makes the whole CDR undecodable rather than degrading. No fixture uses them, so only
1105    /// `xtask enum-coverage` sees the gap.
1106    #[test]
1107    fn the_bookings_branch_reservation_dimensions_decode() {
1108        for (wire, expected, unit) in [
1109            ("RESERVATION_TIME", CdrDimensionType::ReservationTime, "h"),
1110            ("RESERVATION_EXPIRES", CdrDimensionType::ReservationExpires, "h"),
1111            ("RESERVATION_OVERTIME", CdrDimensionType::ReservationOvertime, "h"),
1112        ] {
1113            let decoded: CdrDimensionType =
1114                serde_json::from_str(&format!("\"{wire}\"")).unwrap_or_else(|e| panic!("{wire}: {e}"));
1115            assert_eq!(decoded, expected);
1116            assert_eq!(serde_json::to_string(&decoded).expect("serialises"), format!("\"{wire}\""));
1117            assert_eq!(decoded.unit(), unit);
1118            assert!(!decoded.is_session_only(), "{wire} has no Session-Only mark in the branch table");
1119        }
1120    }
1121}
1122
1123#[cfg(test)]
1124mod period_sequence_tests {
1125    use super::*;
1126    use crate::types::Violation;
1127
1128    fn dt(s: &str) -> DateTime {
1129        s.parse().expect("a valid timestamp")
1130    }
1131
1132    fn check(starts: &[&str], start: &str, end: Option<&str>) -> Vec<Violation> {
1133        let mut v = Validator::new();
1134        validate_period_sequence(
1135            &starts.iter().map(|s| dt(s)).collect::<Vec<_>>(),
1136            dt(start),
1137            end.map(dt),
1138            &mut v,
1139        );
1140        v.finish().into_vec()
1141    }
1142
1143    #[test]
1144    fn a_well_formed_sequence_is_accepted() {
1145        assert!(
1146            check(
1147                &["2024-01-15T10:00:00Z", "2024-01-15T10:30:00Z", "2024-01-15T11:00:00Z"],
1148                "2024-01-15T10:00:00Z",
1149                Some("2024-01-15T11:30:00Z"),
1150            )
1151            .is_empty()
1152        );
1153    }
1154
1155    #[test]
1156    fn periods_out_of_order_are_reported_at_the_offending_index() {
1157        // The failure a merge of two period streams produces: everything is individually valid.
1158        let found = check(
1159            &["2024-01-15T10:00:00Z", "2024-01-15T11:00:00Z", "2024-01-15T10:30:00Z"],
1160            "2024-01-15T10:00:00Z",
1161            Some("2024-01-15T12:00:00Z"),
1162        );
1163        assert_eq!(found.len(), 1, "{found:?}");
1164        assert_eq!(found[0].pointer, "/charging_periods/2/start_date_time");
1165        assert_eq!(found[0].code, ViolationCode::Inconsistent);
1166    }
1167
1168    #[test]
1169    fn two_periods_at_the_same_instant_are_reported() {
1170        // Not merely unordered: a zero-length period has no duration to price.
1171        let found = check(&["2024-01-15T10:00:00Z", "2024-01-15T10:00:00Z"], "2024-01-15T10:00:00Z", None);
1172        assert_eq!(found.len(), 1, "{found:?}");
1173        assert_eq!(found[0].pointer, "/charging_periods/1/start_date_time");
1174    }
1175
1176    #[test]
1177    fn a_period_outside_the_session_is_reported() {
1178        let before = check(&["2024-01-15T09:00:00Z"], "2024-01-15T10:00:00Z", None);
1179        assert_eq!(before.len(), 1);
1180        assert!(before[0].message.contains("before the session started"), "{:?}", before[0]);
1181
1182        let after = check(&["2024-01-15T13:00:00Z"], "2024-01-15T10:00:00Z", Some("2024-01-15T12:00:00Z"));
1183        assert_eq!(after.len(), 1);
1184        assert!(after[0].message.contains("after the session ended"), "{:?}", after[0]);
1185    }
1186
1187    #[test]
1188    fn an_empty_or_single_period_list_has_nothing_to_disagree_with() {
1189        assert!(check(&[], "2024-01-15T10:00:00Z", None).is_empty());
1190        assert!(check(&["2024-01-15T10:00:00Z"], "2024-01-15T10:00:00Z", None).is_empty());
1191    }
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196    use super::*;
1197
1198    fn dim(t: CdrDimensionType, v: &str) -> CdrDimension {
1199        CdrDimension::new(t, v.parse().unwrap())
1200    }
1201
1202    #[test]
1203    fn session_only_dimensions_are_rejected_in_a_cdr() {
1204        let p = ChargingPeriod::builder()
1205            .start_date_time("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
1206            .dimensions(vec![dim(CdrDimensionType::StateOfCharge, "50")])
1207            .build();
1208        assert!(p.validate().is_ok(), "a Session may carry STATE_OF_CHARGE");
1209        assert!(CdrDimensionType::StateOfCharge.is_session_only());
1210        assert!(!CdrDimensionType::Energy.is_session_only());
1211    }
1212
1213    #[test]
1214    fn dimension_units_and_signs_follow_the_table() {
1215        assert_eq!(CdrDimensionType::Energy.unit(), "kWh");
1216        assert_eq!(CdrDimensionType::ParkingTime.unit(), "h");
1217        assert!(CdrDimensionType::Power.may_be_negative(), "V2G power flows both ways");
1218        assert!(!CdrDimensionType::ParkingTime.may_be_negative());
1219        assert!(dim(CdrDimensionType::ParkingTime, "-1").validate().is_err());
1220        assert!(dim(CdrDimensionType::Power, "-7.5").validate().is_ok());
1221        assert!(dim(CdrDimensionType::StateOfCharge, "101").validate().is_err());
1222    }
1223
1224    #[test]
1225    fn a_period_cannot_measure_the_same_dimension_twice() {
1226        let p = ChargingPeriod::builder()
1227            .start_date_time("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
1228            .dimensions(vec![dim(CdrDimensionType::Energy, "1"), dim(CdrDimensionType::Energy, "2")])
1229            .build();
1230        assert_eq!(p.validate().unwrap_err().as_slice()[0].code, ViolationCode::Inconsistent);
1231    }
1232
1233    #[test]
1234    fn empty_dimensions_are_a_cardinality_violation() {
1235        let p = ChargingPeriod::builder()
1236            .start_date_time("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
1237            .dimensions(vec![])
1238            .build();
1239        assert_eq!(p.validate().unwrap_err().as_slice()[0].code, ViolationCode::EmptyRequiredList);
1240    }
1241}