Skip to main content

ocpi_kit/v2_3_0/
tariffs.rs

1//! The *Tariffs* module of OCPI 2.3.0: what charging costs.
2//!
3//! *Module Identifier: `tariffs`* — Data owner: CPO.
4//!
5//! A Tariff is a list of [`TariffElement`]s; each is a group of [`PriceComponent`]s that share
6//! [`TariffRestrictions`]. Evaluating them against a session is the job of
7//! [`crate::tariffs`], the pricing engine.
8//!
9//! > *NOTE: There are no parameters related to price rounding in the Tariff object or any of its
10//! > constituent objects. Nor does the specification text of this module give any requirements
11//! > about how to do price rounding.*
12//!
13//! Spec: 2.3.0 §mod_tariffs_tariffs_module
14
15use bon::Builder;
16use serde::{Deserialize, Serialize};
17
18use crate::ocpi_enum;
19use crate::types::validate_fields;
20use crate::types::{
21    CiString, CountryCode, Currency, DateTime, DisplayText, Extensions, LocalDate, LocalTime, Number,
22    PartyId, PartyRef, Url, Validate, Validator, ViolationCode,
23};
24
25use super::locations::EnergyMix;
26
27/// A tariff: one or more [`TariffElement`]s that price a charging session.
28///
29/// > *When the list of Tariff Elements contains more than one Element that has a Price Component
30/// > for a certain dimension, then the first Tariff Element with a Price Component for that
31/// > dimension in the list with matching Tariff Restrictions will be used.*
32///
33/// Spec: 2.3.0 §mod_tariffs_tariff_object
34#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
35#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
36#[builder(on(_, into))]
37pub struct Tariff {
38    /// ISO-3166 alpha-2 country code of the CPO that owns this Tariff.
39    pub country_code: CountryCode,
40    /// ID of the CPO that 'owns' this Tariff.
41    pub party_id: PartyId,
42    /// Uniquely identifies the tariff within the CPO's platform.
43    pub id: CiString<36>,
44    /// ISO-4217 code of the currency of this tariff.
45    pub currency: Currency,
46    /// The type of the tariff. When omitted, this tariff is valid for all sessions.
47    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
48    pub tariff_type: Option<TariffType>,
49    /// Multi-language alternative tariff info texts.
50    ///
51    /// > *When a Tariff contains both the `tariff_alt_text` and `elements` fields, the
52    /// > `tariff_alt_text` SHALL only contain additional tariff information in human-readable
53    /// > text, not the price information that is also available via the `elements` field.*
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    #[builder(default)]
56    pub tariff_alt_text: Vec<DisplayText>,
57    /// URL to a web page explaining the tariff in human-readable form.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub tariff_alt_url: Option<Url>,
60    /// A Charging Session with this tariff will cost at least this amount.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub min_price: Option<PriceLimit>,
63    /// A Charging Session with this tariff will cost at most this amount.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub max_price: Option<PriceLimit>,
66    /// The amount a Payment Terminal Provider should preauthorize for a Session with this
67    /// Tariff. New in OCPI 2.3.0, together with the Payments module.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub preauthorize_amount: Option<Number>,
70    /// The Tariff Elements. Cardinality `+`.
71    pub elements: Vec<TariffElement>,
72    /// Whether taxes are included in the amounts in this Tariff. New in OCPI 2.3.0.
73    pub tax_included: TaxIncluded,
74    /// When this tariff becomes active, in UTC.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub start_date_time: Option<DateTime>,
77    /// When this tariff stops being valid, in UTC.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub end_date_time: Option<DateTime>,
80    /// Details on the energy supplied with this tariff.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub energy_mix: Option<EnergyMix>,
83    /// Timestamp when this Tariff was last updated (or created).
84    pub last_updated: DateTime,
85    /// Undocumented JSON fields, preserved verbatim.
86    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
87    #[builder(default)]
88    pub extensions: Extensions,
89}
90
91impl Tariff {
92    /// The CPO that owns this Tariff.
93    #[must_use]
94    pub fn owner_party(&self) -> PartyRef {
95        PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
96    }
97
98    /// Whether this tariff is in its validity window at `instant`.
99    ///
100    /// An absent `start_date_time` means "already active"; an absent `end_date_time` means
101    /// "still active".
102    #[must_use]
103    pub fn is_active_at(&self, instant: DateTime) -> bool {
104        self.start_date_time.is_none_or(|s| instant >= s) && self.end_date_time.is_none_or(|e| instant < e)
105    }
106
107    /// Whether this is the "Free of Charge" shape the spec prescribes.
108    ///
109    /// > *To define a "Free of Charge" tariff in OCPI, a Tariff containing one Tariff Element
110    /// > with no restrictions containing one Price Component with `type` = `FLAT` and
111    /// > `price` = `0.00` has to be provided.*
112    #[must_use]
113    pub fn is_free_of_charge(&self) -> bool {
114        match self.elements.as_slice() {
115            [element] if element.restrictions.is_none() => match element.price_components.as_slice() {
116                [pc] => pc.component_type == TariffDimensionType::Flat && pc.price.is_zero(),
117                _ => false,
118            },
119            _ => false,
120        }
121    }
122}
123
124impl Validate for Tariff {
125    fn validate_in(&self, v: &mut Validator) {
126        validate_fields!(
127            self, v, country_code, party_id, id, currency, tariff_type as "type", tariff_alt_text,
128            tariff_alt_url, min_price, max_price, preauthorize_amount, elements, tax_included,
129            start_date_time, end_date_time, energy_mix, last_updated,
130        );
131        if self.elements.is_empty() {
132            v.report_at(
133                "elements",
134                ViolationCode::EmptyRequiredList,
135                "a Tariff has cardinality `+` elements: at least one is required",
136            );
137        }
138        if let (Some(start), Some(end)) = (self.start_date_time, self.end_date_time)
139            && end <= start
140        {
141            v.report_at(
142                "end_date_time",
143                ViolationCode::Inconsistent,
144                "a tariff's validity window must be non-empty",
145            );
146        }
147        if let (Some(min), Some(max)) = (self.min_price.as_ref(), self.max_price.as_ref())
148            && max.before_taxes < min.before_taxes
149        {
150            v.report_at(
151                "max_price",
152                ViolationCode::Inconsistent,
153                "max_price.before_taxes is below min_price.before_taxes",
154            );
155        }
156        // "A reservation can only have: FLAT and TIME TariffDimensions."
157        for (i, element) in self.elements.iter().enumerate() {
158            let Some(restrictions) = element.restrictions.as_ref() else { continue };
159            if restrictions.reservation.is_none() {
160                continue;
161            }
162            for (j, pc) in element.price_components.iter().enumerate() {
163                if !matches!(pc.component_type, TariffDimensionType::Flat | TariffDimensionType::Time) {
164                    v.enter("elements");
165                    v.enter(&i.to_string());
166                    v.enter("price_components");
167                    v.enter(&j.to_string());
168                    v.report_at(
169                        "type",
170                        ViolationCode::Inconsistent,
171                        format!(
172                            "a reservation Tariff Element can only have FLAT and TIME dimensions, \
173                             not {}",
174                            pc.component_type
175                        ),
176                    );
177                    v.leave();
178                    v.leave();
179                    v.leave();
180                    v.leave();
181                }
182            }
183        }
184    }
185}
186
187/// A group of [`PriceComponent`]s that share a set of restrictions.
188///
189/// > *That the Price Components share the same restrictions does not mean that at any time, they
190/// > either all apply or all do not apply. The reason is that applicable Price Components are
191/// > looked up separately for each dimension.*
192///
193/// Spec: 2.3.0 §mod_tariffs_tariffelement_class
194#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
195#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
196#[builder(on(_, into))]
197pub struct TariffElement {
198    /// How each priced dimension is priced. Cardinality `+`.
199    pub price_components: Vec<PriceComponent>,
200    /// Under which circumstances these Price Components apply.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub restrictions: Option<TariffRestrictions>,
203    /// Undocumented JSON fields, preserved verbatim.
204    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
205    #[builder(default)]
206    pub extensions: Extensions,
207}
208
209impl TariffElement {
210    /// The Price Component for one dimension, if this element prices it.
211    #[must_use]
212    pub fn component(&self, dimension: TariffDimensionType) -> Option<&PriceComponent> {
213        self.price_components.iter().find(|c| c.component_type == dimension)
214    }
215}
216
217impl Validate for TariffElement {
218    fn validate_in(&self, v: &mut Validator) {
219        validate_fields!(self, v, price_components, restrictions);
220        if self.price_components.is_empty() {
221            v.report_at(
222                "price_components",
223                ViolationCode::EmptyRequiredList,
224                "a TariffElement has cardinality `+` price_components: at least one is required",
225            );
226        }
227        let mut seen: Vec<TariffDimensionType> = Vec::new();
228        for pc in &self.price_components {
229            if seen.contains(&pc.component_type) {
230                v.report_at(
231                    "price_components",
232                    ViolationCode::Inconsistent,
233                    format!(
234                        "{} is priced twice in one Tariff Element; only one Price Component per \
235                         dimension can be active at a time",
236                        pc.component_type
237                    ),
238                );
239            }
240            seen.push(pc.component_type);
241        }
242    }
243}
244
245/// How consumption of one dimension translates into money owed.
246///
247/// Spec: 2.3.0 §mod_tariffs_pricecomponent_class
248#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
249#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
250#[builder(on(_, into))]
251pub struct PriceComponent {
252    /// The dimension that is being priced.
253    #[serde(rename = "type")]
254    pub component_type: TariffDimensionType,
255    /// Price per unit for this dimension, including or excluding taxes according to the
256    /// containing Tariff's `tax_included` field.
257    pub price: Number,
258    /// Applicable VAT percentage for this dimension. If omitted, no VAT is applicable.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub vat: Option<Number>,
261    /// Minimum amount to be billed: the dimension is billed in blocks of this size.
262    ///
263    /// > *NOTE: The `step_size` field is no longer present in OCPI 3.0. … Users of OCPI 2.2.1
264    /// > looking to be ready for a transition to OCPI 3.0 … are advised to effectively avoid
265    /// > using `step_size` by setting `step_size` to 1 always.*
266    ///
267    /// The unit is the dimension's `step_size` multiplier: 1 Wh for `ENERGY`, 1 second for the
268    /// time dimensions, and nothing for `FLAT`. See [`TariffDimensionType::step_size_unit`].
269    pub step_size: u32,
270    /// Undocumented JSON fields, preserved verbatim.
271    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
272    #[builder(default)]
273    pub extensions: Extensions,
274}
275
276impl PriceComponent {
277    /// A price component with no VAT and a `step_size` of 1.
278    #[must_use]
279    pub fn new(component_type: TariffDimensionType, price: Number) -> Self {
280        Self { component_type, price, vat: None, step_size: 1, extensions: Extensions::new() }
281    }
282}
283
284impl Validate for PriceComponent {
285    fn validate_in(&self, v: &mut Validator) {
286        validate_fields!(self, v, component_type as "type", price, vat);
287        // FLAT is "a flat fee without unit for step_size", so its value carries no meaning —
288        // the specification's own free-of-charge example writes `"step_size": 0` there. For a
289        // dimension that does have a unit, a step of zero would bill nothing.
290        if self.step_size == 0 && self.component_type.step_size_unit().is_some() {
291            v.report_at(
292                "step_size",
293                ViolationCode::OutOfRange,
294                format!(
295                    "a step_size of 0 would bill no {}; the smallest meaningful value is 1",
296                    self.component_type
297                ),
298            );
299        }
300        if self.vat.is_some_and(Number::is_negative) {
301            v.report_at("vat", ViolationCode::OutOfRange, "a VAT percentage cannot be negative");
302        }
303    }
304}
305
306/// A minimum or maximum total cost for a Charging Session. New in OCPI 2.3.0.
307///
308/// > *As the taxes on a Charging Session might be different for different parts of the Session,
309/// > there might be situations where the minimum cost after taxes is reached earlier or later
310/// > than the minimum price before taxes. So as a rule, they both apply.*
311///
312/// Spec: 2.3.0 §mod_tariffs_pricelimit_class
313#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
315pub struct PriceLimit {
316    /// Maximum or minimum cost excluding taxes.
317    pub before_taxes: Number,
318    /// Maximum or minimum cost including taxes.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub after_taxes: Option<Number>,
321    /// Undocumented JSON fields, preserved verbatim.
322    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
323    pub extensions: Extensions,
324}
325
326impl PriceLimit {
327    /// A limit on the pre-tax amount only.
328    #[must_use]
329    pub fn before_taxes(amount: Number) -> Self {
330        Self { before_taxes: amount, after_taxes: None, extensions: Extensions::new() }
331    }
332}
333
334impl Validate for PriceLimit {
335    fn validate_in(&self, v: &mut Validator) {
336        validate_fields!(self, v, before_taxes, after_taxes);
337        if self.after_taxes.is_some_and(|a| a < self.before_taxes) {
338            v.report_at(
339                "after_taxes",
340                ViolationCode::Inconsistent,
341                "the amount including taxes cannot be lower than the amount excluding them",
342            );
343        }
344    }
345}
346
347/// When a [`TariffElement`] is active during a Charging Session.
348///
349/// > *When more than one restriction is set, they are to be treated as a logical AND. So a Tariff
350/// > Element is active if and only if all of the properties in its TariffRestrictions match.*
351///
352/// Spec: 2.3.0 §mod_tariffs_tariffrestrictions_class
353#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Builder)]
354#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
355#[builder(on(_, into))]
356pub struct TariffRestrictions {
357    /// Start time of day in local time, e.g. `13:30`; valid from this time of the day.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub start_time: Option<LocalTime>,
360    /// End time of day in local time.
361    ///
362    /// > *If `end_time` < `start_time` then the period wraps around to the next day. To stop at
363    /// > end of the day use: 00:00.*
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub end_time: Option<LocalTime>,
366    /// Start date in local time; valid from this day, inclusive.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub start_date: Option<LocalDate>,
369    /// End date in local time; valid until this day, exclusive.
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub end_date: Option<LocalDate>,
372    /// Minimum consumed energy in kWh, inclusive.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub min_kwh: Option<Number>,
375    /// Maximum consumed energy in kWh, exclusive.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub max_kwh: Option<Number>,
378    /// Sum of the minimum current over all phases, in A, inclusive.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub min_current: Option<Number>,
381    /// Sum of the maximum current over all phases, in A, exclusive.
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub max_current: Option<Number>,
384    /// Minimum power in kW, inclusive.
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub min_power: Option<Number>,
387    /// Maximum power in kW, exclusive.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub max_power: Option<Number>,
390    /// Minimum duration in seconds the Charging Session must last, inclusive.
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub min_duration: Option<u64>,
393    /// Maximum duration in seconds the Charging Session must last, exclusive.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub max_duration: Option<u64>,
396    /// Which days of the week this Tariff Element is active.
397    #[serde(default, skip_serializing_if = "Vec::is_empty")]
398    #[builder(default)]
399    pub day_of_week: Vec<DayOfWeek>,
400    /// When present, this Tariff Element describes reservation costs.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub reservation: Option<ReservationRestrictionType>,
403    /// When present, this Tariff Element describes **booking** costs.
404    ///
405    /// Added by the OCPI 2.3.0 `bookings` release branch, so it is behind the `bookings` feature.
406    ///
407    /// Spec: 2.3.0-bookings §mod_tariffs_tariffrestrictions_class
408    #[cfg(feature = "bookings")]
409    #[cfg_attr(docsrs, doc(cfg(feature = "bookings")))]
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub booking: Option<BookingRestrictionType>,
412    /// Undocumented JSON fields, preserved verbatim.
413    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
414    #[builder(default)]
415    pub extensions: Extensions,
416}
417
418impl TariffRestrictions {
419    /// Whether no restriction at all is set, making this the element's fallback.
420    ///
421    /// > *It is advised to always add a "default" Price Component per dimension. This can be
422    /// > achieved by adding a Tariff Element without restrictions after all other occurrences.*
423    #[must_use]
424    pub fn is_unrestricted(&self) -> bool {
425        self == &Self::default()
426    }
427
428    /// Whether this element prices a reservation rather than a charging session.
429    #[must_use]
430    pub const fn is_reservation(&self) -> bool {
431        self.reservation.is_some()
432    }
433}
434
435impl Validate for TariffRestrictions {
436    fn validate_in(&self, v: &mut Validator) {
437        validate_fields!(
438            self,
439            v,
440            start_time,
441            end_time,
442            start_date,
443            end_date,
444            min_kwh,
445            max_kwh,
446            min_current,
447            max_current,
448            min_power,
449            max_power,
450            day_of_week,
451            reservation,
452        );
453        // A wrap-around window is legal for times, but not for dates or magnitudes.
454        for (lo_name, lo, hi_name, hi) in [
455            ("min_kwh", self.min_kwh, "max_kwh", self.max_kwh),
456            ("min_current", self.min_current, "max_current", self.max_current),
457            ("min_power", self.min_power, "max_power", self.max_power),
458        ] {
459            if let (Some(lo_v), Some(hi_v)) = (lo, hi)
460                && hi_v <= lo_v
461            {
462                v.report_at(
463                    hi_name,
464                    ViolationCode::Inconsistent,
465                    format!("{hi_name} is not above {lo_name}, so this element can never apply"),
466                );
467            }
468        }
469        if let (Some(lo), Some(hi)) = (self.min_duration, self.max_duration)
470            && hi <= lo
471        {
472            v.report_at(
473                "max_duration",
474                ViolationCode::Inconsistent,
475                "max_duration is not above min_duration, so this element can never apply",
476            );
477        }
478        if let (Some(start), Some(end)) = (self.start_date, self.end_date)
479            && end <= start
480        {
481            v.report_at(
482                "end_date",
483                ViolationCode::Inconsistent,
484                "end_date is exclusive and must be after start_date",
485            );
486        }
487        let mut seen: Vec<DayOfWeek> = Vec::new();
488        for d in &self.day_of_week {
489            if seen.contains(d) {
490                v.report_at(
491                    "day_of_week",
492                    ViolationCode::Inconsistent,
493                    format!("{d} is listed more than once"),
494                );
495            }
496            seen.push(*d);
497        }
498    }
499}
500
501ocpi_enum! {
502    /// A day of the week, as used in [`TariffRestrictions::day_of_week`].
503    ///
504    /// Spec: 2.3.0 §mod_tariffs_dayofweek_enum
505    pub enum DayOfWeek {
506        /// Monday.
507        Monday = "MONDAY",
508        /// Tuesday.
509        Tuesday = "TUESDAY",
510        /// Wednesday.
511        Wednesday = "WEDNESDAY",
512        /// Thursday.
513        Thursday = "THURSDAY",
514        /// Friday.
515        Friday = "FRIDAY",
516        /// Saturday.
517        Saturday = "SATURDAY",
518        /// Sunday.
519        Sunday = "SUNDAY",
520    }
521}
522
523impl DayOfWeek {
524    /// The ISO weekday number, Monday = 1 through Sunday = 7.
525    ///
526    /// This is the same numbering `RegularHours.weekday` uses.
527    #[must_use]
528    pub const fn iso_number(self) -> u8 {
529        match self {
530            Self::Monday => 1,
531            Self::Tuesday => 2,
532            Self::Wednesday => 3,
533            Self::Thursday => 4,
534            Self::Friday => 5,
535            Self::Saturday => 6,
536            Self::Sunday => 7,
537        }
538    }
539
540    /// The day for an ISO weekday number, Monday = 1 through Sunday = 7.
541    #[must_use]
542    pub const fn from_iso_number(n: u8) -> Option<Self> {
543        Some(match n {
544            1 => Self::Monday,
545            2 => Self::Tuesday,
546            3 => Self::Wednesday,
547            4 => Self::Thursday,
548            5 => Self::Friday,
549            6 => Self::Saturday,
550            7 => Self::Sunday,
551            _ => return None,
552        })
553    }
554}
555
556ocpi_enum! {
557    /// Whether a Tariff Element prices a reservation.
558    ///
559    /// > *A reservation starts when the reservation is made, and ends when the driver starts
560    /// > charging on the reserved EVSE/Location, or when the reservation expires.*
561    ///
562    /// Spec: 2.3.0 §mod_tariffs_reservation_restriction_type
563    pub enum ReservationRestrictionType {
564        /// Costs for a reservation.
565        Reservation = "RESERVATION",
566        /// Costs for a reservation that expires before the driver starts charging.
567        ReservationExpires = "RESERVATION_EXPIRES",
568    }
569}
570
571// `#[cfg]` gates the whole expansion, so it stays out here; the `doc(cfg)` badge has to go *in*,
572// on the enum the macro emits. Attached to the invocation instead it documents nothing, and
573// rustdoc rejects it: "rustdoc does not generate documentation for macro invocations".
574#[cfg(feature = "bookings")]
575ocpi_enum! {
576    /// What kind of booking cost a Tariff Element describes.
577    ///
578    /// Spec: 2.3.0-bookings §mod_tariffs_booking_restriction_type
579    #[cfg_attr(docsrs, doc(cfg(feature = "bookings")))]
580    pub enum BookingRestrictionType {
581        /// Costs for a booking.
582        Booking = "BOOKING",
583        /// Costs for a booking that does not start within the booked period.
584        BookingExpires = "BOOKING_EXPIRES",
585        /// Costs for cancelling a booking.
586        BookingCancellationFees = "BOOKING_CANCELLATION_FEES",
587        /// Costs for charging after the booking has completed.
588        BookingOvertime = "BOOKING_OVERTIME",
589    }
590}
591
592ocpi_enum! {
593    /// The dimensions a [`PriceComponent`] can price.
594    ///
595    /// Spec: 2.3.0 §mod_tariffs_tariffdimensiontype_enum
596    pub enum TariffDimensionType {
597        /// Defined in kWh; `step_size` multiplier 1 Wh.
598        Energy = "ENERGY",
599        /// Flat fee, without a unit for `step_size`.
600        Flat = "FLAT",
601        /// Time not charging, in hours; `step_size` multiplier 1 second.
602        ParkingTime = "PARKING_TIME",
603        /// Time charging, in hours; `step_size` multiplier 1 second.
604        ///
605        /// > *Can also be used in combination with a RESERVATION restriction to describe the
606        /// > price of the reservation time.*
607        Time = "TIME",
608    }
609}
610
611impl TariffDimensionType {
612    /// The unit that `step_size` is a multiple of, or `None` for `FLAT`.
613    ///
614    /// > *`ENERGY` has the `step_size` multiplier: 1 Wh … `PARKING_TIME` has the `step_size`
615    /// > multiplier: 1 second.*
616    #[must_use]
617    pub const fn step_size_unit(self) -> Option<&'static str> {
618        match self {
619            Self::Energy => Some("Wh"),
620            Self::ParkingTime | Self::Time => Some("s"),
621            Self::Flat => None,
622        }
623    }
624
625    /// Whether this dimension is measured over time rather than over energy.
626    ///
627    /// The distinction matters for `step_size`: the spec says it is *"only taken into account
628    /// once per session for ENERGY and once for PARKING_TIME and TIME combined"*.
629    #[must_use]
630    pub const fn is_time_based(self) -> bool {
631        matches!(self, Self::Time | Self::ParkingTime)
632    }
633}
634
635ocpi_enum! {
636    /// The kind of session a Tariff applies to.
637    ///
638    /// Spec: 2.3.0 §mod_tariffs_tariff_type
639    pub enum TariffType {
640        /// Valid when ad-hoc payment is used at the Charge Point.
641        AdHocPayment = "AD_HOC_PAYMENT",
642        /// Valid when the Charging Preference `CHEAP` is set for the session.
643        ProfileCheap = "PROFILE_CHEAP",
644        /// Valid when the Charging Preference `FAST` is set for the session.
645        ProfileFast = "PROFILE_FAST",
646        /// Valid when the Charging Preference `GREEN` is set for the session.
647        ProfileGreen = "PROFILE_GREEN",
648        /// Valid when using an RFID without a Charging Preference, or with `REGULAR`.
649        Regular = "REGULAR",
650    }
651}
652
653ocpi_enum! {
654    /// Whether taxes are included in the amounts of a Tariff. New in OCPI 2.3.0.
655    ///
656    /// This is what makes North American tax handling expressible: a CPO there often does not
657    /// know the rate when it publishes the Tariff, so it publishes pre-tax prices and says `NO`.
658    ///
659    /// Spec: 2.3.0 §mod_tariffs_taxincluded_enum
660    pub enum TaxIncluded {
661        /// Taxes are included in the prices in this Tariff.
662        Yes = "YES",
663        /// Taxes are not included and will be added on top of the prices in this Tariff.
664        No = "NO",
665        /// No taxes are applicable to this Tariff.
666        NotApplicable = "N/A",
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    fn tariff(elements: Vec<TariffElement>) -> Tariff {
675        Tariff::builder()
676            .country_code("DE")
677            .party_id("ALL")
678            .id("12")
679            .currency("EUR")
680            .elements(elements)
681            .tax_included(TaxIncluded::No)
682            .last_updated("2018-12-17T11:16:55Z".parse::<DateTime>().unwrap())
683            .build()
684    }
685
686    fn flat(price: &str) -> PriceComponent {
687        PriceComponent::new(TariffDimensionType::Flat, price.parse().unwrap())
688    }
689
690    #[test]
691    fn free_of_charge_has_the_exact_shape_the_spec_prescribes() {
692        let free = tariff(vec![TariffElement::builder().price_components(vec![flat("0.00")]).build()]);
693        assert!(free.is_free_of_charge());
694
695        let with_restriction = tariff(vec![
696            TariffElement::builder()
697                .price_components(vec![flat("0.00")])
698                .restrictions(TariffRestrictions {
699                    max_kwh: Some("10".parse().unwrap()),
700                    ..Default::default()
701                })
702                .build(),
703        ]);
704        assert!(!with_restriction.is_free_of_charge(), "a restricted zero price is not free");
705        assert!(
706            !tariff(vec![TariffElement::builder().price_components(vec![flat("0.25")]).build()])
707                .is_free_of_charge()
708        );
709    }
710
711    #[test]
712    fn reservation_elements_may_only_price_flat_and_time() {
713        let bad = tariff(vec![
714            TariffElement::builder()
715                .price_components(vec![PriceComponent::new(
716                    TariffDimensionType::Energy,
717                    "0.25".parse().unwrap(),
718                )])
719                .restrictions(TariffRestrictions {
720                    reservation: Some(ReservationRestrictionType::Reservation),
721                    ..Default::default()
722                })
723                .build(),
724        ]);
725        let err = bad.validate().unwrap_err();
726        assert_eq!(err.as_slice()[0].pointer, "/elements/0/price_components/0/type");
727    }
728
729    #[test]
730    fn a_dimension_cannot_be_priced_twice_in_one_element() {
731        let e = TariffElement::builder().price_components(vec![flat("1"), flat("2")]).build();
732        assert_eq!(e.validate().unwrap_err().as_slice()[0].code, ViolationCode::Inconsistent);
733    }
734
735    #[test]
736    fn impossible_restriction_windows_are_reported() {
737        let r = TariffRestrictions {
738            min_kwh: Some("20".parse().unwrap()),
739            max_kwh: Some("10".parse().unwrap()),
740            ..Default::default()
741        };
742        assert_eq!(r.validate().unwrap_err().as_slice()[0].pointer, "/max_kwh");
743        // Times may wrap around midnight, so no complaint there.
744        let wrap = TariffRestrictions {
745            start_time: Some("22:00".parse().unwrap()),
746            end_time: Some("06:00".parse().unwrap()),
747            ..Default::default()
748        };
749        assert!(wrap.validate().is_ok());
750    }
751
752    #[test]
753    fn step_size_units_follow_the_dimension() {
754        assert_eq!(TariffDimensionType::Energy.step_size_unit(), Some("Wh"));
755        assert_eq!(TariffDimensionType::Time.step_size_unit(), Some("s"));
756        assert_eq!(TariffDimensionType::Flat.step_size_unit(), None);
757        // FLAT has no unit, so any step_size is meaningless rather than wrong; the spec's own
758        // free-of-charge example writes 0 there.
759        assert!(PriceComponent { step_size: 0, ..flat("0.00") }.validate().is_ok());
760        let no_energy = PriceComponent {
761            step_size: 0,
762            ..PriceComponent::new(TariffDimensionType::Energy, "0.25".parse().unwrap())
763        };
764        assert_eq!(no_energy.validate().unwrap_err().as_slice()[0].pointer, "/step_size");
765    }
766
767    #[test]
768    fn validity_window_is_checked_against_an_instant() {
769        let mut t = tariff(vec![TariffElement::builder().price_components(vec![flat("1")]).build()]);
770        t.end_date_time = Some("2019-06-30T00:00:00Z".parse().unwrap());
771        assert!(t.is_active_at("2019-01-01T00:00:00Z".parse().unwrap()));
772        assert!(!t.is_active_at("2019-07-01T00:00:00Z".parse().unwrap()));
773    }
774
775    #[test]
776    fn iso_weekday_numbering_matches_regular_hours() {
777        assert_eq!(DayOfWeek::Monday.iso_number(), 1);
778        assert_eq!(DayOfWeek::Sunday.iso_number(), 7);
779        assert_eq!(DayOfWeek::from_iso_number(3), Some(DayOfWeek::Wednesday));
780        assert_eq!(DayOfWeek::from_iso_number(0), None);
781    }
782}