use bon::Builder;
use serde::{Deserialize, Serialize};
use crate::ocpi_enum;
use crate::types::validate_fields;
use crate::types::{
CiString, CountryCode, Currency, DateTime, DisplayText, Extensions, LocalDate, LocalTime, Number,
PartyId, PartyRef, Url, Validate, Validator, ViolationCode,
};
use super::locations::EnergyMix;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Tariff {
pub country_code: CountryCode,
pub party_id: PartyId,
pub id: CiString<36>,
pub currency: Currency,
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
pub tariff_type: Option<TariffType>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub tariff_alt_text: Vec<DisplayText>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tariff_alt_url: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_price: Option<PriceLimit>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_price: Option<PriceLimit>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preauthorize_amount: Option<Number>,
pub elements: Vec<TariffElement>,
pub tax_included: TaxIncluded,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_date_time: Option<DateTime>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_date_time: Option<DateTime>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub energy_mix: Option<EnergyMix>,
pub last_updated: DateTime,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Tariff {
#[must_use]
pub fn owner_party(&self) -> PartyRef {
PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
}
#[must_use]
pub fn is_active_at(&self, instant: DateTime) -> bool {
self.start_date_time.is_none_or(|s| instant >= s) && self.end_date_time.is_none_or(|e| instant < e)
}
#[must_use]
pub fn is_free_of_charge(&self) -> bool {
match self.elements.as_slice() {
[element] if element.restrictions.is_none() => match element.price_components.as_slice() {
[pc] => pc.component_type == TariffDimensionType::Flat && pc.price.is_zero(),
_ => false,
},
_ => false,
}
}
}
impl Validate for Tariff {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self, v, country_code, party_id, id, currency, tariff_type as "type", tariff_alt_text,
tariff_alt_url, min_price, max_price, preauthorize_amount, elements, tax_included,
start_date_time, end_date_time, energy_mix, last_updated,
);
if self.elements.is_empty() {
v.report_at(
"elements",
ViolationCode::EmptyRequiredList,
"a Tariff has cardinality `+` elements: at least one is required",
);
}
if let (Some(start), Some(end)) = (self.start_date_time, self.end_date_time)
&& end <= start
{
v.report_at(
"end_date_time",
ViolationCode::Inconsistent,
"a tariff's validity window must be non-empty",
);
}
if let (Some(min), Some(max)) = (self.min_price.as_ref(), self.max_price.as_ref())
&& max.before_taxes < min.before_taxes
{
v.report_at(
"max_price",
ViolationCode::Inconsistent,
"max_price.before_taxes is below min_price.before_taxes",
);
}
for (i, element) in self.elements.iter().enumerate() {
let Some(restrictions) = element.restrictions.as_ref() else { continue };
if restrictions.reservation.is_none() {
continue;
}
for (j, pc) in element.price_components.iter().enumerate() {
if !matches!(pc.component_type, TariffDimensionType::Flat | TariffDimensionType::Time) {
v.enter("elements");
v.enter(&i.to_string());
v.enter("price_components");
v.enter(&j.to_string());
v.report_at(
"type",
ViolationCode::Inconsistent,
format!(
"a reservation Tariff Element can only have FLAT and TIME dimensions, \
not {}",
pc.component_type
),
);
v.leave();
v.leave();
v.leave();
v.leave();
}
}
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct TariffElement {
pub price_components: Vec<PriceComponent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restrictions: Option<TariffRestrictions>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl TariffElement {
#[must_use]
pub fn component(&self, dimension: TariffDimensionType) -> Option<&PriceComponent> {
self.price_components.iter().find(|c| c.component_type == dimension)
}
}
impl Validate for TariffElement {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, price_components, restrictions);
if self.price_components.is_empty() {
v.report_at(
"price_components",
ViolationCode::EmptyRequiredList,
"a TariffElement has cardinality `+` price_components: at least one is required",
);
}
let mut seen: Vec<TariffDimensionType> = Vec::new();
for pc in &self.price_components {
if seen.contains(&pc.component_type) {
v.report_at(
"price_components",
ViolationCode::Inconsistent,
format!(
"{} is priced twice in one Tariff Element; only one Price Component per \
dimension can be active at a time",
pc.component_type
),
);
}
seen.push(pc.component_type);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct PriceComponent {
#[serde(rename = "type")]
pub component_type: TariffDimensionType,
pub price: Number,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vat: Option<Number>,
pub step_size: u32,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl PriceComponent {
#[must_use]
pub fn new(component_type: TariffDimensionType, price: Number) -> Self {
Self { component_type, price, vat: None, step_size: 1, extensions: Extensions::new() }
}
}
impl Validate for PriceComponent {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, component_type as "type", price, vat);
if self.step_size == 0 && self.component_type.step_size_unit().is_some() {
v.report_at(
"step_size",
ViolationCode::OutOfRange,
format!(
"a step_size of 0 would bill no {}; the smallest meaningful value is 1",
self.component_type
),
);
}
if self.vat.is_some_and(Number::is_negative) {
v.report_at("vat", ViolationCode::OutOfRange, "a VAT percentage cannot be negative");
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PriceLimit {
pub before_taxes: Number,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_taxes: Option<Number>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl PriceLimit {
#[must_use]
pub fn before_taxes(amount: Number) -> Self {
Self { before_taxes: amount, after_taxes: None, extensions: Extensions::new() }
}
}
impl Validate for PriceLimit {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, before_taxes, after_taxes);
if self.after_taxes.is_some_and(|a| a < self.before_taxes) {
v.report_at(
"after_taxes",
ViolationCode::Inconsistent,
"the amount including taxes cannot be lower than the amount excluding them",
);
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct TariffRestrictions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_time: Option<LocalTime>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_time: Option<LocalTime>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_date: Option<LocalDate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_date: Option<LocalDate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_kwh: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_kwh: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_current: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_current: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_power: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_power: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_duration: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_duration: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub day_of_week: Vec<DayOfWeek>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reservation: Option<ReservationRestrictionType>,
#[cfg(feature = "bookings")]
#[cfg_attr(docsrs, doc(cfg(feature = "bookings")))]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub booking: Option<BookingRestrictionType>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl TariffRestrictions {
#[must_use]
pub fn is_unrestricted(&self) -> bool {
self == &Self::default()
}
#[must_use]
pub const fn is_reservation(&self) -> bool {
self.reservation.is_some()
}
}
impl Validate for TariffRestrictions {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
start_time,
end_time,
start_date,
end_date,
min_kwh,
max_kwh,
min_current,
max_current,
min_power,
max_power,
day_of_week,
reservation,
);
for (lo_name, lo, hi_name, hi) in [
("min_kwh", self.min_kwh, "max_kwh", self.max_kwh),
("min_current", self.min_current, "max_current", self.max_current),
("min_power", self.min_power, "max_power", self.max_power),
] {
if let (Some(lo_v), Some(hi_v)) = (lo, hi)
&& hi_v <= lo_v
{
v.report_at(
hi_name,
ViolationCode::Inconsistent,
format!("{hi_name} is not above {lo_name}, so this element can never apply"),
);
}
}
if let (Some(lo), Some(hi)) = (self.min_duration, self.max_duration)
&& hi <= lo
{
v.report_at(
"max_duration",
ViolationCode::Inconsistent,
"max_duration is not above min_duration, so this element can never apply",
);
}
if let (Some(start), Some(end)) = (self.start_date, self.end_date)
&& end <= start
{
v.report_at(
"end_date",
ViolationCode::Inconsistent,
"end_date is exclusive and must be after start_date",
);
}
let mut seen: Vec<DayOfWeek> = Vec::new();
for d in &self.day_of_week {
if seen.contains(d) {
v.report_at(
"day_of_week",
ViolationCode::Inconsistent,
format!("{d} is listed more than once"),
);
}
seen.push(*d);
}
}
}
ocpi_enum! {
pub enum DayOfWeek {
Monday = "MONDAY",
Tuesday = "TUESDAY",
Wednesday = "WEDNESDAY",
Thursday = "THURSDAY",
Friday = "FRIDAY",
Saturday = "SATURDAY",
Sunday = "SUNDAY",
}
}
impl DayOfWeek {
#[must_use]
pub const fn iso_number(self) -> u8 {
match self {
Self::Monday => 1,
Self::Tuesday => 2,
Self::Wednesday => 3,
Self::Thursday => 4,
Self::Friday => 5,
Self::Saturday => 6,
Self::Sunday => 7,
}
}
#[must_use]
pub const fn from_iso_number(n: u8) -> Option<Self> {
Some(match n {
1 => Self::Monday,
2 => Self::Tuesday,
3 => Self::Wednesday,
4 => Self::Thursday,
5 => Self::Friday,
6 => Self::Saturday,
7 => Self::Sunday,
_ => return None,
})
}
}
ocpi_enum! {
pub enum ReservationRestrictionType {
Reservation = "RESERVATION",
ReservationExpires = "RESERVATION_EXPIRES",
}
}
#[cfg(feature = "bookings")]
ocpi_enum! {
#[cfg_attr(docsrs, doc(cfg(feature = "bookings")))]
pub enum BookingRestrictionType {
Booking = "BOOKING",
BookingExpires = "BOOKING_EXPIRES",
BookingCancellationFees = "BOOKING_CANCELLATION_FEES",
BookingOvertime = "BOOKING_OVERTIME",
}
}
ocpi_enum! {
pub enum TariffDimensionType {
Energy = "ENERGY",
Flat = "FLAT",
ParkingTime = "PARKING_TIME",
Time = "TIME",
}
}
impl TariffDimensionType {
#[must_use]
pub const fn step_size_unit(self) -> Option<&'static str> {
match self {
Self::Energy => Some("Wh"),
Self::ParkingTime | Self::Time => Some("s"),
Self::Flat => None,
}
}
#[must_use]
pub const fn is_time_based(self) -> bool {
matches!(self, Self::Time | Self::ParkingTime)
}
}
ocpi_enum! {
pub enum TariffType {
AdHocPayment = "AD_HOC_PAYMENT",
ProfileCheap = "PROFILE_CHEAP",
ProfileFast = "PROFILE_FAST",
ProfileGreen = "PROFILE_GREEN",
Regular = "REGULAR",
}
}
ocpi_enum! {
pub enum TaxIncluded {
Yes = "YES",
No = "NO",
NotApplicable = "N/A",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tariff(elements: Vec<TariffElement>) -> Tariff {
Tariff::builder()
.country_code("DE")
.party_id("ALL")
.id("12")
.currency("EUR")
.elements(elements)
.tax_included(TaxIncluded::No)
.last_updated("2018-12-17T11:16:55Z".parse::<DateTime>().unwrap())
.build()
}
fn flat(price: &str) -> PriceComponent {
PriceComponent::new(TariffDimensionType::Flat, price.parse().unwrap())
}
#[test]
fn free_of_charge_has_the_exact_shape_the_spec_prescribes() {
let free = tariff(vec![TariffElement::builder().price_components(vec![flat("0.00")]).build()]);
assert!(free.is_free_of_charge());
let with_restriction = tariff(vec![
TariffElement::builder()
.price_components(vec![flat("0.00")])
.restrictions(TariffRestrictions {
max_kwh: Some("10".parse().unwrap()),
..Default::default()
})
.build(),
]);
assert!(!with_restriction.is_free_of_charge(), "a restricted zero price is not free");
assert!(
!tariff(vec![TariffElement::builder().price_components(vec![flat("0.25")]).build()])
.is_free_of_charge()
);
}
#[test]
fn reservation_elements_may_only_price_flat_and_time() {
let bad = tariff(vec![
TariffElement::builder()
.price_components(vec![PriceComponent::new(
TariffDimensionType::Energy,
"0.25".parse().unwrap(),
)])
.restrictions(TariffRestrictions {
reservation: Some(ReservationRestrictionType::Reservation),
..Default::default()
})
.build(),
]);
let err = bad.validate().unwrap_err();
assert_eq!(err.as_slice()[0].pointer, "/elements/0/price_components/0/type");
}
#[test]
fn a_dimension_cannot_be_priced_twice_in_one_element() {
let e = TariffElement::builder().price_components(vec![flat("1"), flat("2")]).build();
assert_eq!(e.validate().unwrap_err().as_slice()[0].code, ViolationCode::Inconsistent);
}
#[test]
fn impossible_restriction_windows_are_reported() {
let r = TariffRestrictions {
min_kwh: Some("20".parse().unwrap()),
max_kwh: Some("10".parse().unwrap()),
..Default::default()
};
assert_eq!(r.validate().unwrap_err().as_slice()[0].pointer, "/max_kwh");
let wrap = TariffRestrictions {
start_time: Some("22:00".parse().unwrap()),
end_time: Some("06:00".parse().unwrap()),
..Default::default()
};
assert!(wrap.validate().is_ok());
}
#[test]
fn step_size_units_follow_the_dimension() {
assert_eq!(TariffDimensionType::Energy.step_size_unit(), Some("Wh"));
assert_eq!(TariffDimensionType::Time.step_size_unit(), Some("s"));
assert_eq!(TariffDimensionType::Flat.step_size_unit(), None);
assert!(PriceComponent { step_size: 0, ..flat("0.00") }.validate().is_ok());
let no_energy = PriceComponent {
step_size: 0,
..PriceComponent::new(TariffDimensionType::Energy, "0.25".parse().unwrap())
};
assert_eq!(no_energy.validate().unwrap_err().as_slice()[0].pointer, "/step_size");
}
#[test]
fn validity_window_is_checked_against_an_instant() {
let mut t = tariff(vec![TariffElement::builder().price_components(vec![flat("1")]).build()]);
t.end_date_time = Some("2019-06-30T00:00:00Z".parse().unwrap());
assert!(t.is_active_at("2019-01-01T00:00:00Z".parse().unwrap()));
assert!(!t.is_active_at("2019-07-01T00:00:00Z".parse().unwrap()));
}
#[test]
fn iso_weekday_numbering_matches_regular_hours() {
assert_eq!(DayOfWeek::Monday.iso_number(), 1);
assert_eq!(DayOfWeek::Sunday.iso_number(), 7);
assert_eq!(DayOfWeek::from_iso_number(3), Some(DayOfWeek::Wednesday));
assert_eq!(DayOfWeek::from_iso_number(0), None);
}
}