Skip to main content

ocpi_kit/v2_1_1/
cdrs.rs

1//! The *CDRs* module of OCPI 2.1.1.
2//!
3//! The 2.1.1 CDR is markedly smaller than its successors: the cost is a bare `number` excluding
4//! VAT rather than a `Price`, the location is a **whole `Location` object** rather than a
5//! purpose-built `CdrLocation`, the driver is identified by `auth_id` rather than by a
6//! `CdrToken`, and there is no `session_id`, no signed metering data and no credit CDR.
7//!
8//! Spec: 2.1.1 §mod_cdrs
9
10use bon::Builder;
11use serde::{Deserialize, Serialize};
12
13use crate::ocpi_lenient_enum;
14use crate::types::validate_fields;
15use crate::types::{
16    CiString, Currency, DateTime, Extensions, Number, OcpiString, Validate, Validator, ViolationCode,
17};
18
19use super::locations::Location;
20use super::tariffs::Tariff;
21
22/// A Charge Detail Record, in OCPI 2.1.1.
23///
24/// Spec: 2.1.1 §mod_cdrs_cdr_object
25#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[builder(on(_, into))]
28pub struct Cdr {
29    /// Uniquely identifies the CDR within the CPO's platform.
30    pub id: CiString<36>,
31    /// Start timestamp of the charging session.
32    pub start_date_time: DateTime,
33    /// Stop timestamp of the charging session.
34    ///
35    /// Renamed to `end_date_time` in OCPI 2.2.
36    pub stop_date_time: DateTime,
37    /// Reference to the `auth_id` of the Token that started the session.
38    ///
39    /// Replaced by the richer `cdr_token` object in OCPI 2.2.
40    pub auth_id: OcpiString<36>,
41    /// Method used for authentication.
42    pub auth_method: AuthMethod,
43    /// Where the charging session took place.
44    ///
45    /// A whole `Location` object, *"including only the relevant EVSE and Connector"*. OCPI 2.2
46    /// replaced this with the purpose-built `CdrLocation`, which is both smaller and unambiguous
47    /// about which EVSE the session used.
48    pub location: Location,
49    /// Identification of the meter inside the Charge Point.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub meter_id: Option<OcpiString<255>>,
52    /// Currency of the CDR in ISO 4217 code.
53    pub currency: Currency,
54    /// Relevant Tariffs.
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    #[builder(default)]
57    pub tariffs: Vec<Tariff>,
58    /// Charging Periods that make up this session. Cardinality `+`.
59    pub charging_periods: Vec<ChargingPeriod>,
60    /// Total cost of this transaction, **excluding VAT**.
61    pub total_cost: Number,
62    /// Total energy charged, in kWh.
63    pub total_energy: Number,
64    /// Total duration of the session, in hours.
65    pub total_time: Number,
66    /// Total duration during which the EV was not charging, in hours.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub total_parking_time: Option<Number>,
69    /// Human-readable remark.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub remark: Option<OcpiString<255>>,
72    /// Timestamp when this CDR was last updated (or created).
73    pub last_updated: DateTime,
74    /// Undocumented JSON fields, preserved verbatim.
75    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
76    #[builder(default)]
77    pub extensions: Extensions,
78}
79
80impl Cdr {
81    /// The time the EV was actually charging, in hours.
82    #[must_use]
83    pub fn total_charging_time(&self) -> Number {
84        self.total_time - self.total_parking_time.unwrap_or(Number::ZERO)
85    }
86
87    /// The total volume of one dimension across every charging period.
88    #[must_use]
89    pub fn dimension_total(&self, dimension: CdrDimensionType) -> Number {
90        self.charging_periods
91            .iter()
92            .flat_map(|p| p.dimensions.iter())
93            .filter(|d| d.dimension_type == dimension)
94            .map(|d| d.volume)
95            .sum()
96    }
97}
98
99impl Validate for Cdr {
100    fn validate_in(&self, v: &mut Validator) {
101        validate_fields!(
102            self,
103            v,
104            id,
105            start_date_time,
106            stop_date_time,
107            auth_id,
108            auth_method,
109            location,
110            meter_id,
111            currency,
112            tariffs,
113            charging_periods,
114            total_cost,
115            total_energy,
116            total_time,
117            total_parking_time,
118            remark,
119            last_updated,
120        );
121        crate::v2_3_0::cdrs::validate_period_sequence(
122            &self.charging_periods.iter().map(|p| p.start_date_time).collect::<Vec<_>>(),
123            self.start_date_time,
124            Some(self.stop_date_time),
125            v,
126        );
127        if self.charging_periods.is_empty() {
128            v.report_at(
129                "charging_periods",
130                ViolationCode::EmptyRequiredList,
131                "a CDR has cardinality `+` charging_periods: at least one is required",
132            );
133        }
134        if self.stop_date_time < self.start_date_time {
135            v.report_at(
136                "stop_date_time",
137                ViolationCode::Inconsistent,
138                "a session cannot stop before it starts",
139            );
140        }
141        if self.total_parking_time.is_some_and(|p| p > self.total_time) {
142            v.report_at(
143                "total_parking_time",
144                ViolationCode::Inconsistent,
145                "cannot exceed total_time, of which it is a part",
146            );
147        }
148    }
149}
150
151/// A period of a session during which the values that influence its cost were stable.
152///
153/// Identical in shape to the later versions except that its dimensions are the six of
154/// [`CdrDimensionType`], and it has no `tariff_id` — that arrived in OCPI 2.2.
155///
156/// Spec: 2.1.1 §mod_cdrs_chargingperiod_class
157#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
159#[builder(on(_, into))]
160pub struct ChargingPeriod {
161    /// Start of the charging period.
162    pub start_date_time: DateTime,
163    /// Relevant values for this charging period. Cardinality `+`.
164    pub dimensions: Vec<CdrDimension>,
165    /// Undocumented JSON fields, preserved verbatim.
166    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
167    #[builder(default)]
168    pub extensions: Extensions,
169}
170
171impl ChargingPeriod {
172    /// The volume recorded for one dimension in this period.
173    #[must_use]
174    pub fn volume(&self, dimension: CdrDimensionType) -> Option<Number> {
175        self.dimensions.iter().find(|d| d.dimension_type == dimension).map(|d| d.volume)
176    }
177}
178
179impl Validate for ChargingPeriod {
180    fn validate_in(&self, v: &mut Validator) {
181        validate_fields!(self, v, start_date_time, dimensions);
182        if self.dimensions.is_empty() {
183            v.report_at(
184                "dimensions",
185                ViolationCode::EmptyRequiredList,
186                "a ChargingPeriod has cardinality `+` dimensions: at least one is required",
187            );
188        }
189    }
190}
191
192/// One measured quantity within a [`ChargingPeriod`].
193///
194/// Spec: 2.1.1 §mod_cdrs_cdrdimension_class
195#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197pub struct CdrDimension {
198    /// Type of CDR dimension.
199    #[serde(rename = "type")]
200    pub dimension_type: CdrDimensionType,
201    /// Volume of the dimension consumed.
202    pub volume: Number,
203    /// Undocumented JSON fields, preserved verbatim.
204    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
205    pub extensions: Extensions,
206}
207
208impl CdrDimension {
209    /// Creates a dimension measurement.
210    #[must_use]
211    pub fn new(dimension_type: CdrDimensionType, volume: Number) -> Self {
212        Self { dimension_type, volume, extensions: Extensions::new() }
213    }
214}
215
216impl Validate for CdrDimension {
217    fn validate_in(&self, v: &mut Validator) {
218        validate_fields!(self, v, dimension_type as "type", volume);
219    }
220}
221
222ocpi_lenient_enum! {
223    /// How the driver was authenticated, in OCPI 2.1.1.
224    ///
225    /// `COMMAND` — the session was started by a remote command — arrived in OCPI 2.2.
226    ///
227    /// Spec: 2.1.1 §mod_cdrs_authmethod_enum
228    pub enum AuthMethod {
229        /// An authentication request was sent to the eMSP.
230        AuthRequest = "AUTH_REQUEST",
231        /// A whitelist was used; no request to the eMSP was performed.
232        Whitelist = "WHITELIST",
233    }
234}
235
236ocpi_lenient_enum! {
237    /// The quantities a [`ChargingPeriod`] can record, in OCPI 2.1.1.
238    ///
239    /// Six values. OCPI 2.2 grew this to thirteen and split the session-only ones out.
240    ///
241    /// Spec: 2.1.1 §mod_cdrs_cdrdimensiontype_enum
242    pub enum CdrDimensionType {
243        /// Total amount of energy charged during this period, in kWh.
244        Energy = "ENERGY",
245        /// A flat fee, without a unit.
246        Flat = "FLAT",
247        /// Sum of the maximum current over all phases, in A.
248        MaxCurrent = "MAX_CURRENT",
249        /// Sum of the minimum current over all phases, in A.
250        MinCurrent = "MIN_CURRENT",
251        /// Time not charging, in hours.
252        ParkingTime = "PARKING_TIME",
253        /// Time charging, in hours.
254        Time = "TIME",
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn the_2_1_1_dimension_set_is_the_small_one() {
264        assert_eq!(CdrDimensionType::ALL_KNOWN.len(), 6);
265        // ENERGY_EXPORT and STATE_OF_CHARGE arrived in OCPI 2.2.
266        assert!(!CdrDimensionType::from("STATE_OF_CHARGE").is_known());
267        assert!(!AuthMethod::from("COMMAND").is_known(), "COMMAND arrived with the Commands module");
268    }
269
270    #[test]
271    fn the_cost_is_a_bare_number_excluding_vat() {
272        let json = r#"{"volume":12.5,"type":"ENERGY"}"#;
273        let dimension: CdrDimension = serde_json::from_str(json).unwrap();
274        assert_eq!(dimension.dimension_type, CdrDimensionType::Energy);
275        assert_eq!(serde_json::to_string(&dimension).unwrap(), r#"{"type":"ENERGY","volume":12.5}"#);
276    }
277}