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,
};

// CDR: 10 kWh, 1 hour, cost filled in by caller.
fn simple_cdr_json(total_cost_excl: 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": 10.0 }
                ]
            }
        ],
        "total_cost": { "excl_vat": total_cost_excl },
        "total_time": 1.0,
        "total_energy": 10.0,
        "last_updated": "2022-01-13T00:00:00Z"
    })
    .to_string()
}

// Tariff with two elements:
// - element 0: 1.00 EUR/kWh, restricted to RESERVATION sessions only
// - element 1: 0.30 EUR/kWh, no restriction (fallback)
fn tariff_with_reservation_element_json(reservation_type: &str) -> String {
    serde_json::json!({
        "id": "T1",
        "country_code": "NL",
        "party_id": "TDR",
        "currency": "EUR",
        "elements": [
            {
                "price_components": [
                    { "type": "ENERGY", "price": 1.00, "vat": 0.0, "step_size": 1 }
                ],
                "restrictions": {
                    "reservation": reservation_type
                }
            },
            {
                "price_components": [
                    { "type": "ENERGY", "price": 0.30, "vat": 0.0, "step_size": 1 }
                ]
            }
        ],
        "last_updated": "2022-01-01T00:00:00Z"
    })
    .to_string()
}

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

    // 10 kWh * 0.30 = 3.00 -- reservation element (1.00/kWh) must be skipped
    let cdr_json = simple_cdr_json(3.0);
    let tariff_json = tariff_with_reservation_element_json("RESERVATION");

    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(),
        "reservation element must not apply; fallback (0.30/kWh) should be used"
    );

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

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

    // 10 kWh * 0.30 = 3.00 -- RESERVATION_EXPIRES element (1.00/kWh) must be skipped
    let cdr_json = simple_cdr_json(3.0);
    let tariff_json = tariff_with_reservation_element_json("RESERVATION_EXPIRES");

    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(),
        "reservation_expires element must not apply; fallback (0.30/kWh) should be used"
    );

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

// Tariff with a single unrestricted element at 0.30 EUR/kWh.
fn tariff_without_reservation_element_json() -> String {
    serde_json::json!({
        "id": "T2",
        "country_code": "NL",
        "party_id": "TDR",
        "currency": "EUR",
        "elements": [
            {
                "price_components": [
                    { "type": "ENERGY", "price": 0.30, "vat": 0.0, "step_size": 1 }
                ]
            }
        ],
        "last_updated": "2022-01-01T00:00:00Z"
    })
    .to_string()
}

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

    // 10 kWh * 0.30 = 3.00 -- no reservation element, no warning
    let cdr_json = simple_cdr_json(3.0);
    // Tariff with a single unrestricted element at 0.30 EUR/kWh.
    let tariff_json = tariff_without_reservation_element_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 id_map = warnings.path_id_map();
    let has_warning = id_map
        .values()
        .flatten()
        .any(|id| id.as_str() == "reservation_element_skipped");
    assert!(
        !has_warning,
        "unexpected reservation_element_skipped warning: {id_map:#?}"
    );
}