ocpi-tariffs 0.44.0

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

#[cfg(test)]
mod test_null_fields;

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

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

use super::{v2x, CpoId, 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>
///
/// Note: The `country_code` and `party_id` fields are merged for simplicity as the two fields
/// represent a single CPO ID. Representing them as independent `Option` fields means we have to
/// handle the impossible case of `(Some, None)` and `(None, Some)` when a `v211` tariff is converted
/// to this `v221` tariff.
///
/// Note: 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> {
    /// The five character ID of the CPO that 'owns' this tariff.
    ///
    /// The first two characters are the ISO-3166 alpha-2 country code of the CPO.
    /// The remaining three characters are the ISO-15118 ID of the CPO.
    ///
    /// Note: `ocpi-tariffs` considers the `v221` tariff to be the "normalized" version.
    /// A `v211` tariff is converted to a `v221` tariff for various operations.
    /// The `v211` version does not contain `country_code` or `party_id` fields.
    /// When the `v211` version is converted into the "normalized" `v221` version
    /// the `party_id` field will by `None`.
    pub party_id: Option<CpoId<'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,

    /// When this field is set, a Charging Session with this tariff will at least cost this amount.
    ///
    /// The generation of the CDR does not take VAT into account.
    pub min_price: Option<Price>,

    /// When this field is set, a Charging Session with this tariff will NOT cost more than this amount.
    ///
    /// The generation of the CDR does not take VAT into account.
    pub max_price: Option<Price>,

    /// The time when this tariff becomes active, in UTC, `time_zone` field of the Location can be used to convert to local time.
    /// Typically used for a new tariff that is already given with the location, before it becomes active.
    pub start_date_time: Option<DateTime<Utc>>,

    /// The time after which this tariff is no longer valid, in UTC, `time_zone` field if the Location can be used to convert to local time.
    /// Typically used when this tariff is going to be replaced with a different tariff in the near future.
    pub end_date_time: Option<DateTime<Utc>>,

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

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

/// 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.2.1-bugfixes/mod_tariffs.asciidoc#mod_tariffs_pricecomponent_class>
#[derive(Debug)]
pub(crate) struct PriceComponent {
    /// The dimension that is being priced.
    pub dimension_type: v2x::DimensionType,

    /// Applicable VAT percentage for this tariff dimension. If omitted, no VAT is applicable.
    /// Not providing a VAT is different from 0% VAT, which would be a value of 0.0 here.
    pub vat: VatApplicable,

    /// 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.
    pub 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.
    pub 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.
    pub 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.
    pub end_date: Option<NaiveDate>,

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

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

    /// If the charging current is equal to or lower than this value, the associated `TariffElement` becomes inactive.
    pub min_current: Option<Ampere>,

    /// If the charging current is equal to or higher than this value, the associated `TariffElement` becomes inactive.
    pub max_current: Option<Ampere>,

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

    /// If the charging power is equal to or higher than this value, the associated `TariffElement` becomes inactive.
    pub 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.
    pub min_duration: Option<TimeDelta>,

    /// 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.
    pub max_duration: Option<TimeDelta>,

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

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();

        let code_set =
            parse_required_or_bail!(elem, fields, "country_code", country::CodeSet, warnings);
        let currency_code =
            parse_required_or_bail!(elem, fields, "currency", currency::Code, warnings);
        let elements_elem = required_field_or_bail!(elem, fields, "elements", warnings);
        let id = parse_required_or_bail!(elem, fields, "id", string::CiMaxLen::<'_, 36>, warnings);
        let party_id = parse_required_or_bail!(
            elem,
            fields,
            "party_id",
            string::CiExactLen::<'_, 3>,
            warnings
        );
        let min_price = parse_nullable_or_bail!(fields, "min_price", Price, warnings);
        let max_price = parse_nullable_or_bail!(fields, "max_price", Price, warnings);
        let start_date_time =
            parse_nullable_or_bail!(fields, "start_date_time", DateTime<Utc>, warnings);
        let end_date_time =
            parse_nullable_or_bail!(fields, "end_date_time", DateTime<Utc>, warnings);

        // We don't care if the country code is spec compliant, we just want the data.
        let country_code = match code_set {
            country::CodeSet::Alpha2(code) | country::CodeSet::Alpha3(code) => code,
        };

        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,
            // Combine the `country_code` and `party_id` into a single CPO ID.
            party_id: Some(CpoId {
                country_code,
                id: party_id,
            }),
            id,
            min_price,
            max_price,
            start_date_time,
            end_date_time,
            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();

        let restrictions = if let Some(elem) = restrictions_elem {
            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 vat_elem = fields.get("vat");
        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 vat = vat_elem
            .map(|e| VatApplicable::from_json(e))
            .transpose()?
            .gather_warnings_into(&mut warnings)
            .unwrap_or(VatApplicable::Inapplicable);

        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,
            vat,
            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_current = parse_nullable_or_bail!(fields, "min_current", Ampere, warnings);
        let max_current = parse_nullable_or_bail!(fields, "max_current", Ampere, 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_current,
            max_current,
            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))
    }
}