Skip to main content

ocpi_kit/v2_3_0/
bookings.rs

1//! The *Bookings* module, from the OCPI 2.3.0 `bookings` release branch.
2//!
3//! *Module Identifier: `Booking`* — Data owner: CPO.
4//!
5//! **Spec quirks.** Two, both worth knowing before an integration:
6//!
7//! * The identifier really is `Booking` — singular, and the only module ID in OCPI that is not
8//!   lower case. It is also missing from that branch's `ModuleID` table.
9//!   [`ModuleId::matches`](crate::ModuleId::matches) accepts the lower-case plural too, because
10//!   implementations that guessed it exist and failing to discover the module is worse.
11//! * `Cancellation.who_canceled` is typed as `Role` in the property table but links to the
12//!   *`InterfaceRole`* anchor. The description — *"Who canceled the booking"*, with the enum's
13//!   own values naming the CPO and the MSP — only makes sense as a party role, so
14//!   [`Cancellation::who_canceled`] is a [`Role`].
15//!
16//! # Lifecycle
17//!
18//! > *A Booking starts in a `PENDING` state when initially requested by the eMSP. … From
19//! > `RESERVED`, the Booking can transition to `FULFILLED`, `CANCELED` or `NO_SHOW`.*
20//!
21//! [`ReservationStatus::can_transition_to`] encodes the whole state machine.
22//!
23//! Spec: 2.3.0-bookings §mod_bookings_bookings_module
24
25use bon::Builder;
26use serde::{Deserialize, Serialize};
27
28use crate::ocpi_enum;
29use crate::types::validate_fields;
30use crate::types::{
31    CiString, ContractId, CountryCode, DateTime, Extensions, Number, OcpiText, PartyId, PartyRef, Url,
32    Validate, Validator, ViolationCode,
33};
34
35use super::locations::{ConnectorFormat, ConnectorType, EvsePosition, PowerType, VehicleType};
36use super::tokens::TokenType;
37use super::types::Role;
38
39/// A booking of a charging slot at a Location.
40///
41/// Spec: 2.3.0-bookings §mod_bookings_booking_object
42#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
43#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
44#[builder(on(_, into))]
45pub struct Booking {
46    /// ID for the CPO side.
47    pub id: CiString<36>,
48    /// ISO-3166 alpha-2 country code of the CPO that 'owns' this Booking.
49    pub country_code: CountryCode,
50    /// ID of the CPO that 'owns' this Booking.
51    pub party_id: PartyId,
52    /// Request ID determined by the requesting party.
53    ///
54    /// > *The same request ID SHALL be used for all edits on booking.*
55    pub request_id: CiString<36>,
56    /// The specification selected for charging at this Location.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub booking_option: Option<BookingOption>,
59    /// `Location.id` on which the reservation was made.
60    pub location_id: CiString<36>,
61    /// Tokens that can be used to take up the booking.
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    #[builder(default)]
64    pub booking_tokens: Vec<BookingToken>,
65    /// Tariffs relevant for this booking.
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    #[builder(default)]
68    pub tariff_ids: Vec<CiString<36>>,
69    /// The timeslot booked.
70    pub period: Timeslot,
71    /// The current state of the reservation.
72    pub reservation_status: ReservationStatus,
73    /// Why the booking was canceled, and by whom.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub canceled: Option<Cancellation>,
76    /// How to get to the Location.
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    #[builder(default)]
79    pub access_information: Vec<AccessInformation>,
80    /// Authorization reference for the resulting Session and CDR.
81    pub authorization_reference: CiString<36>,
82    /// The booking terms that were accepted.
83    pub booking_terms: BookingTerms,
84    /// Every request made for this booking. Cardinality `+`.
85    pub booking_requests: Vec<BookingRequestStatus>,
86    /// When this Booking was last changed.
87    pub last_updated: DateTime,
88    /// Undocumented JSON fields, preserved verbatim.
89    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
90    #[builder(default)]
91    pub extensions: Extensions,
92}
93
94impl Booking {
95    /// The CPO that owns this Booking.
96    #[must_use]
97    pub fn owner_party(&self) -> PartyRef {
98        PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
99    }
100
101    /// Whether the booking has reached a state it will not leave.
102    #[must_use]
103    pub fn is_final(&self) -> bool {
104        self.reservation_status.is_terminal()
105    }
106}
107
108impl Validate for Booking {
109    fn validate_in(&self, v: &mut Validator) {
110        validate_fields!(
111            self,
112            v,
113            id,
114            country_code,
115            party_id,
116            request_id,
117            booking_option,
118            location_id,
119            booking_tokens,
120            tariff_ids,
121            period,
122            reservation_status,
123            canceled,
124            access_information,
125            authorization_reference,
126            booking_terms,
127            booking_requests,
128            last_updated,
129        );
130        if self.booking_requests.is_empty() {
131            v.report_at(
132                "booking_requests",
133                ViolationCode::EmptyRequiredList,
134                "a Booking has cardinality `+` booking_requests: the request that created it is \
135                 always one of them",
136            );
137        }
138        // "canceled: Is the booking canceled, why and by whom."
139        match (self.reservation_status, self.canceled.is_some()) {
140            (ReservationStatus::Canceled, false) => v.report_at(
141                "canceled",
142                ViolationCode::MissingConditional,
143                "a CANCELED booking should say why and by whom",
144            ),
145            (status, true) if status != ReservationStatus::Canceled => v.report_at(
146                "reservation_status",
147                ViolationCode::Inconsistent,
148                format!("a cancellation is recorded, but the status is {status}"),
149            ),
150            _ => {}
151        }
152    }
153}
154
155/// A Location that can be booked, with its calendars and terms.
156///
157/// > *Each bookingLocation should include either the `booking_option` or the `evse_uid`. One of
158/// > them is mandatory.*
159///
160/// Spec: 2.3.0-bookings §mod_bookings_bookinglocation_object
161#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
163#[builder(on(_, into))]
164pub struct BookingLocation {
165    /// ISO-3166 alpha-2 country code of the CPO that 'owns' this BookingLocation.
166    pub country_code: CountryCode,
167    /// ID of the CPO that 'owns' this BookingLocation.
168    pub party_id: PartyId,
169    /// The unique id that identifies this BookingLocation in the CPO platform.
170    pub id: CiString<36>,
171    /// `Location.id` on which the reservation can be made.
172    pub location_id: CiString<36>,
173    /// What drivers can book at this Location.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub booking_option: Option<BookingOption>,
176    /// How many charging stations are bookable here, and whether booking is required.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub policy: Option<Policy>,
179    /// Tariffs relevant here.
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    #[builder(default)]
182    pub tariff_ids: Vec<CiString<36>>,
183    /// The terms that apply to a booking here.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub booking_terms: Option<BookingTerms>,
186    /// The calendars showing availability.
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    #[builder(default)]
189    pub calendars: Vec<Calendar>,
190    /// When this BookingLocation was last changed.
191    pub last_updated: DateTime,
192    /// Undocumented JSON fields, preserved verbatim.
193    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
194    #[builder(default)]
195    pub extensions: Extensions,
196}
197
198impl BookingLocation {
199    /// The CPO that owns this BookingLocation.
200    #[must_use]
201    pub fn owner_party(&self) -> PartyRef {
202        PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
203    }
204}
205
206impl Validate for BookingLocation {
207    fn validate_in(&self, v: &mut Validator) {
208        validate_fields!(
209            self,
210            v,
211            country_code,
212            party_id,
213            id,
214            location_id,
215            booking_option,
216            policy,
217            tariff_ids,
218            booking_terms,
219            calendars,
220            last_updated,
221        );
222        // "Each bookingLocation should include either the booking_option or the evse_uid.
223        //  One of them is mandatory."
224        let names_an_evse = self.booking_option.as_ref().is_some_and(|o| o.evse_uid.is_some());
225        if self.booking_option.is_none() && !names_an_evse {
226            v.report_at(
227                "booking_option",
228                ViolationCode::MissingConditional,
229                "either `booking_option` or an EVSE must be given; one of them is mandatory",
230            );
231        }
232    }
233}
234
235/// The availability of a BookingLocation over a period.
236///
237/// Spec: 2.3.0-bookings §mod_bookings_calendar_object
238#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
239#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
240#[builder(on(_, into))]
241pub struct Calendar {
242    /// ID of this calendar.
243    pub id: CiString<36>,
244    /// Start of the calendar.
245    pub begin_from: DateTime,
246    /// End of the calendar.
247    pub end_before: DateTime,
248    /// The smallest booking increment within an available timeslot, in minutes.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub timeslot_increment: Option<u32>,
251    /// The available timeslots. Cardinality `+`.
252    pub available_timeslots: Vec<Timeslot>,
253    /// When this calendar was last changed.
254    pub last_updated: DateTime,
255    /// Undocumented JSON fields, preserved verbatim.
256    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
257    #[builder(default)]
258    pub extensions: Extensions,
259}
260
261impl Calendar {
262    /// Whether `slot` fits inside one of the available timeslots.
263    #[must_use]
264    pub fn can_accommodate(&self, slot: &Timeslot) -> bool {
265        self.available_timeslots.iter().any(|available| {
266            slot.start_date_time >= available.start_date_time && slot.end_date_time <= available.end_date_time
267        })
268    }
269}
270
271impl Validate for Calendar {
272    fn validate_in(&self, v: &mut Validator) {
273        validate_fields!(self, v, id, begin_from, end_before, available_timeslots, last_updated);
274        if self.end_before <= self.begin_from {
275            v.report_at(
276                "end_before",
277                ViolationCode::Inconsistent,
278                "a calendar must cover a non-empty period",
279            );
280        }
281        if self.available_timeslots.is_empty() {
282            v.report_at(
283                "available_timeslots",
284                ViolationCode::EmptyRequiredList,
285                "a Calendar has cardinality `+` available_timeslots",
286            );
287        }
288    }
289}
290
291/// A window of time, with the power available in it.
292///
293/// Spec: 2.3.0-bookings §mod_bookings_timeslot_class
294#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
295#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
296#[builder(on(_, into))]
297pub struct Timeslot {
298    /// Start of this timeslot.
299    pub start_date_time: DateTime,
300    /// End of this timeslot.
301    pub end_date_time: DateTime,
302    /// Minimum power guaranteed during this timeslot, in watts.
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub min_power: Option<Number>,
305    /// Maximum power available during this timeslot, in watts.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub max_power: Option<Number>,
308    /// Whether green energy is available during this timeslot.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub green_energy_support: Option<bool>,
311    /// Undocumented JSON fields, preserved verbatim.
312    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
313    #[builder(default)]
314    pub extensions: Extensions,
315}
316
317impl Timeslot {
318    /// The length of this timeslot in minutes, or `None` if it is not a forward interval.
319    #[must_use]
320    pub fn duration_minutes(&self) -> Option<i64> {
321        let seconds = self.end_date_time.unix_timestamp() - self.start_date_time.unix_timestamp();
322        (seconds > 0).then_some(seconds / 60)
323    }
324}
325
326impl Validate for Timeslot {
327    fn validate_in(&self, v: &mut Validator) {
328        validate_fields!(self, v, start_date_time, end_date_time, min_power, max_power);
329        if self.end_date_time <= self.start_date_time {
330            v.report_at(
331                "end_date_time",
332                ViolationCode::Inconsistent,
333                "a timeslot must cover a non-empty period",
334            );
335        }
336        if let (Some(min), Some(max)) = (self.min_power, self.max_power)
337            && max < min
338        {
339            v.report_at(
340                "max_power",
341                ViolationCode::Inconsistent,
342                "the maximum power cannot be below the guaranteed minimum",
343            );
344        }
345    }
346}
347
348/// What a driver can book at a Location.
349///
350/// Spec: 2.3.0-bookings §mod_bookings_booking_option_class
351#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Builder)]
352#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
353#[builder(on(_, into))]
354pub struct BookingOption {
355    /// A bookable `EVSE.uid`. May be `#NA` when no EVSE is assigned yet.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub evse_uid: Option<CiString<36>>,
358    /// `Connector.id` where the booking will happen.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub connector_id: Option<CiString<36>>,
361    /// Reference to a `Parking.id`.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub parking_id: Option<CiString<36>>,
364    /// The position of the EVSE relative to the parking space.
365    #[serde(default, skip_serializing_if = "Vec::is_empty")]
366    #[builder(default)]
367    pub evse_position: Vec<EvsePosition>,
368    /// The vehicle types the parking accommodates.
369    #[serde(default, skip_serializing_if = "Vec::is_empty")]
370    #[builder(default)]
371    pub vehicle_types: Vec<VehicleType>,
372    /// The connector formats available.
373    #[serde(default, skip_serializing_if = "Vec::is_empty")]
374    #[builder(default)]
375    pub connector_format: Vec<ConnectorFormat>,
376    /// The connector types available.
377    #[serde(default, skip_serializing_if = "Vec::is_empty")]
378    #[builder(default)]
379    pub connector_types: Vec<ConnectorType>,
380    /// The power types available.
381    #[serde(default, skip_serializing_if = "Vec::is_empty")]
382    #[builder(default)]
383    pub power_types: Vec<PowerType>,
384    /// Maximum vehicle weight, in kilograms.
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub max_vehicle_weight: Option<Number>,
387    /// Maximum vehicle height, in centimetres.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub max_vehicle_height: Option<Number>,
390    /// Maximum vehicle length, in centimetres.
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub max_vehicle_length: Option<Number>,
393    /// Maximum vehicle width, in centimetres.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub max_vehicle_width: Option<Number>,
396    /// Minimum length of the parking space, in centimetres.
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub min_parking_space_length: Option<Number>,
399    /// Minimum width of the parking space, in centimetres.
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub min_parking_space_width: Option<Number>,
402    /// Whether vehicles loaded with dangerous substances may park.
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub dangerous_goods_allowed: Option<bool>,
405    /// Whether a vehicle can charge without reversing into or out of the space.
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub drive_through: Option<bool>,
408    /// Whether a refrigeration outlet is available.
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub refrigeration_outlet: Option<bool>,
411    /// Undocumented JSON fields, preserved verbatim.
412    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
413    #[builder(default)]
414    pub extensions: Extensions,
415}
416
417impl Validate for BookingOption {
418    fn validate_in(&self, v: &mut Validator) {
419        validate_fields!(
420            self,
421            v,
422            evse_uid,
423            connector_id,
424            parking_id,
425            evse_position,
426            vehicle_types,
427            connector_format,
428            connector_types,
429            power_types,
430            max_vehicle_weight,
431            max_vehicle_height,
432            max_vehicle_length,
433            max_vehicle_width,
434            min_parking_space_length,
435            min_parking_space_width,
436        );
437    }
438}
439
440/// One request made against a booking, and what became of it.
441///
442/// Spec: 2.3.0-bookings §mod_bookings_request_status_class
443#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
444#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
445#[builder(on(_, into))]
446pub struct BookingRequestStatus {
447    /// The current state of the request.
448    pub request_status: ReservationRequestStatus,
449    /// The request that was received.
450    pub booking_request: BookingRequest,
451    /// When it was received.
452    pub request_received: DateTime,
453    /// Undocumented JSON fields, preserved verbatim.
454    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
455    #[builder(default)]
456    pub extensions: Extensions,
457}
458
459impl Validate for BookingRequestStatus {
460    fn validate_in(&self, v: &mut Validator) {
461        validate_fields!(self, v, request_status, booking_request, request_received);
462    }
463}
464
465/// A request from an eMSP to make or change a booking.
466///
467/// Spec: 2.3.0-bookings §mod_bookings_request_class
468#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
469#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
470#[builder(on(_, into))]
471pub struct BookingRequest {
472    /// ISO-3166 alpha-2 country code of the MSP requesting the booking.
473    pub country_code: CountryCode,
474    /// ID of the MSP requesting the booking.
475    pub party_id: PartyId,
476    /// Request ID determined by the requesting party.
477    pub request_id: CiString<36>,
478    /// The specification selected for charging.
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub booking_option: Option<BookingOption>,
481    /// `Location.id` on which the reservation is made.
482    pub location_id: CiString<36>,
483    /// The `BookingLocation.id` being booked.
484    pub booking_location_id: CiString<36>,
485    /// Tokens that can be used to take up the booking.
486    #[serde(default, skip_serializing_if = "Vec::is_empty")]
487    #[builder(default)]
488    pub tokens: Vec<BookingToken>,
489    /// How to get to the Location.
490    #[serde(default, skip_serializing_if = "Vec::is_empty")]
491    #[builder(default)]
492    pub access_information: Vec<AccessInformation>,
493    /// The period requested.
494    pub period: Period,
495    /// Authorization reference for the resulting Session and CDR.
496    pub authorization_reference: CiString<36>,
497    /// The power requested, in kW.
498    ///
499    /// > *If it isn't the maximum available the CPO can relocate the extra to another session.*
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub power_required: Option<u32>,
502    /// Set when the request is to cancel the booking.
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub canceled: Option<Cancellation>,
505    /// Undocumented JSON fields, preserved verbatim.
506    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
507    #[builder(default)]
508    pub extensions: Extensions,
509}
510
511impl BookingRequest {
512    /// The eMSP that made this request.
513    #[must_use]
514    pub fn requester(&self) -> PartyRef {
515        PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
516    }
517}
518
519impl Validate for BookingRequest {
520    fn validate_in(&self, v: &mut Validator) {
521        validate_fields!(
522            self,
523            v,
524            country_code,
525            party_id,
526            request_id,
527            booking_option,
528            location_id,
529            booking_location_id,
530            tokens,
531            access_information,
532            period,
533            authorization_reference,
534            canceled,
535        );
536    }
537}
538
539/// A window of time. Unlike [`Timeslot`], it carries no power information.
540///
541/// Spec: 2.3.0-bookings §mod_bookings_period_class
542#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
543#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
544pub struct Period {
545    /// Start of this period.
546    pub start_date_time: DateTime,
547    /// End of this period.
548    pub end_date_time: DateTime,
549    /// Undocumented JSON fields, preserved verbatim.
550    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
551    pub extensions: Extensions,
552}
553
554impl Validate for Period {
555    fn validate_in(&self, v: &mut Validator) {
556        validate_fields!(self, v, start_date_time, end_date_time);
557        if self.end_date_time <= self.start_date_time {
558            v.report_at(
559                "end_date_time",
560                ViolationCode::Inconsistent,
561                "a period must cover a non-empty span of time",
562            );
563        }
564    }
565}
566
567/// A Token that can take up a booking.
568///
569/// Spec: 2.3.0-bookings §mod_bookings_booking_token_object
570#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
571#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
572#[builder(on(_, into))]
573pub struct BookingToken {
574    /// ISO-3166 alpha-2 country code of the MSP that 'owns' this Token.
575    pub country_code: CountryCode,
576    /// ID of the eMSP that 'owns' this Token.
577    pub party_id: PartyId,
578    /// Unique ID by which this Token can be identified.
579    pub uid: CiString<36>,
580    /// Type of the token.
581    #[serde(rename = "type")]
582    pub token_type: TokenType,
583    /// Uniquely identifies the EV driver contract token within the eMSP's platform.
584    pub contract_id: ContractId,
585    /// Undocumented JSON fields, preserved verbatim.
586    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
587    #[builder(default)]
588    pub extensions: Extensions,
589}
590
591impl Validate for BookingToken {
592    fn validate_in(&self, v: &mut Validator) {
593        validate_fields!(self, v, country_code, party_id, uid, token_type as "type", contract_id);
594    }
595}
596
597/// The terms a booking is made under.
598///
599/// Spec: 2.3.0-bookings §mod_bookings_booking_terms_class
600#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
601#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
602#[builder(on(_, into))]
603pub struct BookingTerms {
604    /// Whether charging for a reserved booking requires an RFID card at the charger.
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    pub rfid_auth_required: Option<bool>,
607    /// Whether any token in the same token group may be used.
608    #[serde(default, skip_serializing_if = "Option::is_none")]
609    pub token_groups_supported: Option<bool>,
610    /// Whether charging can be started remotely, through the Commands module.
611    #[serde(default, skip_serializing_if = "Option::is_none")]
612    pub remote_auth_supported: Option<bool>,
613    /// What is needed to access the Location. Cardinality `+`.
614    pub supported_access_methods: Vec<AccessMethod>,
615    /// Minutes before the booking until which it can be changed.
616    pub change_until_minutes: Number,
617    /// Minutes before the booking until which it can be canceled.
618    pub cancel_until_minutes: Number,
619    /// Whether changing the booking is disallowed.
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub change_not_allowed: Option<bool>,
622    /// Whether starting the session early is possible.
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub early_start_allowed: Option<bool>,
625    /// How many minutes early a session may start.
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub early_start_time: Option<Number>,
628    /// Minutes after the booking start after which it counts as a no-show.
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    pub noshow_timeout: Option<Number>,
631    /// Whether the CPO charges a no-show fee.
632    #[serde(default, skip_serializing_if = "Option::is_none")]
633    pub noshow_fee: Option<bool>,
634    /// Whether a driver may charge for longer than booked.
635    #[serde(default, skip_serializing_if = "Option::is_none")]
636    pub late_stop_allowed: Option<bool>,
637    /// How many minutes a session may run past the end of the booking.
638    ///
639    /// The specification's description reads *"Number of minutes late start is allowed"*, which
640    /// is a copy-paste of `early_start_time`; the field name and its position after
641    /// `late_stop_allowed` make the intent clear.
642    #[serde(default, skip_serializing_if = "Option::is_none")]
643    pub late_stop_time: Option<Number>,
644    /// Whether the same RFID token may be attached to several overlapping bookings.
645    #[serde(default, skip_serializing_if = "Option::is_none")]
646    pub overlapping_bookings_allowed: Option<bool>,
647    /// Minimum booking duration in minutes.
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub min_booking_duration: Option<Number>,
650    /// Maximum booking duration in minutes.
651    #[serde(default, skip_serializing_if = "Option::is_none")]
652    pub max_booking_duration: Option<Number>,
653    /// The CPO's URL to the booking terms.
654    #[serde(default, skip_serializing_if = "Option::is_none")]
655    pub booking_terms: Option<Url>,
656    /// Undocumented JSON fields, preserved verbatim.
657    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
658    #[builder(default)]
659    pub extensions: Extensions,
660}
661
662impl BookingTerms {
663    /// Whether a booking starting at `start` may still be canceled at `now`.
664    #[must_use]
665    pub fn may_cancel_at(&self, now: DateTime, start: DateTime) -> bool {
666        minutes_before(now, start) >= self.cancel_until_minutes
667    }
668
669    /// Whether a booking starting at `start` may still be changed at `now`.
670    #[must_use]
671    pub fn may_change_at(&self, now: DateTime, start: DateTime) -> bool {
672        if self.change_not_allowed.unwrap_or(false) {
673            return false;
674        }
675        minutes_before(now, start) >= self.change_until_minutes
676    }
677}
678
679fn minutes_before(now: DateTime, start: DateTime) -> Number {
680    let seconds = start.unix_timestamp() - now.unix_timestamp();
681    Number::from(seconds) / Number::from(60u32)
682}
683
684impl Validate for BookingTerms {
685    fn validate_in(&self, v: &mut Validator) {
686        validate_fields!(
687            self,
688            v,
689            supported_access_methods,
690            change_until_minutes,
691            cancel_until_minutes,
692            early_start_time,
693            noshow_timeout,
694            late_stop_time,
695            min_booking_duration,
696            max_booking_duration,
697            booking_terms,
698        );
699        if self.supported_access_methods.is_empty() {
700            v.report_at(
701                "supported_access_methods",
702                ViolationCode::EmptyRequiredList,
703                "BookingTerms has cardinality `+` supported_access_methods: a driver needs to \
704                 know how to get in",
705            );
706        }
707        if let (Some(min), Some(max)) = (self.min_booking_duration, self.max_booking_duration)
708            && max < min
709        {
710            v.report_at(
711                "max_booking_duration",
712                ViolationCode::Inconsistent,
713                "the maximum booking duration cannot be below the minimum",
714            );
715        }
716    }
717}
718
719/// How to get to a booked charger.
720///
721/// Spec: 2.3.0-bookings §mod_bookings_access_information_class
722#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
723#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
724pub struct AccessInformation {
725    /// How the Location is accessed.
726    pub method: AccessMethod,
727    /// The value for the method: a licence plate, an access code, and so on.
728    #[serde(default, skip_serializing_if = "Option::is_none")]
729    pub value: Option<OcpiText>,
730    /// Undocumented JSON fields, preserved verbatim.
731    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
732    pub extensions: Extensions,
733}
734
735impl Validate for AccessInformation {
736    fn validate_in(&self, v: &mut Validator) {
737        validate_fields!(self, v, method, value);
738        // OPEN and INTERCOM need nothing; the rest are useless without their value.
739        if self.value.is_none()
740            && matches!(
741                self.method,
742                AccessMethod::Token | AccessMethod::LicensePlate | AccessMethod::AccessCode
743            )
744        {
745            v.report_at(
746                "value",
747                ViolationCode::MissingConditional,
748                format!("{} needs the value the driver is to present", self.method),
749            );
750        }
751    }
752}
753
754/// Why a booking was canceled, and by whom.
755///
756/// Spec: 2.3.0-bookings §mod_bookings_cancellation_class
757#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
758#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
759pub struct Cancellation {
760    /// The reason.
761    pub cancellation_reason: CanceledReason,
762    /// Who canceled.
763    ///
764    /// **Spec erratum.** The property table types this as `Role` but links to the
765    /// `InterfaceRole` anchor. `SENDER`/`RECEIVER` would not answer "who canceled the booking",
766    /// and the reasons themselves are split into CPO-set and MSP-set, so this is a
767    /// [`Role`].
768    pub who_canceled: Role,
769    /// Undocumented JSON fields, preserved verbatim.
770    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
771    pub extensions: Extensions,
772}
773
774impl Validate for Cancellation {
775    fn validate_in(&self, v: &mut Validator) {
776        validate_fields!(self, v, cancellation_reason, who_canceled);
777    }
778}
779
780/// How many bookable stations a Location has, and whether booking is required.
781///
782/// Spec: 2.3.0-bookings §mod_bookings_policy_object
783#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
784#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
785pub struct Policy {
786    /// Whether a reservation is required to charge here.
787    pub reservation_required: bool,
788    /// How many ad-hoc charging options are available.
789    #[serde(default, skip_serializing_if = "Option::is_none")]
790    pub ad_hoc: Option<Number>,
791    /// Undocumented JSON fields, preserved verbatim.
792    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
793    pub extensions: Extensions,
794}
795
796impl Validate for Policy {
797    fn validate_in(&self, v: &mut Validator) {
798        validate_fields!(self, v, ad_hoc);
799        if self.reservation_required && self.ad_hoc.is_some_and(|n| !n.is_zero()) {
800            v.report_at(
801                "ad_hoc",
802                ViolationCode::Inconsistent,
803                "a Location that requires a reservation cannot offer ad-hoc charging",
804            );
805        }
806    }
807}
808
809ocpi_enum! {
810    /// How a driver gets access to a reserved charger.
811    ///
812    /// Spec: 2.3.0-bookings §mod_bookings_access_method_enum
813    pub enum AccessMethod {
814        /// Open access to the site.
815        Open = "OPEN",
816        /// Using a token that was sent in the booking.
817        Token = "TOKEN",
818        /// The licence plate of the vehicle that wants to charge.
819        LicensePlate = "LICENSE_PLATE",
820        /// The access code provided.
821        AccessCode = "ACCESS_CODE",
822        /// Ring the intercom.
823        Intercom = "INTERCOM",
824        /// A parking ticket is required.
825        ParkingTicket = "PARKING_TICKET",
826    }
827}
828
829ocpi_enum! {
830    /// Why a booking was canceled.
831    ///
832    /// Spec: 2.3.0-bookings §mod_bookings_canceled_reason_enum
833    pub enum CanceledReason {
834        /// No power available at the site. Set by the CPO.
835        PowerOutage = "POWER_OUTAGE",
836        /// The charger is broken. Set by the CPO.
837        BrokenCharger = "BROKEN_CHARGER",
838        /// The chargers are full because someone is not leaving. Set by the CPO.
839        Full = "FULL",
840        /// The reserved charger is not physically reachable.
841        Blocked = "BLOCKED",
842        /// The vehicle cannot arrive in time because of traffic. Set by the MSP.
843        Traffic = "TRAFFIC",
844        /// The vehicle broke down. Set by the MSP.
845        BrokenVehicle = "BROKEN_VEHICLE",
846        /// The driver gave no reason. Set by the MSP.
847        NoCanceled = "NO_CANCELED",
848        /// Any other or unknown reason.
849        Unknown = "UNKNOWN",
850    }
851}
852
853ocpi_enum! {
854    /// The state of one booking request.
855    ///
856    /// Spec: 2.3.0-bookings §mod_bookings_request_status_enum
857    pub enum ReservationRequestStatus {
858        /// Pending processing by the CPO.
859        Pending = "PENDING",
860        /// Accepted by the CPO.
861        Accepted = "ACCEPTED",
862        /// Declined by the CPO.
863        Declined = "DECLINED",
864        /// The request failed with an error.
865        Failed = "FAILED",
866    }
867}
868
869ocpi_enum! {
870    /// The state of a booking.
871    ///
872    /// Spec: 2.3.0-bookings §mod_bookings_reservation_status_enum
873    pub enum ReservationStatus {
874        /// Pending processing by the CPO. The initial state.
875        Pending = "PENDING",
876        /// Accepted by the CPO.
877        Reserved = "RESERVED",
878        /// Canceled.
879        Canceled = "CANCELED",
880        /// The request failed with an error.
881        Failed = "FAILED",
882        /// Nobody showed up within the no-show window.
883        NoShow = "NO_SHOW",
884        /// A session was started with the communicated token before the booking expired.
885        Fulfilled = "FULFILLED",
886        /// Rejected after processing, e.g. because the requested slot was unavailable.
887        Rejected = "REJECTED",
888        /// Any other or unknown state.
889        Unknown = "UNKNOWN",
890    }
891}
892
893impl ReservationStatus {
894    /// Whether the booking can still change.
895    #[must_use]
896    pub const fn is_terminal(self) -> bool {
897        matches!(self, Self::Canceled | Self::Failed | Self::NoShow | Self::Fulfilled | Self::Rejected)
898    }
899
900    /// Whether `next` is a state this booking may move to.
901    ///
902    /// > *A Booking starts in a `PENDING` state when initially requested by the eMSP. … From
903    /// > `RESERVED`, the Booking can transition to `FULFILLED`, `CANCELED` or `NO_SHOW`.*
904    ///
905    /// Spec: 2.3.0-bookings §mod_bookings_bookings_module
906    #[must_use]
907    pub const fn can_transition_to(self, next: Self) -> bool {
908        match self {
909            Self::Pending => matches!(
910                next,
911                Self::Reserved | Self::Rejected | Self::Failed | Self::Canceled | Self::Unknown
912            ),
913            Self::Reserved => {
914                matches!(next, Self::Fulfilled | Self::Canceled | Self::NoShow | Self::Unknown)
915            }
916            Self::Unknown => true,
917            _ => false,
918        }
919    }
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925
926    fn dt(s: &str) -> DateTime {
927        s.parse().unwrap()
928    }
929
930    #[test]
931    fn the_lifecycle_is_the_one_the_spec_describes() {
932        use ReservationStatus::{Canceled, Fulfilled, NoShow, Pending, Rejected, Reserved};
933        assert!(Pending.can_transition_to(Reserved));
934        assert!(Pending.can_transition_to(Rejected));
935        assert!(Reserved.can_transition_to(Fulfilled));
936        assert!(Reserved.can_transition_to(Canceled));
937        assert!(Reserved.can_transition_to(NoShow));
938        // A booking that was never reserved cannot be fulfilled.
939        assert!(!Pending.can_transition_to(Fulfilled));
940        // Terminal states are terminal.
941        assert!(!Fulfilled.can_transition_to(Canceled));
942        assert!(Fulfilled.is_terminal() && NoShow.is_terminal());
943        assert!(!Pending.is_terminal() && !Reserved.is_terminal());
944    }
945
946    #[test]
947    fn a_timeslot_must_be_a_forward_interval_with_coherent_power() {
948        let slot = Timeslot::builder()
949            .start_date_time(dt("2024-06-01T10:00:00Z"))
950            .end_date_time(dt("2024-06-01T12:00:00Z"))
951            .min_power(Number::from(11_000u32))
952            .max_power(Number::from(22_000u32))
953            .build();
954        assert!(slot.validate().is_ok());
955        assert_eq!(slot.duration_minutes(), Some(120));
956
957        let backwards = Timeslot { end_date_time: dt("2024-06-01T09:00:00Z"), ..slot.clone() };
958        assert!(backwards.validate().is_err());
959        assert_eq!(backwards.duration_minutes(), None);
960
961        let impossible = Timeslot { max_power: Some(Number::from(1000u32)), ..slot };
962        assert!(impossible.validate().is_err());
963    }
964
965    #[test]
966    fn a_calendar_accommodates_a_slot_that_fits_inside_an_available_one() {
967        let calendar = Calendar::builder()
968            .id("CAL1")
969            .begin_from(dt("2024-06-01T00:00:00Z"))
970            .end_before(dt("2024-06-02T00:00:00Z"))
971            .available_timeslots(vec![
972                Timeslot::builder()
973                    .start_date_time(dt("2024-06-01T08:00:00Z"))
974                    .end_date_time(dt("2024-06-01T18:00:00Z"))
975                    .build(),
976            ])
977            .last_updated(dt("2024-05-01T00:00:00Z"))
978            .build();
979        assert!(calendar.validate().is_ok());
980
981        let fits = Timeslot::builder()
982            .start_date_time(dt("2024-06-01T10:00:00Z"))
983            .end_date_time(dt("2024-06-01T12:00:00Z"))
984            .build();
985        assert!(calendar.can_accommodate(&fits));
986
987        let overruns = Timeslot::builder()
988            .start_date_time(dt("2024-06-01T17:00:00Z"))
989            .end_date_time(dt("2024-06-01T19:00:00Z"))
990            .build();
991        assert!(!calendar.can_accommodate(&overruns));
992    }
993
994    #[test]
995    fn the_change_and_cancel_windows_are_computed_from_the_terms() {
996        let terms = BookingTerms::builder()
997            .supported_access_methods(vec![AccessMethod::Open])
998            .change_until_minutes(Number::from(60u32))
999            .cancel_until_minutes(Number::from(30u32))
1000            .build();
1001        let start = dt("2024-06-01T12:00:00Z");
1002        assert!(terms.may_change_at(dt("2024-06-01T10:00:00Z"), start));
1003        assert!(!terms.may_change_at(dt("2024-06-01T11:30:00Z"), start), "inside the 60 minutes");
1004        assert!(terms.may_cancel_at(dt("2024-06-01T11:30:00Z"), start));
1005        assert!(!terms.may_cancel_at(dt("2024-06-01T11:45:00Z"), start));
1006
1007        let frozen = BookingTerms { change_not_allowed: Some(true), ..terms };
1008        assert!(!frozen.may_change_at(dt("2024-06-01T00:00:00Z"), start));
1009    }
1010
1011    #[test]
1012    fn an_access_method_that_needs_a_value_must_have_one() {
1013        let bare = AccessInformation {
1014            method: AccessMethod::AccessCode,
1015            value: None,
1016            extensions: Extensions::new(),
1017        };
1018        assert_eq!(bare.validate().unwrap_err().as_slice()[0].pointer, "/value");
1019
1020        let open =
1021            AccessInformation { method: AccessMethod::Open, value: None, extensions: Extensions::new() };
1022        assert!(open.validate().is_ok(), "OPEN needs nothing");
1023    }
1024
1025    #[test]
1026    fn a_location_that_requires_a_reservation_offers_no_ad_hoc_charging() {
1027        let contradiction = Policy {
1028            reservation_required: true,
1029            ad_hoc: Some(Number::from(2u32)),
1030            extensions: Extensions::new(),
1031        };
1032        assert!(contradiction.validate().is_err());
1033        let coherent = Policy { ad_hoc: Some(Number::ZERO), ..contradiction };
1034        assert!(coherent.validate().is_ok());
1035    }
1036}