Skip to main content

ocpi_kit/v2_2_1/
tariffs.rs

1//! The *Tariffs* module of OCPI 2.2.1, as a delta from
2//! [`v2_3_0::tariffs`](crate::v2_3_0::tariffs).
3//!
4//! OCPI 2.3.0 changed three things about the [`Tariff`] object, all of them about tax:
5//!
6//! * `min_price`/`max_price` became [`PriceLimit`](crate::v2_3_0::tariffs::PriceLimit), which can
7//!   bound the after-tax total as well as the pre-tax one; here they are the 2.2.1
8//!   [`Price`];
9//! * `tax_included` was added, and is **required** there;
10//! * `preauthorize_amount` was added, for the Payments module that does not exist here.
11//!
12//! In 2.2.1 a `PriceComponent.price` is always **excluding VAT**; in 2.3.0 that depends on the
13//! Tariff's `tax_included`. Everything else about the two modules is identical.
14//!
15//! Spec: 2.2.1 §mod_tariffs_tariffs_module
16
17use bon::Builder;
18use serde::{Deserialize, Serialize};
19
20use crate::types::validate_fields;
21use crate::types::{
22    CiString, CountryCode, Currency, DateTime, DisplayText, Extensions, PartyId, PartyRef, Url, Validate,
23    Validator, ViolationCode,
24};
25
26use super::locations::EnergyMix;
27use super::types::Price;
28
29// Wire-identical to OCPI 2.3.0.
30pub use crate::v2_3_0::tariffs::{
31    DayOfWeek, PriceComponent, ReservationRestrictionType, TariffDimensionType, TariffElement,
32    TariffRestrictions, TariffType,
33};
34
35/// A tariff: one or more [`TariffElement`]s that price a charging session, in OCPI 2.2.1.
36///
37/// Spec: 2.2.1 §mod_tariffs_tariff_object
38#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
39#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
40#[builder(on(_, into))]
41pub struct Tariff {
42    /// ISO-3166 alpha-2 country code of the CPO that owns this Tariff.
43    pub country_code: CountryCode,
44    /// ID of the CPO that 'owns' this Tariff.
45    pub party_id: PartyId,
46    /// Uniquely identifies the tariff within the CPO's platform.
47    pub id: CiString<36>,
48    /// ISO-4217 code of the currency of this tariff.
49    pub currency: Currency,
50    /// The type of the tariff. When omitted, this tariff is valid for all sessions.
51    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
52    pub tariff_type: Option<TariffType>,
53    /// Multi-language alternative tariff info texts.
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<Price>,
63    /// A Charging Session with this tariff will not cost more than this amount.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub max_price: Option<Price>,
66    /// The Tariff Elements. Cardinality `+`.
67    pub elements: Vec<TariffElement>,
68    /// When this tariff becomes active, in UTC.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub start_date_time: Option<DateTime>,
71    /// When this tariff stops being valid, in UTC.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub end_date_time: Option<DateTime>,
74    /// Details on the energy supplied with this tariff.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub energy_mix: Option<EnergyMix>,
77    /// Timestamp when this Tariff was last updated (or created).
78    pub last_updated: DateTime,
79    /// Undocumented JSON fields, preserved verbatim.
80    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
81    #[builder(default)]
82    pub extensions: Extensions,
83}
84
85impl Tariff {
86    /// The CPO that owns this Tariff.
87    #[must_use]
88    pub fn owner_party(&self) -> PartyRef {
89        PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
90    }
91
92    /// Whether this tariff is in its validity window at `instant`.
93    #[must_use]
94    pub fn is_active_at(&self, instant: DateTime) -> bool {
95        self.start_date_time.is_none_or(|s| instant >= s) && self.end_date_time.is_none_or(|e| instant < e)
96    }
97
98    /// Whether this is the "Free of Charge" shape the spec prescribes.
99    #[must_use]
100    pub fn is_free_of_charge(&self) -> bool {
101        match self.elements.as_slice() {
102            [element] if element.restrictions.is_none() => match element.price_components.as_slice() {
103                [pc] => pc.component_type == TariffDimensionType::Flat && pc.price.is_zero(),
104                _ => false,
105            },
106            _ => false,
107        }
108    }
109}
110
111impl Validate for Tariff {
112    fn validate_in(&self, v: &mut Validator) {
113        validate_fields!(
114            self, v, country_code, party_id, id, currency, tariff_type as "type", tariff_alt_text,
115            tariff_alt_url, min_price, max_price, elements, start_date_time, end_date_time,
116            energy_mix, last_updated,
117        );
118        if self.elements.is_empty() {
119            v.report_at(
120                "elements",
121                ViolationCode::EmptyRequiredList,
122                "a Tariff has cardinality `+` elements: at least one is required",
123            );
124        }
125        if let (Some(start), Some(end)) = (self.start_date_time, self.end_date_time)
126            && end <= start
127        {
128            v.report_at(
129                "end_date_time",
130                ViolationCode::Inconsistent,
131                "a tariff's validity window must be non-empty",
132            );
133        }
134        if let (Some(min), Some(max)) = (self.min_price.as_ref(), self.max_price.as_ref())
135            && max.excl_vat < min.excl_vat
136        {
137            v.report_at(
138                "max_price",
139                ViolationCode::Inconsistent,
140                "max_price.excl_vat is below min_price.excl_vat",
141            );
142        }
143        for (i, element) in self.elements.iter().enumerate() {
144            let Some(restrictions) = element.restrictions.as_ref() else { continue };
145            if restrictions.reservation.is_none() {
146                continue;
147            }
148            for (j, pc) in element.price_components.iter().enumerate() {
149                if !matches!(pc.component_type, TariffDimensionType::Flat | TariffDimensionType::Time) {
150                    v.enter("elements");
151                    v.enter(&i.to_string());
152                    v.enter("price_components");
153                    v.enter(&j.to_string());
154                    v.report_at(
155                        "type",
156                        ViolationCode::Inconsistent,
157                        format!(
158                            "a reservation Tariff Element can only have FLAT and TIME dimensions, \
159                             not {}",
160                            pc.component_type
161                        ),
162                    );
163                    v.leave();
164                    v.leave();
165                    v.leave();
166                    v.leave();
167                }
168            }
169        }
170    }
171}