ocpi-tariffs 0.51.0

OCPI tariff calculations
Documentation
//! Tests for lowering a `schema::Number` into the decimal-backed money types via
//! `FromSchema`.
//!
//! A `schema::Number` is only produced by the builder, so each test drives a real
//! `v2.1.1` tariff through `build_tariff` and lowers a price component's `price` field
//! (a `Number` leaf). The leaf is just a carrier of numeric content here; its origin
//! field is irrelevant to the lowering.

#![allow(
    clippy::indexing_slicing,
    clippy::unwrap_in_result,
    reason = "unwraps and indexing are allowed anywhere in tests"
)]

use std::assert_matches;

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

use super::{Money, Price, Vat, VatOrigin, Warning};
use crate::{
    json, number,
    schema::{self, v211, v221, Integrity},
    warning, FromSchema as _,
};

/// A minimal, fully valid `v2.1.1` tariff. The price component's `price` is the `Number`
/// leaf the tests lower.
const VALID: &str = r#"{
    "currency": "EUR",
    "elements": [
        {"price_components": [{"price": 0.25, "step_size": 1, "type": "ENERGY"}]}
    ],
    "id": "ID",
    "last_updated": "2024-01-01T00:00:00Z"
}"#;

/// Build a `v2.1.1` tariff, dropping the schema-level warnings.
fn build_tariff<'buf>(doc: &json::Document<'buf>) -> v211::Tariff<'buf> {
    v211::build_tariff(doc).ignore_warnings()
}

/// Borrow the single price component's `price` `Number` leaf out of a tariff.
fn price<'a, 'buf>(tariff: &'a v211::Tariff<'buf>) -> &'a schema::Number<'buf> {
    let Integrity::Ok(elements) = &tariff.elements else {
        panic!("elements should be built: {:?}", tariff.elements);
    };
    let Integrity::Ok(element) = &elements[0] else {
        panic!("the element should be built");
    };
    let Integrity::Ok(components) = &element.price_components else {
        panic!("price_components should be built");
    };
    let Integrity::Ok(component) = &components[0] else {
        panic!("the price component should be built");
    };
    let Integrity::Ok(number) = &component.price else {
        panic!("the price should be built: {:?}", component.price);
    };
    number
}

#[test]
fn money_lowers_from_number() {
    let doc = json::parse(VALID.into()).unwrap();
    let tariff = build_tariff(&doc);

    let (money, warnings) = Money::from_schema(price(&tariff)).unwrap().into_parts();

    assert_eq!(Decimal::from(money), dec!(0.25));
    assert!(warnings.path_map().is_empty());
}

#[test]
fn vat_lowers_from_number() {
    let doc = json::parse(VALID.into()).unwrap();
    let tariff = build_tariff(&doc);

    let (vat, warnings) = Vat::from_schema(price(&tariff)).unwrap().into_parts();

    assert_eq!(Decimal::from(vat), dec!(0.25));
    assert!(warnings.path_map().is_empty());
}

#[test]
fn vat_origin_lowers_from_number() {
    let doc = json::parse(VALID.into()).unwrap();
    let tariff = build_tariff(&doc);

    let (vat_origin, warnings) = VatOrigin::from_schema(price(&tariff)).unwrap().into_parts();

    let VatOrigin::Provided(vat) = vat_origin else {
        panic!("expected Provided, got {vat_origin:?}");
    };
    assert_eq!(Decimal::from(vat), dec!(0.25));
    assert!(warnings.path_map().is_empty());
}

#[test]
fn excessive_precision_warns_but_lowers() {
    let src = VALID.replace(r#""price": 0.25"#, r#""price": 0.12345"#);
    let doc = json::parse(src.as_str().into()).unwrap();
    let tariff = build_tariff(&doc);

    let (money, warnings) = Money::from_schema(price(&tariff)).unwrap().into_parts();

    assert_eq!(Decimal::from(money), dec!(0.12345));
    let warnings: Vec<&number::Warning> = warnings.path_map().into_values().flatten().collect();
    assert_matches!(warnings[..], [number::Warning::ExcessivePrecision]);
}

// -- Price (object-shaped, lowered from `schema::v221::Price`) ----------------

/// A minimal, fully valid `v2.2.1` tariff with a `min_price` object the tests lower.
const VALID_V221: &str = r#"{
    "country_code": "NL",
    "currency": "EUR",
    "elements": [
        {"price_components": [{"price": 0.25, "step_size": 1, "type": "ENERGY"}]}
    ],
    "id": "ID",
    "last_updated": "2024-01-01T00:00:00Z",
    "party_id": "ABC",
    "min_price": {"excl_vat": 1.0, "incl_vat": 1.21}
}"#;

/// Build a `v2.2.1` tariff, dropping the schema-level warnings.
fn build_v221_tariff<'buf>(doc: &json::Document<'buf>) -> v221::Tariff<'buf> {
    v221::build_tariff(doc).ignore_warnings()
}

/// Borrow the built `min_price` `Price` object out of a tariff.
fn min_price<'a, 'buf>(tariff: &'a v221::Tariff<'buf>) -> &'a v221::Price<'buf> {
    let Integrity::Ok(Some(price)) = &tariff.min_price else {
        panic!("min_price should be built: {:?}", tariff.min_price);
    };
    price
}

/// Collect every `money::Warning` across all paths into a flat list.
fn all_warnings(warnings: &warning::Set<Warning>) -> Vec<&Warning> {
    warnings.path_map().into_values().flatten().collect()
}

#[test]
fn price_with_excl_and_incl_lowers() {
    let doc = json::parse(VALID_V221.into()).unwrap();
    let tariff = build_v221_tariff(&doc);

    let (price, warnings) = Price::from_schema(min_price(&tariff)).unwrap().into_parts();

    assert_eq!(Decimal::from(price.excl_vat), dec!(1.0));
    assert_eq!(price.incl_vat.map(Decimal::from), Some(dec!(1.21)));
    assert!(all_warnings(&warnings).is_empty());
}

#[test]
fn price_with_only_excl_has_no_incl_vat() {
    let src = VALID_V221.replace(
        r#""min_price": {"excl_vat": 1.0, "incl_vat": 1.21}"#,
        r#""min_price": {"excl_vat": 1.0}"#,
    );
    let doc = json::parse(src.as_str().into()).unwrap();
    let tariff = build_v221_tariff(&doc);

    let (price, warnings) = Price::from_schema(min_price(&tariff)).unwrap().into_parts();

    assert_eq!(Decimal::from(price.excl_vat), dec!(1.0));
    assert_eq!(price.incl_vat, None);
    assert!(all_warnings(&warnings).is_empty());
}

#[test]
fn excl_greater_than_incl_warns() {
    let src = VALID_V221.replace(
        r#""min_price": {"excl_vat": 1.0, "incl_vat": 1.21}"#,
        r#""min_price": {"excl_vat": 2.0, "incl_vat": 1.0}"#,
    );
    let doc = json::parse(src.as_str().into()).unwrap();
    let tariff = build_v221_tariff(&doc);

    let (_price, warnings) = Price::from_schema(min_price(&tariff)).unwrap().into_parts();

    assert_matches!(
        all_warnings(&warnings)[..],
        [Warning::ExclusiveVatGreaterThanInclusive]
    );
}

#[test]
fn price_from_bare_number_lowers_to_excl_vat_only() {
    // OCPI 2.1.1 wrote a price as a bare number; the schema accepts that shape and puts
    // the number in `excl_vat`, leaving `incl_vat` absent.
    let src = VALID_V221.replace(
        r#""min_price": {"excl_vat": 1.0, "incl_vat": 1.21}"#,
        r#""min_price": 1.5"#,
    );
    let doc = json::parse(src.as_str().into()).unwrap();
    let tariff = build_v221_tariff(&doc);

    let (price, warnings) = Price::from_schema(min_price(&tariff)).unwrap().into_parts();

    assert_eq!(Decimal::from(price.excl_vat), dec!(1.5));
    assert_eq!(price.incl_vat, None);
    assert!(all_warnings(&warnings).is_empty());
}

#[test]
fn missing_excl_vat_is_rejected() {
    let src = VALID_V221.replace(
        r#""min_price": {"excl_vat": 1.0, "incl_vat": 1.21}"#,
        r#""min_price": {"incl_vat": 1.0}"#,
    );
    let doc = json::parse(src.as_str().into()).unwrap();
    let tariff = build_v221_tariff(&doc);

    let err_set = Price::from_schema(min_price(&tariff)).unwrap_err();
    let (error, _warnings) = err_set.into_parts();
    let (error, _element) = error.into_parts();

    assert_matches!(error, Warning::MissingExclVatField);
}