Skip to main content

ocpi_kit/v2_1_1/
sessions.rs

1//! The *Sessions* module of OCPI 2.1.1.
2//!
3//! Two things to watch for: the timestamp fields are spelled `start_datetime` and `end_datetime`
4//! — **without** the second underscore that every other OCPI version uses — and the session
5//! carries a whole [`Location`] rather than the three ids that replaced it in OCPI 2.2.
6//!
7//! Spec: 2.1.1 §mod_sessions
8
9use bon::Builder;
10use serde::{Deserialize, Serialize};
11
12use crate::ocpi_lenient_enum;
13use crate::types::validate_fields;
14use crate::types::{Currency, DateTime, Extensions, Number, OcpiString, Validate, Validator, ViolationCode};
15
16use super::cdrs::{AuthMethod, CdrDimensionType, ChargingPeriod};
17use super::locations::Location;
18
19/// One charging session, in OCPI 2.1.1.
20///
21/// Spec: 2.1.1 §mod_sessions_session_object
22#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
23#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
24#[builder(on(_, into))]
25pub struct Session {
26    /// The unique id that identifies the session in the CPO platform.
27    pub id: OcpiString<36>,
28    /// When the session became active.
29    ///
30    /// **Note the spelling.** OCPI 2.1.1 writes `start_datetime`; every later version writes
31    /// `start_date_time`. Getting this wrong is a silent data loss, which is why the Rust field
32    /// is named after the later spelling and carries an explicit `#[serde(rename)]`.
33    #[serde(rename = "start_datetime")]
34    pub start_date_time: DateTime,
35    /// When the session was completed.
36    #[serde(rename = "end_datetime", default, skip_serializing_if = "Option::is_none")]
37    pub end_date_time: Option<DateTime>,
38    /// How many kWh were charged.
39    pub kwh: Number,
40    /// Reference to the `auth_id` of the Token that started the session.
41    pub auth_id: OcpiString<36>,
42    /// Method used for authentication.
43    pub auth_method: AuthMethod,
44    /// Where this session took place, *"including only the relevant EVSE and connector"*.
45    pub location: Location,
46    /// Optional identification of the kWh meter.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub meter_id: Option<OcpiString<255>>,
49    /// ISO 4217 code of the currency used for this session.
50    pub currency: Currency,
51    /// Charging Periods that can be used to calculate and verify the total cost.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    #[builder(default)]
54    pub charging_periods: Vec<ChargingPeriod>,
55    /// The total cost of the session, **excluding VAT**.
56    ///
57    /// > *A total_cost of 0.00 means free of charge. When omitted … this does not have to mean it
58    /// > is free of charge.*
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub total_cost: Option<Number>,
61    /// The status of the session.
62    pub status: SessionStatus,
63    /// Timestamp when this Session was last updated (or created).
64    pub last_updated: DateTime,
65    /// Undocumented JSON fields, preserved verbatim.
66    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
67    #[builder(default)]
68    pub extensions: Extensions,
69}
70
71impl Session {
72    /// The total volume of one dimension across every charging period.
73    #[must_use]
74    pub fn dimension_total(&self, dimension: CdrDimensionType) -> Number {
75        self.charging_periods
76            .iter()
77            .flat_map(|p| p.dimensions.iter())
78            .filter(|d| d.dimension_type == dimension)
79            .map(|d| d.volume)
80            .sum()
81    }
82}
83
84impl Validate for Session {
85    fn validate_in(&self, v: &mut Validator) {
86        validate_fields!(
87            self, v,
88            id,
89            start_date_time as "start_datetime",
90            end_date_time as "end_datetime",
91            kwh, auth_id, auth_method, location, meter_id, currency, charging_periods, status,
92            last_updated,
93        );
94        if self.end_date_time.is_some_and(|end| end < self.start_date_time) {
95            v.report_at("end_datetime", ViolationCode::Inconsistent, "a session cannot end before it starts");
96        }
97        if self.kwh.is_negative() {
98            v.report_at("kwh", ViolationCode::OutOfRange, "a session cannot charge negative energy");
99        }
100        crate::v2_3_0::cdrs::validate_period_sequence(
101            &self.charging_periods.iter().map(|p| p.start_date_time).collect::<Vec<_>>(),
102            self.start_date_time,
103            self.end_date_time,
104            v,
105        );
106        if self.status == SessionStatus::Completed && self.end_date_time.is_none() {
107            v.report_at(
108                "end_datetime",
109                ViolationCode::MissingConditional,
110                "a COMPLETED session has finished and should carry the time it finished",
111            );
112        }
113    }
114}
115
116ocpi_lenient_enum! {
117    /// The state of a session, in OCPI 2.1.1.
118    ///
119    /// `RESERVATION` arrived in OCPI 2.2, with the reservation pricing that needed it.
120    ///
121    /// Spec: 2.1.1 §mod_sessions_sessionstatus_enum
122    pub enum SessionStatus {
123        /// The session is accepted and active.
124        Active = "ACTIVE",
125        /// The session has finished successfully.
126        Completed = "COMPLETED",
127        /// The session is declared invalid and will not be billed.
128        Invalid = "INVALID",
129        /// The session is pending; it has not yet started.
130        Pending = "PENDING",
131    }
132}
133
134impl SessionStatus {
135    /// Whether the session can still change.
136    #[must_use]
137    pub fn is_terminal(&self) -> bool {
138        matches!(self, Self::Completed | Self::Invalid)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn the_timestamp_fields_use_the_2_1_1_spelling_on_the_wire() {
148        let json = r#"{"id":"101","start_datetime":"2015-06-29T22:39:09Z","kwh":0,"auth_id":"DE8ACC12E46L89","auth_method":"WHITELIST","location":{"id":"LOC1","type":"ON_STREET","address":"a","city":"b","postal_code":"c","country":"NLD","coordinates":{"latitude":"51.047599","longitude":"3.729944"},"last_updated":"2015-06-29T20:39:09Z"},"currency":"EUR","status":"PENDING","last_updated":"2015-06-29T22:39:09Z"}"#;
149        let session: Session = serde_json::from_str(json).unwrap();
150        assert!(session.validate().is_ok());
151        let encoded = serde_json::to_string(&session).unwrap();
152        assert!(encoded.contains("\"start_datetime\""), "not start_date_time: {encoded}");
153        assert_eq!(encoded, json);
154    }
155
156    #[test]
157    fn a_violation_points_at_the_wire_field_name() {
158        let mut session: Session = serde_json::from_str(
159            r#"{"id":"101","start_datetime":"2015-06-29T22:39:09Z","kwh":0,"auth_id":"X","auth_method":"WHITELIST","location":{"id":"LOC1","type":"ON_STREET","address":"a","city":"b","postal_code":"c","country":"NLD","coordinates":{"latitude":"51.047599","longitude":"3.729944"},"last_updated":"2015-06-29T20:39:09Z"},"currency":"EUR","status":"PENDING","last_updated":"2015-06-29T22:39:09Z"}"#,
160        )
161        .unwrap();
162        session.status = SessionStatus::Completed;
163        let err = session.validate().unwrap_err();
164        assert_eq!(err.as_slice()[0].pointer, "/end_datetime");
165    }
166
167    #[test]
168    fn the_reservation_status_arrived_later() {
169        assert_eq!(SessionStatus::ALL_KNOWN.len(), 4);
170        assert!(!SessionStatus::from("RESERVATION").is_known());
171    }
172}