ocpi-tariffs 0.52.0

OCPI tariff calculations
Documentation
//! Various monetary types.

#[cfg(test)]
mod test;

#[cfg(test)]
mod test_from_schema;

#[cfg(test)]
mod test_price;

use std::fmt;

use rust_decimal::Decimal;
use rust_decimal_macros::dec;

use crate::{
    currency, from_warning_all, impl_dec_newtype,
    number::{self, approx_eq_dec, FromDecimal as _, IsZero, RoundDecimal},
    schema,
    warning::{self, GatherWarnings as _, IntoCaveat as _},
    FromSchema, SaturatingAdd as _, Verdict,
};

/// An item that has a cost.
pub trait Cost: Copy {
    /// The cost of this dimension at a certain price.
    fn cost(&self, money: Money) -> Money;
}

impl Cost for () {
    fn cost(&self, money: Money) -> Money {
        money
    }
}

/// The warnings that can happen when parsing or linting a `Price`.
#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
pub enum Warning {
    /// The `excl_vat` field is greater than the `incl_vat` field.
    ExclusiveVatGreaterThanInclusive,

    /// Both the `excl_vat` and `incl_vat` fields should be valid numbers.
    Number(number::Warning),

    /// A feature rejected the schema IR for a `Price` because a required field was missing or
    /// invalid. The located cause is reported by the schema validation warnings; this is a
    /// content-free marker (see [`warning::Rejected`]).
    Rejected,
}

impl fmt::Display for Warning {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::ExclusiveVatGreaterThanInclusive => write!(
                f,
                "The `excl_vat` field is greater than the `incl_vat` field"
            ),
            Self::Number(kind) => fmt::Display::fmt(kind, f),
            Self::Rejected => f.write_str(
                "The schema IR for a `Price` was rejected; see the schema validation warnings.",
            ),
        }
    }
}

impl crate::Warning for Warning {
    fn id(&self) -> warning::Id {
        match self {
            Self::ExclusiveVatGreaterThanInclusive => {
                warning::Id::from_static("exclusive_vat_greater_than_inclusive")
            }
            Self::Number(kind) => kind.id(),
            Self::Rejected => warning::Id::from_static("rejected"),
        }
    }

    fn is_rejected(&self) -> bool {
        matches!(self, Self::Rejected)
    }
}

impl From<warning::Rejected> for Warning {
    fn from(_: warning::Rejected) -> Self {
        Self::Rejected
    }
}

from_warning_all!(number::Warning => Warning::Number);

/// A price consisting of a value including VAT, and a value excluding VAT.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd)]
#[cfg_attr(test, derive(serde::Deserialize))]
pub struct Price {
    /// The price excluding VAT.
    pub excl_vat: Money,

    /// The price including VAT.
    ///
    /// If no vat is applicable this value will be equal to the `excl_vat`.
    ///
    /// If no vat could be determined this value will be `None`.
    /// The v211 tariffs can't determine VAT.
    #[cfg_attr(test, serde(default))]
    pub incl_vat: Option<Money>,
}

impl RoundDecimal for Price {
    fn round_to_ocpi_scale(self) -> Self {
        let Self { excl_vat, incl_vat } = self;
        Self {
            excl_vat: excl_vat.round_to_ocpi_scale(),
            incl_vat: incl_vat.round_to_ocpi_scale(),
        }
    }
}

impl fmt::Display for Price {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if let Some(incl_vat) = self.incl_vat {
            if f.alternate() {
                write!(f, "{{ -vat: {:#}, +vat: {:#} }}", self.excl_vat, incl_vat)
            } else {
                write!(f, "{{ -vat: {}, +vat: {} }}", self.excl_vat, incl_vat)
            }
        } else {
            fmt::Display::fmt(&self.excl_vat, f)
        }
    }
}

impl<'buf> FromSchema<'buf, schema::v221::Price<'buf>> for Price {
    type Warning = Warning;

    fn from_schema(source: &schema::v221::Price<'buf>) -> Verdict<Self, Self::Warning> {
        let mut warnings = warning::Set::new();

        // A bare-number price (the 2.1.1 shape) is just `excl_vat`: there is no `incl_vat`
        // and thus no excl-vs-incl comparison to make.
        let (elem, excl_vat, incl_vat) = match source {
            schema::v221::Price::Number(number) => {
                let excl_vat = Money::from_schema(number)?.gather_warnings_into(&mut warnings);
                let price = Self {
                    excl_vat,
                    incl_vat: None,
                };
                return Ok(price.into_caveat(warnings));
            }
            schema::v221::Price::Object {
                elem,
                excl_vat,
                incl_vat,
            } => (elem, excl_vat, incl_vat),
        };

        // `excl_vat` is required; without a usable value a `Price` cannot be built. A
        // missing or wrong-kind field is already reported structurally by the schema
        // walk, so the bail here is the content-free `Rejected` signal.
        let excl_vat = warnings.ok_or_bail(excl_vat)?;
        let excl_vat = Money::from_schema(excl_vat)?.gather_warnings_into(&mut warnings);

        // `incl_vat` is optional. Absent or `null` leaves the price without VAT info; a
        // wrong-kind value (flagged structurally by the schema) is likewise treated as no
        // VAT rather than failing the whole price.
        let incl_vat = incl_vat
            .map_some(Money::from_schema)
            .transpose()?
            .gather_warnings_into(&mut warnings);

        if let Some(incl_vat) = incl_vat {
            if excl_vat > incl_vat {
                warnings.insert(elem, Warning::ExclusiveVatGreaterThanInclusive);
            }
        }

        Ok(Self { excl_vat, incl_vat }.into_caveat(warnings))
    }
}

impl IsZero for Price {
    fn is_zero(&self) -> bool {
        self.excl_vat.is_zero() && self.incl_vat.is_none_or(|v| v.is_zero())
    }
}

impl Price {
    pub fn zero() -> Self {
        Self {
            excl_vat: Money::zero(),
            incl_vat: Some(Money::zero()),
        }
    }

    /// Round this number to the OCPI specified amount of decimals.
    #[must_use]
    pub fn rescale(self) -> Self {
        Self {
            excl_vat: self.excl_vat.rescale(),
            incl_vat: self.incl_vat.map(Money::rescale),
        }
    }

    /// Saturating addition.
    #[must_use]
    pub(crate) fn saturating_add(self, rhs: Self) -> Self {
        let incl_vat = self
            .incl_vat
            .zip(rhs.incl_vat)
            .map(|(lhs, rhs)| lhs.saturating_add(rhs));

        Self {
            excl_vat: self.excl_vat.saturating_add(rhs.excl_vat),
            incl_vat,
        }
    }

    #[must_use]
    pub fn round_dp(self, digits: u32) -> Self {
        Self {
            excl_vat: self.excl_vat.round_dp(digits),
            incl_vat: self.incl_vat.map(|v| v.round_dp(digits)),
        }
    }

    /// Display a Price with the given currency.
    pub fn display_currency(&self, currency: currency::Code) -> DisplayPriceCurrency<'_> {
        DisplayPriceCurrency {
            currency,
            price: self,
        }
    }
}

/// A Display object for displaying a `Price` with an associated currency.
///
/// Note: The placement of the currency symbol is always before the amount.
/// The locale is not used to determine symbol position.
pub struct DisplayPriceCurrency<'a> {
    currency: currency::Code,
    price: &'a Price,
}

impl fmt::Display for DisplayPriceCurrency<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if let Some(incl_vat) = self.price.incl_vat {
            write!(
                f,
                "{{ -vat: {:#}, +vat: {:#} }}",
                self.price.excl_vat, incl_vat
            )
        } else {
            fmt::Display::fmt(&self.price.excl_vat.display_currency(self.currency), f)
        }
    }
}

impl Default for Price {
    fn default() -> Self {
        Self::zero()
    }
}

/// A monetary amount, the currency is dependent on the specified tariff.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Ord, PartialOrd)]
#[cfg_attr(test, derive(serde::Deserialize))]
pub struct Money(Decimal);

impl_dec_newtype!(Money, "ยค");

impl IsZero for Money {
    fn is_zero(&self) -> bool {
        const TOLERANCE: Decimal = dec!(0.01);

        approx_eq_dec(&self.0, &Decimal::ZERO, TOLERANCE)
    }
}

impl Money {
    #[must_use]
    pub(crate) const fn zero() -> Self {
        Self(Decimal::ZERO)
    }

    /// Apply a VAT percentage to this monetary amount.
    #[must_use]
    pub fn apply_vat(self, vat: Vat) -> Self {
        const ONE: Decimal = dec!(1);

        let x = vat.as_unit_interval().saturating_add(ONE);
        Self(self.0.saturating_mul(x))
    }

    /// Display Money with the given currency.
    pub fn display_currency(&self, currency: currency::Code) -> DisplayCurrency<'_> {
        DisplayCurrency {
            currency,
            money: self,
        }
    }
}

/// A Display object for displaying `Money` with an associated currency.
///
/// Note: The placement of the currency symbol is always before the amount.
/// The locale is not used to determine symbol position.
pub struct DisplayCurrency<'a> {
    currency: currency::Code,
    money: &'a Money,
}

impl fmt::Display for DisplayCurrency<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}{:#}", self.currency.into_symbol(), self.money)
    }
}

/// A VAT percentage.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub struct Vat(Decimal);

impl_dec_newtype!(Vat, "%");

impl Vat {
    #[expect(clippy::missing_panics_doc, reason = "The divisor is non-zero")]
    pub fn as_unit_interval(self) -> Decimal {
        const PERCENT: Decimal = dec!(100);

        self.0.checked_div(PERCENT).expect("divisor is non-zero")
    }
}

/// The origin of a potential VAT percentage.
#[derive(Clone, Copy, Debug)]
pub(crate) enum VatOrigin {
    /// The VAT percentage is unknown as the tariff is v211 and has no `vat` field.
    ///
    /// NOTE: All `incl_vat` fields should be `None` in the final calculation.
    Unknown,

    /// The tariff could have a `vat` field but a value is not provided.
    ///
    /// NOTE: The total `incl_vat` should be equal to `excl_vat`.
    NotProvided,

    /// The tariff could have a `vat` field and a value is provided.
    Provided(Vat),
}

impl<'buf> FromSchema<'buf, schema::Number<'buf>> for VatOrigin {
    type Warning = number::Warning;

    fn from_schema(source: &schema::Number<'buf>) -> Verdict<Self, Self::Warning> {
        let vat = Decimal::from_schema(source)?;
        Ok(vat.map(|d| Self::Provided(Vat::from_decimal(d))))
    }
}