ocpi-tariffs 0.49.0

OCPI tariff calculations
Documentation
use std::assert_matches;

use super::test;
use crate::{
    generate::{self},
    json, tariff,
    warning::test::VerdictTestExt as _,
};

// Tariff with two elements:
// - element 0: 1.00 EUR/kWh, restricted to RESERVATION sessions only + max_duration: 3600
// - element 1: 0.30 EUR/kWh, no restriction (fallback)
//
// Without ignoring elements that have a `reservation` restriction, the `max_duration`
// from element 0 would inject a spurious period boundary at 3600 s into the timeline.
// Splitting a 2-hour session into two charging periods.
const TARIFF_WITH_RESERVATION_AND_MAX_DURATION: &str = r#"{
    "country_code": "DE",
    "party_id": "ALL",
    "id": "T_RES",
    "currency": "EUR",
    "elements": [
        {
            "price_components": [
                { "type": "ENERGY", "price": 1.00, "vat": 0.0, "step_size": 1 }
            ],
            "restrictions": {
                "reservation": "RESERVATION",
                "max_duration": 3600
            }
        },
        {
            "price_components": [
                { "type": "ENERGY", "price": 0.30, "vat": 0.0, "step_size": 1 }
            ]
        }
    ],
    "last_updated": "2025-01-01T00:00:00Z"
}"#;

#[test]
fn should_not_split_period_at_reservation_element_max_duration() {
    // 2-hour session - longer than the reservation element's max_duration of 3600 s.
    // The reservation element must be completely ignored during timeline construction,
    // so the generated CDR should have exactly one charging period.
    let doc = json::parse_object(TARIFF_WITH_RESERVATION_AND_MAX_DURATION).unwrap();
    let tariff =
        tariff::build_versioned(tariff::infer_version(doc).unwrap_certain()).ignore_warnings();
    let config = generate::Config {
        timezone: chrono_tz::Europe::Amsterdam,
        start_date_time: test::datetime_utc(DATE, "10:00:00"),
        end_date_time: test::datetime_utc(DATE, "12:00:00"),
        max_power_supply_kw: 11.into(),
        requested_kwh: 100.into(),
        max_current_supply_amp: 16.into(),
    };

    let report = generate::cdr_from_tariff(&tariff, &config).unwrap();
    let (report, warnings) = report.into_parts();

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

    let periods = &report.partial_cdr.charging_periods;
    assert_eq!(
        periods.len(),
        1,
        "reservation element's max_duration must not create a period split; got {} periods",
        periods.len()
    );
}

const DATE: &str = "2025-11-10";

#[test]
fn should_warn_no_elements() {
    const TARIFF_JSON: &str = r#"{
    "country_code": "DE",
    "party_id": "ALL",
    "id": "1",
    "currency": "EUR",
    "type": "REGULAR",
    "elements": [],
    "last_updated": "2018-12-05T12:01:09Z"
}
"#;

    let tariff = tariff::build_versioned(
        tariff::infer_version(json::parse_object(TARIFF_JSON).unwrap()).unwrap_certain(),
    )
    .ignore_warnings();
    let config = generate::Config {
        timezone: chrono_tz::Europe::Amsterdam,
        start_date_time: test::datetime_utc(DATE, "15:02:12"),
        end_date_time: test::datetime_utc(DATE, "15:12:12"),
        max_power_supply_kw: 12.into(),
        requested_kwh: 80.into(),
        max_current_supply_amp: 2.into(),
    };
    let failure = generate::cdr_from_tariff(&tariff, &config).unwrap_only_error();
    let generate::Warning::Tariff(warning) = failure.into_warning() else {
        panic!("expected a generate::Warning::Tariff");
    };
    assert_matches!(warning, tariff::Warning::NoElements);
}