ocpi-tariffs 0.45.0

OCPI tariff calculations
Documentation
#[cfg(test)]
mod test_null_fields;

use chrono::{Duration, NaiveDate, NaiveTime, TimeDelta};
use rust_decimal::Decimal;

use crate::{
    currency,
    duration::Seconds,
    energy::{Kw, Kwh},
    expect_array_or_bail, expect_object_or_bail,
    json::{self, FieldsAsExt},
    number::FromDecimal,
    parse_nullable_or_bail, required_field_or_bail, string,
    tariff::v2x,
    warning::{self, GatherWarnings as _, IntoCaveat},
    Enum, Money, VatApplicable, Verdict, Weekday,
};

use super::{v221, Warning};

/// A tariff description used to generate a CDR.
///
/// See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>
///
/// We don't parse the `type`, `tariff_alt_text`, `tariff_alt_url`, `energy_mix` or `last_updated` fields
/// as they do not affect the generation of the CDR.
#[derive(Debug)]
pub(crate) struct Tariff<'buf> {
    /// Uniquely identifies the tariff within the CPO's platform (and sub-operator platforms).
    pub id: string::CiMaxLen<'buf, 36>,

    /// ISO-4217 code of the currency of this tariff.
    pub currency: currency::Code,

    /// List of at least one Element.
    pub elements: Vec<Element>,
}

#[cfg(test)]
impl crate::test::VersionedType for Tariff<'_> {
    const VERSION: crate::Version = crate::Version::V211;
}

/// A Tariff Element is a group of Price Components that share a set of restrictions under which they apply.
///
/// See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#144-tariffelement-class>
#[derive(Debug)]
pub(crate) struct Element {
    /// List of Price Components that each describe how a certain dimension is priced.
    pub price_components: Vec<PriceComponent>,

    /// Restrictions that describe under which circumstances the Price Components of this Tariff Element apply.
    pub restrictions: Option<Restrictions>,
}

/// List of price components that make up the pricing of this tariff.
///
/// See: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#42-pricecomponent-class>
#[derive(Debug)]
pub(crate) struct PriceComponent {
    /// The dimension that is being priced.
    pub dimension_type: v2x::DimensionType,

    /// Price per unit (excl. VAT) for this dimension.
    pub price: Money,

    /// Minimum amount to be billed. That is, the dimension will be billed in this `step_size` blocks.
    /// Consumed amounts are rounded up to the smallest multiple of `step_size` that is greater than the consumed amount.
    pub step_size: u64,
}

/// A `TariffRestrictions` object describes if and when a Tariff Element becomes active or inactive during a Charging Session.
/// These restrictions are not to be interpreted as making the Tariff Element applicable or not applicable for the entire Charging Session.
///
/// When more than one restriction is set, they are to be treated as a logical AND.
/// So a Tariff Element is active if and only if all the properties in its `TariffRestrictions` match.
///
/// See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#mod_tariffs_tariffrestrictions_class>
///
/// We do not parse the `reservation` field as it has no effect on the generated CDR.
#[derive(Debug)]
pub(crate) struct Restrictions {
    /// The `Element` is valid from this time of day in local time.
    ///
    /// The time zone is defined in the `time_zone` field of the Location.
    start_time: Option<NaiveTime>,

    /// The `Element` is valid until this time of day in local time.
    ///
    /// The time zone is defined in the `time_zone` field of the Location.
    end_time: Option<NaiveTime>,

    /// The `Element` is valid from this date (inclusive) in local time.
    ///
    /// The time zone is defined in the `time_zone` field of the Location.
    start_date: Option<NaiveDate>,

    /// The `Element` is valid until this date (exclusive) in local time.
    ///
    /// The time zone is defined in the `time_zone` field of the Location.
    end_date: Option<NaiveDate>,

    /// Minimum consumed energy in kWh, for example 20, valid from this amount of energy (inclusive) being used.
    min_kwh: Option<Kwh>,

    /// Maximum consumed energy in kWh, for example 50, valid until this amount of energy (exclusive) being used.
    max_kwh: Option<Kwh>,

    /// If the charging power is equal to or lower than this value, the associated `TariffElement` becomes inactive.
    min_power: Option<Kw>,

    /// If the charging power is equal to or higher than this value, the associated `TariffElement` becomes inactive.
    max_power: Option<Kw>,

    /// Minimum duration in seconds the Charging Session MUST last (inclusive).
    ///
    /// When the duration of a Charging Session is longer than the defined value, this `TariffElement` is or becomes active.
    /// Before that moment, this `TariffElement` is not yet active.
    min_duration: Option<Duration>,

    /// Maximum duration in seconds the Charging Session MUST last (exclusive).
    ///
    /// When the duration of a Charging Session is shorter than the defined value, this `TariffElement` is or becomes active.
    /// After that moment, this `TariffElement` is no longer active.
    max_duration: Option<Duration>,

    /// Which day(s) of the week this `TariffElement` is active.
    day_of_week: Option<Vec<Weekday>>,
}

impl<'a> From<Tariff<'a>> for v221::Tariff<'a> {
    fn from(tariff: Tariff<'a>) -> Self {
        let Tariff {
            id,
            currency,
            elements,
        } = tariff;

        let elements = elements.into_iter().map(v221::Element::from).collect();
        v221::Tariff {
            party_id: None,
            id,
            currency,
            min_price: None,
            max_price: None,
            start_date_time: None,
            end_date_time: None,
            elements,
        }
    }
}

impl From<Element> for v221::Element {
    fn from(element: Element) -> Self {
        let Element {
            price_components,
            restrictions,
        } = element;

        v221::Element {
            price_components: price_components
                .into_iter()
                .map(v221::PriceComponent::from)
                .collect(),
            restrictions: restrictions.map(v221::Restrictions::from),
        }
    }
}

impl From<PriceComponent> for v221::PriceComponent {
    fn from(value: PriceComponent) -> Self {
        let PriceComponent {
            dimension_type,
            price,
            step_size,
        } = value;
        v221::PriceComponent {
            dimension_type,
            vat: VatApplicable::Unknown,
            price,
            step_size,
        }
    }
}

impl From<Restrictions> for v221::Restrictions {
    fn from(restrictions: Restrictions) -> Self {
        let Restrictions {
            start_time,
            end_time,
            start_date,
            end_date,
            min_kwh,
            max_kwh,
            min_power,
            max_power,
            min_duration,
            max_duration,
            day_of_week,
        } = restrictions;

        v221::Restrictions {
            start_time,
            end_time,
            start_date,
            end_date,
            min_kwh,
            max_kwh,
            min_current: None,
            max_current: None,
            min_power,
            max_power,
            min_duration,
            max_duration,
            day_of_week,
        }
    }
}

impl<'buf> json::FromJson<'buf> for Tariff<'buf> {
    type Warning = Warning;

    fn from_json(elem: &json::Element<'buf>) -> Verdict<Self, Self::Warning> {
        let mut warnings = warning::Set::<Warning>::new();

        // The Tariff should be a JSON object
        let fields = expect_object_or_bail!(elem, warnings);
        let fields = fields.as_raw_map();

        // Get refs to the required and optional fields.
        let currency_elem = required_field_or_bail!(elem, fields, "currency", warnings);
        let elements_elem = required_field_or_bail!(elem, fields, "elements", warnings);
        let id_elem = required_field_or_bail!(elem, fields, "id", warnings);

        let currency_code =
            currency::Code::from_json(currency_elem)?.gather_warnings_into(&mut warnings);

        let id = string::CiMaxLen::from_json(id_elem)?.gather_warnings_into(&mut warnings);

        let elements = expect_array_or_bail!(elements_elem, warnings)
            .iter()
            .map(Element::from_json)
            .collect::<Result<Vec<_>, _>>()?;

        let elements = elements.gather_warnings_into(&mut warnings);

        if elements.is_empty() {
            return warnings.bail(Warning::NoElements, elements_elem);
        }

        let tariff = Tariff {
            currency: currency_code,
            id,
            elements,
        };

        Ok(tariff.into_caveat(warnings))
    }
}

impl json::FromJson<'_> for Element {
    type Warning = Warning;

    fn from_json(elem: &'_ json::Element<'_>) -> Verdict<Self, Self::Warning> {
        let mut warnings = warning::Set::<Warning>::new();
        let fields = expect_object_or_bail!(elem, warnings);
        let fields = fields.as_raw_map();

        let price_components_elem =
            required_field_or_bail!(elem, fields, "price_components", warnings);
        let restrictions_elem = fields.get("restrictions");

        // If the `DimensionType` of a `PriceComponent` is unknown the entire `PriceComponent` will be returned as `None`.
        // There can still be Warnings generated from the `Ok` and `Err` paths from the `from_json` call.
        // Warnings in the `Err` path will cause an early return.
        let price_components = expect_array_or_bail!(price_components_elem, warnings)
            .iter()
            .map(Option::<PriceComponent>::from_json)
            .collect::<Result<Vec<_>, _>>()?;

        // This collection has all Results resolved but still might contain Dimensions with
        // unknown `DimensionType`s. We gather up these Warnings and flatten the `None` Dimensions.
        // This leaves us with a collection of `Dimensions` where the `DimensionType` is known.
        let price_components = price_components
            .gather_warnings_into(&mut warnings)
            .into_iter()
            .flatten()
            .collect();

        // The `restrictions` field is optional. If the field is null we treat it like it isn't defined.
        let restrictions = if let Some(elem) = restrictions_elem.filter(|e| !e.is_null()) {
            Some(Restrictions::from_json(elem)?.gather_warnings_into(&mut warnings))
        } else {
            None
        };

        let elem = Element {
            price_components,
            restrictions,
        };

        Ok(elem.into_caveat(warnings))
    }
}

impl json::FromJson<'_> for Option<PriceComponent> {
    type Warning = Warning;

    fn from_json(elem: &'_ json::Element<'_>) -> Verdict<Self, Self::Warning> {
        let mut warnings = warning::Set::<Warning>::new();
        let fields = expect_object_or_bail!(elem, warnings);
        let fields = fields.as_raw_map();

        let type_elem = required_field_or_bail!(elem, fields, "type", warnings);
        let price_elem = required_field_or_bail!(elem, fields, "price", warnings);
        let step_size_elem = required_field_or_bail!(elem, fields, "step_size", warnings);

        let dimension_type =
            Enum::<v2x::DimensionType>::from_json(type_elem)?.gather_warnings_into(&mut warnings);

        let dimension_type = match dimension_type {
            Enum::Known(v) => v,
            Enum::Unknown(s) => {
                warnings.insert(
                    Warning::field_invalid_value(s, "A tariff DimensionType should be one of `ENERGY`, `FLAT`, `PACKING_TIME` or `TIME`"),
                    elem
                );
                return Ok(None.into_caveat(warnings));
            }
        };

        let price = Decimal::from_json(price_elem)?.gather_warnings_into(&mut warnings);
        let step_size = u64::from_json(step_size_elem)?.gather_warnings_into(&mut warnings);

        let comp = PriceComponent {
            dimension_type,
            price: Money::from_decimal(price),
            step_size,
        };

        Ok(Some(comp).into_caveat(warnings))
    }
}

impl json::FromJson<'_> for Restrictions {
    type Warning = Warning;

    fn from_json(elem: &'_ json::Element<'_>) -> Verdict<Self, Self::Warning> {
        let mut warnings = warning::Set::<Warning>::new();
        let fields = expect_object_or_bail!(elem, warnings);
        let fields = fields.as_raw_map();

        let start_time = parse_nullable_or_bail!(fields, "start_time", NaiveTime, warnings);
        let end_time = parse_nullable_or_bail!(fields, "end_time", NaiveTime, warnings);
        let start_date = parse_nullable_or_bail!(fields, "start_date", NaiveDate, warnings);
        let end_date = parse_nullable_or_bail!(fields, "end_date", NaiveDate, warnings);
        let min_kwh = parse_nullable_or_bail!(fields, "min_kwh", Kwh, warnings);
        let max_kwh = parse_nullable_or_bail!(fields, "max_kwh", Kwh, warnings);
        let min_power = parse_nullable_or_bail!(fields, "min_power", Kw, warnings);
        let max_power = parse_nullable_or_bail!(fields, "max_power", Kw, warnings);
        let min_duration = parse_nullable_or_bail!(fields, "min_duration", Seconds, warnings);
        let max_duration = parse_nullable_or_bail!(fields, "max_duration", Seconds, warnings);

        let day_of_week_elem = fields.get("day_of_week");
        let day_of_week = if let Some(elem) = day_of_week_elem {
            let list = expect_array_or_bail!(elem, warnings)
                .iter()
                .map(Weekday::from_json)
                .collect::<Result<Vec<_>, _>>()?;

            Some(list.gather_warnings_into(&mut warnings))
        } else {
            None
        };

        let res = Restrictions {
            start_time,
            end_time,
            start_date,
            end_date,
            min_kwh,
            max_kwh,
            min_power,
            max_power,
            min_duration: min_duration.map(TimeDelta::from),
            max_duration: max_duration.map(TimeDelta::from),
            day_of_week,
        };

        Ok(res.into_caveat(warnings))
    }
}