ocpi-tariffs 0.49.0

OCPI tariff calculations
Documentation
#![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
#![allow(clippy::panic, reason = "tests are allowed to panic")]

use rust_decimal_macros::dec;

use crate::{
    cdr, json, price,
    price::test::UnwrapReport as _,
    tariff,
    test::{self},
    Version,
};

fn simple_cdr_json(
    energy_kwh: f64,
    total_cost_excl: f64,
    total_cost_incl: f64,
    total_energy_cost_excl: f64,
    total_energy_cost_incl: f64,
) -> String {
    serde_json::json!({
        "country_code": "NL",
        "party_id": "TDR",
        "start_date_time": "2022-01-13T10:00:00Z",
        "end_date_time": "2022-01-13T11:00:00Z",
        "currency": "EUR",
        "tariffs": [],
        "cdr_location": { "country": "NLD" },
        "charging_periods": [
            {
                "start_date_time": "2022-01-13T10:00:00Z",
                "dimensions": [
                    { "type": "ENERGY", "volume": energy_kwh }
                ]
            }
        ],
        "total_cost": { "excl_vat": total_cost_excl, "incl_vat": total_cost_incl },
        "total_energy_cost": {
            "excl_vat": total_energy_cost_excl,
            "incl_vat": total_energy_cost_incl
        },
        "total_time": 1.0,
        "total_energy": energy_kwh,
        "last_updated": "2022-01-13T00:00:00Z"
    })
    .to_string()
}

// Tariff: 0.50 EUR/kWh excl. VAT, 10% VAT, min_price = 5.00 / 5.50
fn tariff_with_min_price_json() -> String {
    serde_json::json!({
        "id": "T1",
        "country_code": "NL",
        "party_id": "TDR",
        "currency": "EUR",
        "min_price": { "excl_vat": 5.00, "incl_vat": 5.50 },
        "elements": [
            {
                "price_components": [
                    { "type": "ENERGY", "price": 0.50, "vat": 10.0, "step_size": 1 }
                ]
            }
        ],
        "last_updated": "2022-01-01T00:00:00Z"
    })
    .to_string()
}

// Tariff: 0.50 EUR/kWh excl. VAT, 10% VAT, max_price = 3.00 / 3.30
fn tariff_with_max_price_json() -> String {
    serde_json::json!({
        "id": "T2",
        "country_code": "NL",
        "party_id": "TDR",
        "currency": "EUR",
        "max_price": { "excl_vat": 3.00, "incl_vat": 3.30 },
        "elements": [
            {
                "price_components": [
                    { "type": "ENERGY", "price": 0.50, "vat": 10.0, "step_size": 1 }
                ]
            }
        ],
        "last_updated": "2022-01-01T00:00:00Z"
    })
    .to_string()
}

#[test]
fn should_clamp_total_cost_to_min_price() {
    test::setup();

    // 4 kWh * 0.50 = 2.00 excl., 2.20 incl. -- below min of 5.00 / 5.50
    let cdr_json = simple_cdr_json(4.0, 2.00, 2.20, 2.00, 2.20);
    let tariff_json = tariff_with_min_price_json();

    let (cdr, _) = cdr::build(json::parse_object(&cdr_json).unwrap(), Version::V221).into_parts();
    let (tariff, _) =
        tariff::build(json::parse_object(&tariff_json).unwrap(), Version::V221).into_parts();

    let report = cdr::price(
        &cdr,
        price::TariffSource::Override(vec![tariff]),
        chrono_tz::Tz::UTC,
    )
    .unwrap_report(cdr.as_json_str());

    let (report, warnings) = report.into_parts();

    let total_cost = report.total_cost.calculated.unwrap();
    assert_eq!(total_cost.excl_vat, dec!(5.00).into());
    assert_eq!(total_cost.incl_vat, Some(dec!(5.50).into()));

    let id_map = warnings.path_id_map();
    let has_warning = id_map
        .values()
        .flatten()
        .any(|id| id.as_str() == "total_cost_clamped_to_min");
    assert!(
        has_warning,
        "expected TotalCostClampedToMin warning, got: {id_map:#?}"
    );
}

#[test]
fn should_clamp_total_cost_to_max_price() {
    test::setup();

    // 10 kWh * 0.50 = 5.00 excl., 5.50 incl. -- above max of 3.00 / 3.30
    let cdr_json = simple_cdr_json(10.0, 5.00, 5.50, 5.00, 5.50);
    let tariff_json = tariff_with_max_price_json();

    let (cdr, _) = cdr::build(json::parse_object(&cdr_json).unwrap(), Version::V221).into_parts();
    let (tariff, _) =
        tariff::build(json::parse_object(&tariff_json).unwrap(), Version::V221).into_parts();

    let report = cdr::price(
        &cdr,
        price::TariffSource::Override(vec![tariff]),
        chrono_tz::Tz::UTC,
    )
    .unwrap_report(cdr.as_json_str());

    let (report, warnings) = report.into_parts();

    let total_cost = report.total_cost.calculated.unwrap();
    assert_eq!(total_cost.excl_vat, dec!(3.00).into());
    assert_eq!(total_cost.incl_vat, Some(dec!(3.30).into()));

    let id_map = warnings.path_id_map();
    let has_warning = id_map
        .values()
        .flatten()
        .any(|id| id.as_str() == "total_cost_clamped_to_max");
    assert!(
        has_warning,
        "expected TotalCostClampedToMax warning, got: {id_map:#?}"
    );
}

#[test]
fn should_not_clamp_when_cost_within_bounds() {
    test::setup();

    // 8 kWh * 0.50 = 4.00 excl., 4.40 incl. -- between min 3.00 and max 6.00, no clamping
    let cdr_json = simple_cdr_json(8.0, 4.00, 4.40, 4.00, 4.40);
    let tariff_json = serde_json::json!({
        "id": "T3",
        "country_code": "NL",
        "party_id": "TDR",
        "currency": "EUR",
        "min_price": { "excl_vat": 3.00, "incl_vat": 3.30 },
        "max_price": { "excl_vat": 6.00, "incl_vat": 6.60 },
        "elements": [
            {
                "price_components": [
                    { "type": "ENERGY", "price": 0.50, "vat": 10.0, "step_size": 1 }
                ]
            }
        ],
        "last_updated": "2022-01-01T00:00:00Z"
    })
    .to_string();

    let (cdr, _) = cdr::build(json::parse_object(&cdr_json).unwrap(), Version::V221).into_parts();
    let (tariff, _) =
        tariff::build(json::parse_object(&tariff_json).unwrap(), Version::V221).into_parts();

    let report = cdr::price(
        &cdr,
        price::TariffSource::Override(vec![tariff]),
        chrono_tz::Tz::UTC,
    )
    .unwrap_report(cdr.as_json_str());

    let (report, warnings) = report.into_parts();

    let total_cost = report.total_cost.calculated.unwrap();
    assert_eq!(total_cost.excl_vat, dec!(4.00).into());
    assert_eq!(total_cost.incl_vat, Some(dec!(4.40).into()));

    let id_map = warnings.path_id_map();
    let has_clamp_warning = id_map.values().flatten().any(|id| {
        id.as_str() == "total_cost_clamped_to_min" || id.as_str() == "total_cost_clamped_to_max"
    });
    assert!(
        !has_clamp_warning,
        "unexpected clamping warning: {id_map:#?}"
    );
}