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, Url, 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    /// Whether the timestamps are the `1970-1-1T00:00:00Z` placeholder the spec permits.
171    ///
172    /// > *If the MSP and CPO both agree that they accept CDRs that miss either or both the
173    /// > `start_date_time` and `end_date_time` … the CPO could send a CDR where the
174    /// > `start_date_time` and/or `end_date_time` are set to "1970-1-1T00:00:00Z".*
175    #[must_use]
176    pub fn has_placeholder_timestamps(&self) -> bool {
177        self.start_date_time.unix_timestamp() == 0 || self.end_date_time.unix_timestamp() == 0
178    }
179}
180
181impl Validate for Cdr {
182    fn validate_in(&self, v: &mut Validator) {
183        validate_fields!(
184            self,
185            v,
186            country_code,
187            party_id,
188            id,
189            start_date_time,
190            end_date_time,
191            session_id,
192            cdr_token,
193            auth_method,
194            authorization_reference,
195            cdr_location,
196            meter_id,
197            currency,
198            tariffs,
199            charging_periods,
200            signed_data,
201            total_cost,
202            total_fixed_cost,
203            total_energy,
204            total_energy_cost,
205            total_time,
206            total_time_cost,
207            total_parking_time,
208            total_parking_cost,
209            total_reservation_cost,
210            remark,
211            invoice_reference_id,
212            credit_reference_id,
213            last_updated,
214        );
215
216        if self.charging_periods.is_empty() {
217            v.report_at(
218                "charging_periods",
219                ViolationCode::EmptyRequiredList,
220                "a CDR has cardinality `+` charging_periods: at least one is required",
221            );
222        }
223
224        // "Normal (non-credit) CDRs SHALL only have an ID with a maximum length of 36."
225        if !self.is_credit() && self.id.len() > NON_CREDIT_ID_MAX_LEN {
226            v.report_at(
227                "id",
228                ViolationCode::TooLong,
229                format!(
230                    "a non-credit CDR id may be at most {NON_CREDIT_ID_MAX_LEN} characters; \
231                     the extra length is reserved for credit CDRs"
232                ),
233            );
234        }
235
236        // "When set to true, this is a Credit CDR, and the field credit_reference_id needs to be
237        //  set as well."
238        if self.is_credit() && self.credit_reference_id.is_none() {
239            v.report_at(
240                "credit_reference_id",
241                ViolationCode::MissingConditional,
242                "is required to be set for a Credit CDR",
243            );
244        }
245        if !self.is_credit() && self.credit_reference_id.is_some() {
246            v.report_at(
247                "credit",
248                ViolationCode::Inconsistent,
249                "credit_reference_id is set, so `credit` should be true",
250            );
251        }
252
253        if !self.has_placeholder_timestamps() && self.end_date_time < self.start_date_time {
254            v.report_at(
255                "end_date_time",
256                ViolationCode::Inconsistent,
257                "a session cannot end before it starts",
258            );
259        }
260
261        // The energy total has to agree with the metered ENERGY dimensions.
262        let metered = self.dimension_total(CdrDimensionType::Energy);
263        if !self.charging_periods.is_empty()
264            && self
265                .charging_periods
266                .iter()
267                .any(|p| p.dimensions.iter().any(|d| d.dimension_type == CdrDimensionType::Energy))
268            && metered != self.total_energy
269        {
270            v.report_at(
271                "total_energy",
272                ViolationCode::Inconsistent,
273                format!(
274                    "is {}, but the ENERGY dimensions of the charging periods add up to {metered}",
275                    self.total_energy
276                ),
277            );
278        }
279
280        validate_period_sequence(
281            &self.charging_periods.iter().map(|p| p.start_date_time).collect::<Vec<_>>(),
282            self.start_date_time,
283            Some(self.end_date_time),
284            v,
285        );
286
287        if self.total_parking_time.is_some_and(|p| p > self.total_time) {
288            v.report_at(
289                "total_parking_time",
290                ViolationCode::Inconsistent,
291                "cannot exceed total_time, of which it is a part",
292            );
293        }
294
295        // "SHALL only be used in Sessions" — these dimensions have no meaning in a CDR.
296        for (i, period) in self.charging_periods.iter().enumerate() {
297            for (j, dim) in period.dimensions.iter().enumerate() {
298                if dim.dimension_type.is_session_only() {
299                    v.enter("charging_periods");
300                    v.enter(&i.to_string());
301                    v.enter("dimensions");
302                    v.enter(&j.to_string());
303                    v.report_at(
304                        "type",
305                        ViolationCode::Inconsistent,
306                        format!(
307                            "{} is marked \"Session Only\" and SHALL NOT appear in a CDR",
308                            dim.dimension_type
309                        ),
310                    );
311                    v.leave();
312                    v.leave();
313                    v.leave();
314                    v.leave();
315                }
316            }
317        }
318    }
319}
320
321/// The token that started a session, as recorded in a CDR or Session.
322///
323/// Spec: 2.3.0 §mod_cdrs_cdr_token_object
324#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
325#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
326#[builder(on(_, into))]
327pub struct CdrToken {
328    /// ISO-3166 alpha-2 country code of the MSP that 'owns' this Token.
329    pub country_code: CountryCode,
330    /// ID of the eMSP that 'owns' this Token.
331    pub party_id: PartyId,
332    /// Unique ID by which this Token can be identified by the CPO's system.
333    pub uid: CiString<36>,
334    /// Type of the token.
335    #[serde(rename = "type")]
336    pub token_type: TokenType,
337    /// Uniquely identifies the EV driver contract token within the eMSP's platform.
338    pub contract_id: ContractId,
339    /// Undocumented JSON fields, preserved verbatim.
340    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
341    #[builder(default)]
342    pub extensions: Extensions,
343}
344
345impl CdrToken {
346    /// The eMSP that owns this Token.
347    #[must_use]
348    pub fn owner_party(&self) -> PartyRef {
349        PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
350    }
351}
352
353impl Validate for CdrToken {
354    fn validate_in(&self, v: &mut Validator) {
355        validate_fields!(self, v, country_code, party_id, uid, token_type as "type", contract_id);
356    }
357}
358
359/// The parts of a Location that a CDR needs, frozen at the start of the session.
360///
361/// Spec: 2.3.0 §mod_cdrs_cdr_location_class
362#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
363#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
364#[builder(on(_, into))]
365pub struct CdrLocation {
366    /// Uniquely identifies the location within the CPO's platform.
367    pub id: CiString<36>,
368    /// Display name of the location.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub name: Option<OcpiString<255>>,
371    /// Street/block name and house number if available.
372    pub address: OcpiString<45>,
373    /// City or town.
374    pub city: OcpiString<45>,
375    /// Postal code of the location.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub postal_code: Option<OcpiString<10>>,
378    /// State, only to be used when relevant.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub state: Option<OcpiString<20>>,
381    /// ISO 3166-1 alpha-3 code for the country of this location.
382    pub country: OcpiString<3>,
383    /// Coordinates of the location.
384    pub coordinates: GeoLocation,
385    /// The EVSE's technical identifier. May be `#NA` for a reservation that never charged.
386    pub evse_uid: CiString<36>,
387    /// The EVSE's human-readable ID. May be `#NA` for a reservation that never charged.
388    pub evse_id: EvseId,
389    /// Identifier of the connector within the EVSE. May be `#NA`.
390    pub connector_id: CiString<36>,
391    /// The standard of the installed connector.
392    pub connector_standard: ConnectorType,
393    /// The format (socket/cable) of the installed connector.
394    pub connector_format: ConnectorFormat,
395    /// Whether the connector supplies AC or DC, and on how many phases.
396    pub connector_power_type: PowerType,
397    /// Undocumented JSON fields, preserved verbatim.
398    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
399    #[builder(default)]
400    pub extensions: Extensions,
401}
402
403impl CdrLocation {
404    /// Whether this CDR records a reservation that never became a charging session.
405    ///
406    /// The spec marks `evse_uid`, `evse_id` and `connector_id` as *"allowed to be set to `#NA`
407    /// when this CDR is created for a reservation that never resulted in a charging session"*,
408    /// in which case the connector fields *"can be set to any value and should be ignored"*.
409    #[must_use]
410    pub fn is_reservation_only(&self) -> bool {
411        self.evse_uid.is_not_available()
412            || self.evse_id.is_not_available()
413            || self.connector_id.is_not_available()
414    }
415}
416
417impl Validate for CdrLocation {
418    fn validate_in(&self, v: &mut Validator) {
419        validate_fields!(
420            self,
421            v,
422            id,
423            name,
424            address,
425            city,
426            postal_code,
427            state,
428            country,
429            coordinates,
430            evse_uid,
431            evse_id,
432            connector_id,
433            connector_standard,
434            connector_format,
435            connector_power_type,
436        );
437    }
438}
439
440/// A period of a session during which the values that influence its cost were stable.
441///
442/// > *A CPO SHALL at least start (and add) a ChargingPeriod every moment/event that has relevance
443/// > for the total costs of a CDR.*
444///
445/// Spec: 2.3.0 §mod_cdrs_chargingperiod_class
446#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
447#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
448#[builder(on(_, into))]
449pub struct ChargingPeriod {
450    /// Start of the charging period. A period ends when the next one starts.
451    pub start_date_time: DateTime,
452    /// Relevant values for this charging period. Cardinality `+`.
453    pub dimensions: Vec<CdrDimension>,
454    /// The Tariff relevant during this period. When absent, no Tariff is relevant.
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub tariff_id: Option<CiString<36>>,
457    /// Undocumented JSON fields, preserved verbatim.
458    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
459    #[builder(default)]
460    pub extensions: Extensions,
461}
462
463impl ChargingPeriod {
464    /// The volume recorded for one dimension in this period.
465    #[must_use]
466    pub fn volume(&self, dimension: CdrDimensionType) -> Option<Number> {
467        self.dimensions.iter().find(|d| d.dimension_type == dimension).map(|d| d.volume)
468    }
469}
470
471impl Validate for ChargingPeriod {
472    fn validate_in(&self, v: &mut Validator) {
473        validate_fields!(self, v, start_date_time, dimensions, tariff_id);
474        if self.dimensions.is_empty() {
475            v.report_at(
476                "dimensions",
477                ViolationCode::EmptyRequiredList,
478                "a ChargingPeriod has cardinality `+` dimensions: at least one is required",
479            );
480        }
481        let mut seen: Vec<&CdrDimensionType> = Vec::new();
482        for d in &self.dimensions {
483            if seen.contains(&&d.dimension_type) {
484                v.report_at(
485                    "dimensions",
486                    ViolationCode::Inconsistent,
487                    format!("the dimension {} appears more than once in one period", d.dimension_type),
488                );
489            }
490            seen.push(&d.dimension_type);
491        }
492    }
493}
494
495/// Checks that a list of Charging Periods is a sequence a session could actually have had.
496///
497/// # Why this is worth checking
498///
499/// Nothing in the property tables says the periods are ordered, but everything built on them
500/// assumes it. `step_size` is defined in terms of *"the last relevant PriceComponent"* and
501/// *"the last time-based period"*; a period's duration is only knowable as the gap to the next
502/// one; and a pricing engine reading them out of order will quietly bill the wrong rate.
503///
504/// It is also the failure that shows up in practice. Charging periods arrive from a CSMS through
505/// a CPO's own aggregation, and a merge that loses the sort is invisible in every field-by-field
506/// check — the objects are all individually valid.
507///
508/// So this reports three things, each as a [`ViolationCode::Inconsistent`] at the offending
509/// index: a period that does not start after the one before it, one that starts before the
510/// session did, and one that starts at or after the session ended.
511///
512/// Spec: 2.3.0 §mod_cdrs_cdr_object, §mod_cdrs_step_size
513pub fn validate_period_sequence(
514    starts: &[DateTime],
515    session_start: DateTime,
516    session_end: Option<DateTime>,
517    v: &mut Validator,
518) {
519    let mut previous: Option<DateTime> = None;
520    for (i, start) in starts.iter().copied().enumerate() {
521        let at = |v: &mut Validator, message: String| {
522            v.enter("charging_periods");
523            v.enter(&i.to_string());
524            v.report_at("start_date_time", ViolationCode::Inconsistent, message);
525            v.leave();
526            v.leave();
527        };
528        if let Some(previous) = previous
529            && start <= previous
530        {
531            at(
532                v,
533                format!(
534                    "is {start}, which is not after the previous period's {previous}; \
535                     charging periods have to be in order for `step_size` and for a period's \
536                     own duration to mean anything"
537                ),
538            );
539        }
540        if start < session_start {
541            at(v, format!("is {start}, before the session started at {session_start}"));
542        }
543        if let Some(end) = session_end
544            && start >= end
545        {
546            at(v, format!("is {start}, at or after the session ended at {end}"));
547        }
548        previous = Some(start);
549    }
550}
551
552/// One measured quantity within a [`ChargingPeriod`].
553///
554/// Spec: 2.3.0 §mod_cdrs_cdrdimension_class
555#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
556#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
557pub struct CdrDimension {
558    /// Type of CDR dimension.
559    #[serde(rename = "type")]
560    pub dimension_type: CdrDimensionType,
561    /// Volume of the dimension consumed, measured according to the dimension type.
562    pub volume: Number,
563    /// Undocumented JSON fields, preserved verbatim.
564    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
565    pub extensions: Extensions,
566}
567
568impl CdrDimension {
569    /// Creates a dimension measurement.
570    #[must_use]
571    pub fn new(dimension_type: CdrDimensionType, volume: Number) -> Self {
572        Self { dimension_type, volume, extensions: Extensions::new() }
573    }
574}
575
576impl Validate for CdrDimension {
577    fn validate_in(&self, v: &mut Validator) {
578        validate_fields!(self, v, dimension_type as "type", volume);
579        if self.dimension_type == CdrDimensionType::StateOfCharge {
580            let pct = self.volume;
581            if pct < Number::ZERO || pct > Number::from(100u32) {
582                v.report_at(
583                    "volume",
584                    ViolationCode::OutOfRange,
585                    "STATE_OF_CHARGE is a percentage: values allowed are 0 to 100",
586                );
587            }
588        }
589        if !self.dimension_type.may_be_negative() && self.volume.is_negative() {
590            v.report_at(
591                "volume",
592                ViolationCode::OutOfRange,
593                format!("{} cannot be negative", self.dimension_type),
594            );
595        }
596    }
597}
598
599/// Signed metering data, for German *Eichrecht* and comparable regimes.
600///
601/// Spec: 2.3.0 §mod_cdrs_signed_data_class
602#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
603#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
604#[builder(on(_, into))]
605pub struct SignedData {
606    /// The name of the encoding used, as given by a company or group of companies.
607    ///
608    /// Known implementations include `OCMF`, `Alfen Eichrecht`, `EDL40 E-Mobility Extension` and
609    /// `EDL40 Mennekes`.
610    pub encoding_method: CiString<36>,
611    /// Version of the encoding method, when applicable.
612    #[serde(default, skip_serializing_if = "Option::is_none")]
613    pub encoding_method_version: Option<i32>,
614    /// Public key used to sign the data, base64 encoded.
615    #[serde(default, skip_serializing_if = "Option::is_none")]
616    pub public_key: Option<OcpiString<512>>,
617    /// One or more signed values. Cardinality `+`.
618    pub signed_values: Vec<SignedValue>,
619    /// URL where an EV driver can check the signed data of a charging session.
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub url: Option<Url>,
622    /// Undocumented JSON fields, preserved verbatim.
623    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
624    #[builder(default)]
625    pub extensions: Extensions,
626}
627
628impl Validate for SignedData {
629    fn validate_in(&self, v: &mut Validator) {
630        validate_fields!(self, v, encoding_method, public_key, signed_values, url,);
631        if self.signed_values.is_empty() {
632            v.report_at(
633                "signed_values",
634                ViolationCode::EmptyRequiredList,
635                "SignedData has cardinality `+` signed_values: at least one is required",
636            );
637        }
638    }
639}
640
641/// One signed and plain value pair.
642///
643/// Spec: 2.3.0 §mod_cdrs_signed_value_class
644#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
645#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
646pub struct SignedValue {
647    /// Nature of the value: the event it belongs to.
648    ///
649    /// > *Possible values at moment of writing: Start, End, Intermediate. Others might be added
650    /// > later.*
651    pub nature: CiString<32>,
652    /// The un-encoded string of data. Its format depends on the encoding method.
653    ///
654    /// NOTE: earlier releases of the OCPI 2.3.0 documentation mistakenly gave a maximum of 512.
655    pub plain_data: OcpiString<5000>,
656    /// Blob of signed data, base64 encoded.
657    pub signed_data: OcpiString<5000>,
658    /// Undocumented JSON fields, preserved verbatim.
659    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
660    pub extensions: Extensions,
661}
662
663impl Validate for SignedValue {
664    fn validate_in(&self, v: &mut Validator) {
665        validate_fields!(self, v, nature, plain_data, signed_data);
666    }
667}
668
669ocpi_enum! {
670    /// How the driver was authenticated for a session.
671    ///
672    /// Spec: 2.3.0 §mod_cdrs_authmethod_enum
673    pub enum AuthMethod {
674        /// An authentication request was sent to the eMSP.
675        AuthRequest = "AUTH_REQUEST",
676        /// A command such as `StartSession` or `ReserveNow` started the session.
677        Command = "COMMAND",
678        /// A whitelist was used; no request to the eMSP was performed.
679        Whitelist = "WHITELIST",
680    }
681}
682
683ocpi_enum! {
684    /// The quantities a [`ChargingPeriod`] can record.
685    ///
686    /// Some values are marked *Session Only* in the spec and must not appear in a CDR; see
687    /// [`CdrDimensionType::is_session_only`].
688    ///
689    /// Spec: 2.3.0 §mod_cdrs_cdrdimensiontype_enum
690    pub enum CdrDimensionType {
691        /// Average charging current during this period, in A. Negative flows to the grid.
692        Current = "CURRENT",
693        /// Total energy (dis-)charged during this period, in kWh. Default `step_size` is 1.
694        Energy = "ENERGY",
695        /// Total energy fed back into the grid, in kWh.
696        EnergyExport = "ENERGY_EXPORT",
697        /// Total energy charged, in kWh.
698        EnergyImport = "ENERGY_IMPORT",
699        /// Sum of the maximum current over all phases reached during this period, in A.
700        MaxCurrent = "MAX_CURRENT",
701        /// Sum of the minimum current over all phases reached during this period, in A.
702        MinCurrent = "MIN_CURRENT",
703        /// Maximum power reached during this period, in kW.
704        MaxPower = "MAX_POWER",
705        /// Minimum power reached during this period, in kW.
706        MinPower = "MIN_POWER",
707        /// Time during which the vehicle is not requesting power, in hours.
708        ///
709        /// > *NOTE: Earlier versions of the OCPI 2.3.0 specification document mistakenly defined
710        /// > PARKING_TIME as "Time during this ChargingPeriod not charging".*
711        ParkingTime = "PARKING_TIME",
712        /// Average power during this period, in kW. Negative flows to the grid.
713        Power = "POWER",
714        /// Time the EVSE has been reserved and not yet in use for this customer, in hours.
715        ReservationTime = "RESERVATION_TIME",
716        /// Time a reservation was held that then **expired**, in hours.
717        ///
718        /// From the 2.3.0 `bookings` branch, which core 2.3.0 does not have. Declared
719        /// unconditionally rather than behind the feature because this enum is **closed**: a
720        /// booking-aware CPO sending it would otherwise make the whole CDR undecodable.
721        ///
722        /// Spec: 2.3.0-bookings §mod_cdrs_cdrdimensiontype_enum
723        ReservationExpires = "RESERVATION_EXPIRES",
724        /// Time the session continued **after** the reserved slot ended, in hours.
725        ///
726        /// Also from the `bookings` branch, and unconditional for the same reason.
727        ///
728        /// Spec: 2.3.0-bookings §mod_cdrs_cdrdimensiontype_enum
729        ReservationOvertime = "RESERVATION_OVERTIME",
730        /// Current state of charge of the EV, in percent, 0 to 100.
731        StateOfCharge = "STATE_OF_CHARGE",
732        /// Time charging in this period, in hours.
733        Time = "TIME",
734    }
735}
736
737impl CdrDimensionType {
738    /// Whether the spec marks this dimension *Session Only*.
739    ///
740    /// > *Some of these values are not useful for CDRs, and SHALL therefore only be used in
741    /// > Sessions.*
742    ///
743    /// Spec: 2.3.0 §mod_cdrs_cdrdimensiontype_enum
744    #[must_use]
745    pub const fn is_session_only(self) -> bool {
746        matches!(
747            self,
748            Self::Current | Self::EnergyExport | Self::EnergyImport | Self::Power | Self::StateOfCharge
749        )
750    }
751
752    /// Whether a negative volume is meaningful for this dimension.
753    ///
754    /// The spec says so explicitly for the bidirectional quantities: *"When negative, the current
755    /// is flowing from the EV to the grid."*
756    #[must_use]
757    pub const fn may_be_negative(self) -> bool {
758        matches!(self, Self::Current | Self::Energy | Self::MinCurrent | Self::MinPower | Self::Power)
759    }
760
761    /// The unit the volume is measured in.
762    #[must_use]
763    pub const fn unit(self) -> &'static str {
764        match self {
765            Self::Current | Self::MaxCurrent | Self::MinCurrent => "A",
766            Self::Energy | Self::EnergyExport | Self::EnergyImport => "kWh",
767            Self::MaxPower | Self::MinPower | Self::Power => "kW",
768            Self::ParkingTime
769            | Self::ReservationTime
770            | Self::ReservationExpires
771            | Self::ReservationOvertime
772            | Self::Time => "h",
773            Self::StateOfCharge => "%",
774        }
775    }
776}
777
778#[cfg(test)]
779mod dimension_tests {
780    use super::*;
781
782    /// The `bookings` branch's two reservation dimensions, on a **closed** enum: a missing value
783    /// here makes the whole CDR undecodable rather than degrading. No fixture uses them, so only
784    /// `xtask enum-coverage` sees the gap.
785    #[test]
786    fn the_bookings_branch_reservation_dimensions_decode() {
787        for (wire, expected, unit) in [
788            ("RESERVATION_TIME", CdrDimensionType::ReservationTime, "h"),
789            ("RESERVATION_EXPIRES", CdrDimensionType::ReservationExpires, "h"),
790            ("RESERVATION_OVERTIME", CdrDimensionType::ReservationOvertime, "h"),
791        ] {
792            let decoded: CdrDimensionType =
793                serde_json::from_str(&format!("\"{wire}\"")).unwrap_or_else(|e| panic!("{wire}: {e}"));
794            assert_eq!(decoded, expected);
795            assert_eq!(serde_json::to_string(&decoded).expect("serialises"), format!("\"{wire}\""));
796            assert_eq!(decoded.unit(), unit);
797            assert!(!decoded.is_session_only(), "{wire} has no Session-Only mark in the branch table");
798        }
799    }
800}
801
802#[cfg(test)]
803mod period_sequence_tests {
804    use super::*;
805    use crate::types::Violation;
806
807    fn dt(s: &str) -> DateTime {
808        s.parse().expect("a valid timestamp")
809    }
810
811    fn check(starts: &[&str], start: &str, end: Option<&str>) -> Vec<Violation> {
812        let mut v = Validator::new();
813        validate_period_sequence(
814            &starts.iter().map(|s| dt(s)).collect::<Vec<_>>(),
815            dt(start),
816            end.map(dt),
817            &mut v,
818        );
819        v.finish().into_vec()
820    }
821
822    #[test]
823    fn a_well_formed_sequence_is_accepted() {
824        assert!(
825            check(
826                &["2024-01-15T10:00:00Z", "2024-01-15T10:30:00Z", "2024-01-15T11:00:00Z"],
827                "2024-01-15T10:00:00Z",
828                Some("2024-01-15T11:30:00Z"),
829            )
830            .is_empty()
831        );
832    }
833
834    #[test]
835    fn periods_out_of_order_are_reported_at_the_offending_index() {
836        // The failure a merge of two period streams produces: everything is individually valid.
837        let found = check(
838            &["2024-01-15T10:00:00Z", "2024-01-15T11:00:00Z", "2024-01-15T10:30:00Z"],
839            "2024-01-15T10:00:00Z",
840            Some("2024-01-15T12:00:00Z"),
841        );
842        assert_eq!(found.len(), 1, "{found:?}");
843        assert_eq!(found[0].pointer, "/charging_periods/2/start_date_time");
844        assert_eq!(found[0].code, ViolationCode::Inconsistent);
845    }
846
847    #[test]
848    fn two_periods_at_the_same_instant_are_reported() {
849        // Not merely unordered: a zero-length period has no duration to price.
850        let found = check(&["2024-01-15T10:00:00Z", "2024-01-15T10:00:00Z"], "2024-01-15T10:00:00Z", None);
851        assert_eq!(found.len(), 1, "{found:?}");
852        assert_eq!(found[0].pointer, "/charging_periods/1/start_date_time");
853    }
854
855    #[test]
856    fn a_period_outside_the_session_is_reported() {
857        let before = check(&["2024-01-15T09:00:00Z"], "2024-01-15T10:00:00Z", None);
858        assert_eq!(before.len(), 1);
859        assert!(before[0].message.contains("before the session started"), "{:?}", before[0]);
860
861        let after = check(&["2024-01-15T13:00:00Z"], "2024-01-15T10:00:00Z", Some("2024-01-15T12:00:00Z"));
862        assert_eq!(after.len(), 1);
863        assert!(after[0].message.contains("after the session ended"), "{:?}", after[0]);
864    }
865
866    #[test]
867    fn an_empty_or_single_period_list_has_nothing_to_disagree_with() {
868        assert!(check(&[], "2024-01-15T10:00:00Z", None).is_empty());
869        assert!(check(&["2024-01-15T10:00:00Z"], "2024-01-15T10:00:00Z", None).is_empty());
870    }
871}
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876
877    fn dim(t: CdrDimensionType, v: &str) -> CdrDimension {
878        CdrDimension::new(t, v.parse().unwrap())
879    }
880
881    #[test]
882    fn session_only_dimensions_are_rejected_in_a_cdr() {
883        let p = ChargingPeriod::builder()
884            .start_date_time("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
885            .dimensions(vec![dim(CdrDimensionType::StateOfCharge, "50")])
886            .build();
887        assert!(p.validate().is_ok(), "a Session may carry STATE_OF_CHARGE");
888        assert!(CdrDimensionType::StateOfCharge.is_session_only());
889        assert!(!CdrDimensionType::Energy.is_session_only());
890    }
891
892    #[test]
893    fn dimension_units_and_signs_follow_the_table() {
894        assert_eq!(CdrDimensionType::Energy.unit(), "kWh");
895        assert_eq!(CdrDimensionType::ParkingTime.unit(), "h");
896        assert!(CdrDimensionType::Power.may_be_negative(), "V2G power flows both ways");
897        assert!(!CdrDimensionType::ParkingTime.may_be_negative());
898        assert!(dim(CdrDimensionType::ParkingTime, "-1").validate().is_err());
899        assert!(dim(CdrDimensionType::Power, "-7.5").validate().is_ok());
900        assert!(dim(CdrDimensionType::StateOfCharge, "101").validate().is_err());
901    }
902
903    #[test]
904    fn a_period_cannot_measure_the_same_dimension_twice() {
905        let p = ChargingPeriod::builder()
906            .start_date_time("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
907            .dimensions(vec![dim(CdrDimensionType::Energy, "1"), dim(CdrDimensionType::Energy, "2")])
908            .build();
909        assert_eq!(p.validate().unwrap_err().as_slice()[0].code, ViolationCode::Inconsistent);
910    }
911
912    #[test]
913    fn empty_dimensions_are_a_cardinality_violation() {
914        let p = ChargingPeriod::builder()
915            .start_date_time("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
916            .dimensions(vec![])
917            .build();
918        assert_eq!(p.validate().unwrap_err().as_slice()[0].code, ViolationCode::EmptyRequiredList);
919    }
920}