Skip to main content

ocpi_kit/v2_1_1/
tariffs.rs

1//! The *Tariffs* module of OCPI 2.1.1.
2//!
3//! A 2.1.1 Tariff has no owner fields, no `type`, no price limits and no tax handling: a
4//! [`PriceComponent`] here is *"price per unit (excluding VAT)"* with no `vat` field at all, so
5//! VAT is simply not expressible. OCPI 2.2 added the per-component `vat`; 2.3.0 added the
6//! Tariff-wide `tax_included`.
7//!
8//! Spec: 2.1.1 §mod_tariffs
9
10use bon::Builder;
11use serde::{Deserialize, Serialize};
12
13use crate::types::validate_fields;
14use crate::types::{
15    Currency, DateTime, DisplayText, Extensions, LocalDate, LocalTime, Number, OcpiString, Url, Validate,
16    Validator, ViolationCode,
17};
18
19use super::locations::EnergyMix;
20
21// Wire-identical to OCPI 2.3.0.
22pub use crate::v2_3_0::tariffs::{DayOfWeek, TariffDimensionType};
23
24/// A tariff, in OCPI 2.1.1.
25///
26/// Spec: 2.1.1 §mod_tariffs_tariff_object
27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29#[builder(on(_, into))]
30pub struct Tariff {
31    /// Uniquely identifies the tariff within the CPO's platform.
32    pub id: OcpiString<36>,
33    /// Currency of this tariff, ISO 4217 code.
34    pub currency: Currency,
35    /// Multi-language alternative tariff info text.
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    #[builder(default)]
38    pub tariff_alt_text: Vec<DisplayText>,
39    /// Alternative URL to tariff info.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub tariff_alt_url: Option<Url>,
42    /// The Tariff Elements. Cardinality `+`.
43    pub elements: Vec<TariffElement>,
44    /// Details on the energy supplied with this tariff.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub energy_mix: Option<EnergyMix>,
47    /// Timestamp when this Tariff was last updated (or created).
48    pub last_updated: DateTime,
49    /// Undocumented JSON fields, preserved verbatim.
50    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
51    #[builder(default)]
52    pub extensions: Extensions,
53}
54
55impl Tariff {
56    /// Whether this is the "Free of Charge" shape: one unrestricted element with a single zero
57    /// `FLAT` component.
58    #[must_use]
59    pub fn is_free_of_charge(&self) -> bool {
60        match self.elements.as_slice() {
61            [element] if element.restrictions.is_none() => match element.price_components.as_slice() {
62                [pc] => pc.component_type == TariffDimensionType::Flat && pc.price.is_zero(),
63                _ => false,
64            },
65            _ => false,
66        }
67    }
68}
69
70impl Validate for Tariff {
71    fn validate_in(&self, v: &mut Validator) {
72        validate_fields!(
73            self,
74            v,
75            id,
76            currency,
77            tariff_alt_text,
78            tariff_alt_url,
79            elements,
80            energy_mix,
81            last_updated,
82        );
83        if self.elements.is_empty() {
84            v.report_at(
85                "elements",
86                ViolationCode::EmptyRequiredList,
87                "a Tariff has cardinality `+` elements: at least one is required",
88            );
89        }
90    }
91}
92
93/// A group of [`PriceComponent`]s that share a set of restrictions.
94///
95/// Spec: 2.1.1 §mod_tariffs_tariffelement_class
96#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
97#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
98#[builder(on(_, into))]
99pub struct TariffElement {
100    /// How each priced dimension is priced. Cardinality `+`.
101    pub price_components: Vec<PriceComponent>,
102    /// Under which circumstances these Price Components apply.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub restrictions: Option<TariffRestrictions>,
105    /// Undocumented JSON fields, preserved verbatim.
106    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
107    #[builder(default)]
108    pub extensions: Extensions,
109}
110
111impl Validate for TariffElement {
112    fn validate_in(&self, v: &mut Validator) {
113        validate_fields!(self, v, price_components, restrictions);
114        if self.price_components.is_empty() {
115            v.report_at(
116                "price_components",
117                ViolationCode::EmptyRequiredList,
118                "a TariffElement has cardinality `+` price_components",
119            );
120        }
121    }
122}
123
124/// How consumption of one dimension translates into money owed, in OCPI 2.1.1.
125///
126/// **There is no `vat` field.** The price is always excluding VAT, and OCPI 2.1.1 provides no
127/// way to say what the VAT is; that arrived in OCPI 2.2.
128///
129/// Spec: 2.1.1 §mod_tariffs_pricecomponent_class
130#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
132pub struct PriceComponent {
133    /// The dimension being priced.
134    #[serde(rename = "type")]
135    pub component_type: TariffDimensionType,
136    /// Price per unit, excluding VAT.
137    pub price: Number,
138    /// Minimum amount to be billed: the dimension is billed in blocks of this size.
139    pub step_size: u32,
140    /// Undocumented JSON fields, preserved verbatim.
141    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
142    pub extensions: Extensions,
143}
144
145impl PriceComponent {
146    /// A price component with a `step_size` of 1.
147    #[must_use]
148    pub fn new(component_type: TariffDimensionType, price: Number) -> Self {
149        Self { component_type, price, step_size: 1, extensions: Extensions::new() }
150    }
151}
152
153impl Validate for PriceComponent {
154    fn validate_in(&self, v: &mut Validator) {
155        validate_fields!(self, v, component_type as "type", price);
156        if self.step_size == 0 && self.component_type.step_size_unit().is_some() {
157            v.report_at(
158                "step_size",
159                ViolationCode::OutOfRange,
160                "a step_size of 0 would bill nothing for a dimension that has a unit",
161            );
162        }
163    }
164}
165
166/// When a [`TariffElement`] is active, in OCPI 2.1.1.
167///
168/// The current-based restrictions (`min_current`, `max_current`) and the reservation restriction
169/// arrived in OCPI 2.2.
170///
171/// Spec: 2.1.1 §mod_tariffs_tariffrestrictions_class
172#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Builder)]
173#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
174#[builder(on(_, into))]
175pub struct TariffRestrictions {
176    /// Start time of day, valid from this time of the day.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub start_time: Option<LocalTime>,
179    /// End time of day, valid until this time of the day.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub end_time: Option<LocalTime>,
182    /// Start date, valid from this day.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub start_date: Option<LocalDate>,
185    /// End date, valid until this day, excluding this day.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub end_date: Option<LocalDate>,
188    /// Minimum used energy in kWh.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub min_kwh: Option<Number>,
191    /// Maximum used energy in kWh.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub max_kwh: Option<Number>,
194    /// Minimum power in kW.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub min_power: Option<Number>,
197    /// Maximum power in kW.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub max_power: Option<Number>,
200    /// Minimum duration in seconds.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub min_duration: Option<u64>,
203    /// Maximum duration in seconds.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub max_duration: Option<u64>,
206    /// Which days of the week this tariff is valid.
207    #[serde(default, skip_serializing_if = "Vec::is_empty")]
208    #[builder(default)]
209    pub day_of_week: Vec<DayOfWeek>,
210    /// Undocumented JSON fields, preserved verbatim.
211    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
212    #[builder(default)]
213    pub extensions: Extensions,
214}
215
216impl Validate for TariffRestrictions {
217    fn validate_in(&self, v: &mut Validator) {
218        validate_fields!(
219            self,
220            v,
221            start_time,
222            end_time,
223            start_date,
224            end_date,
225            min_kwh,
226            max_kwh,
227            min_power,
228            max_power,
229            day_of_week,
230        );
231        for (lo_name, lo, hi_name, hi) in [
232            ("min_kwh", self.min_kwh, "max_kwh", self.max_kwh),
233            ("min_power", self.min_power, "max_power", self.max_power),
234        ] {
235            if let (Some(lo_v), Some(hi_v)) = (lo, hi)
236                && hi_v <= lo_v
237            {
238                v.report_at(
239                    hi_name,
240                    ViolationCode::Inconsistent,
241                    format!("{hi_name} is not above {lo_name}, so this element can never apply"),
242                );
243            }
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn a_2_1_1_price_component_cannot_express_vat() {
254        let json = r#"{"type":"ENERGY","price":0.25,"step_size":1}"#;
255        let component: PriceComponent = serde_json::from_str(json).unwrap();
256        assert_eq!(serde_json::to_string(&component).unwrap(), json);
257        // A peer that sends a 2.2-style `vat` keeps it in extensions rather than losing it.
258        let with_vat: PriceComponent =
259            serde_json::from_str(r#"{"type":"ENERGY","price":0.25,"step_size":1,"vat":10}"#).unwrap();
260        assert_eq!(with_vat.extensions.get::<u32>("vat").unwrap(), Some(10));
261    }
262
263    #[test]
264    fn a_free_of_charge_tariff_has_the_usual_shape() {
265        let tariff = Tariff::builder()
266            .id("15")
267            .currency("EUR")
268            .elements(vec![
269                TariffElement::builder()
270                    .price_components(vec![PriceComponent::new(TariffDimensionType::Flat, Number::ZERO)])
271                    .build(),
272            ])
273            .last_updated("2015-06-29T20:39:09Z".parse::<DateTime>().unwrap())
274            .build();
275        assert!(tariff.is_free_of_charge());
276        assert!(tariff.validate().is_ok());
277    }
278}