ocpi-tariffs 0.43.0

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

use chrono::Utc;
use chrono_tz::Tz;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;

use crate::{
    assert_approx_eq, cdr,
    price::{self, test::UnwrapReport},
    tariff, test, Kwh, Version,
};

use super::{Consumed, Period, TariffSource};

#[test]
fn should_price_periods_from_time_and_parking_time_cdr_and_tariff() {
    const VERSION: Version = Version::V211;
    const CDR_JSON: &str = include_str!(
        "../../test_data/v211/real_world/time_and_parking_time_separate_tariff/cdr.json"
    );
    const TARIFF_JSON: &str = include_str!(
        "../../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json"
    );
    // Every period has a 15 minute duration.
    const PERIOD_DURATION: chrono::TimeDelta = chrono::TimeDelta::minutes(15);

    /// Create `TIME` period for each energy value provided.
    ///
    /// Each `TIME` period is the same duration.
    /// But has a different `start_date_time`.
    fn charging(start_date_time: &str, energy: Vec<Decimal>) -> Vec<Period> {
        let start: chrono::DateTime<Utc> = start_date_time.parse().unwrap();

        energy
            .into_iter()
            .enumerate()
            .map(|(i, kwh)| {
                let i = i32::try_from(i).unwrap();
                let start_date_time = start + (PERIOD_DURATION * i);

                Period {
                    start_date_time,
                    consumed: Consumed {
                        duration_charging: Some(PERIOD_DURATION),
                        energy: Some(kwh.into()),
                        ..Default::default()
                    },
                }
            })
            .collect()
    }

    /// Create `period_count` number of `PARKING_TIME` periods.
    ///
    /// Each `PARKING_TIME` period is the same duration and energy usage (0kWh)
    /// but has a different `start_date_time`.
    fn parking(start_date_time: &str, period_count: usize) -> Vec<Period> {
        // Every parking period has a consumed energy of zero.
        let period_energy = Kwh::from(0);
        let start: chrono::DateTime<Utc> = start_date_time.parse().unwrap();

        let period_count = i32::try_from(period_count).unwrap();
        // Add uniform periods except for the last one
        let mut periods: Vec<Period> = (0..period_count - 1)
            .map(|i| {
                let start_date_time = start + (PERIOD_DURATION * i);

                Period {
                    start_date_time,
                    consumed: Consumed {
                        duration_parking: Some(PERIOD_DURATION),
                        energy: Some(period_energy),
                        ..Default::default()
                    },
                }
            })
            .collect();

        let start_date_time = start + (PERIOD_DURATION * (period_count - 1));

        // The last period is a 10 minutes period instead of 15 minutes.
        periods.push(Period {
            start_date_time,
            consumed: Consumed {
                duration_parking: Some(chrono::TimeDelta::seconds(644)),
                energy: Some(period_energy),
                ..Default::default()
            },
        });

        periods
    }

    test::setup();

    let report = cdr::parse_with_version(CDR_JSON, VERSION).unwrap();
    let cdr::ParseReport {
        cdr,
        unexpected_fields,
    } = report;

    assert!(unexpected_fields.is_empty());
    let tariff::ParseReport {
        tariff,
        unexpected_fields,
    } = tariff::parse_with_version(TARIFF_JSON, VERSION).unwrap();
    assert!(unexpected_fields.is_empty());

    // If you know the version and timezone of a CDR you simply pass them into the `cdr::price` fn.
    let report = cdr::price(
        &cdr,
        TariffSource::Override(vec![tariff.clone()]),
        Tz::Europe__Amsterdam,
    )
    .unwrap_report(cdr.as_json_str());

    let (report, warnings) = report.into_parts();
    assert!(warnings.is_empty(), "{:#?}", warnings.path_id_map());

    let price::Report {
        // We are not concerned with warnings in this test
        periods,
        // We are not concerned with the tariff reports in this test
        tariff_used: _,
        tariff_reports: _,
        timezone: _,
        billed_energy,
        billed_parking_time,
        billed_charging_time,
        total_charging_time,
        total_energy,
        total_parking_time,
        // The `total_time` simply the addition of `total_charging_time` and `total_parking_time`.
        total_time: _,
        total_cost,
        total_energy_cost,
        total_fixed_cost,
        total_parking_cost,
        // Reservation costs are not computed during pricing.
        total_reservation_cost: _,
        total_time_cost,
    } = report;

    let mut cdr_periods = charging(
        "2025-04-09T16:12:54.000Z",
        vec![
            dec!(2.75),
            dec!(2.77),
            dec!(1.88),
            dec!(2.1),
            dec!(2.09),
            dec!(2.11),
            dec!(2.09),
            dec!(2.09),
            dec!(2.09),
            dec!(2.09),
            dec!(2.09),
            dec!(2.09),
            dec!(2.09),
            dec!(2.11),
            dec!(2.13),
            dec!(2.09),
            dec!(2.11),
            dec!(2.12),
            dec!(2.13),
            dec!(2.1),
            dec!(2.0),
            dec!(0.69),
            dec!(0.11),
        ],
    );
    let mut periods_parking = parking("2025-04-09T21:57:55.000Z", 47);

    cdr_periods.append(&mut periods_parking);
    cdr_periods.sort_by_key(|p| p.start_date_time);

    assert_eq!(
            cdr_periods.len(),
            periods.len(),
            "The amount of `price::Report` periods should equal the periods given to the `price::periods` fn"
        );
    assert_eq!(
        periods.len(),
        70,
        "The `time_and_parking/cdr.json` has 70 `charging_periods`"
    );

    assert!(periods
        .iter()
        .map(|p| p.start_date_time)
        .collect::<Vec<_>>()
        .is_sorted());

    let (tariff, warnings) = super::tariff::parse(&tariff).unwrap().into_parts();
    assert!(warnings.is_empty());

    let cdr_periods_count = cdr_periods.len();

    let periods_report = price::periods(
        "2025-04-10T09:38:38.000Z".parse().unwrap(),
        chrono_tz::Europe::Amsterdam,
        &tariff,
        cdr_periods,
    )
    .unwrap()
    .unwrap();

    let price::PeriodsReport {
        billable,
        periods,
        totals,
        total_costs,
    } = periods_report;

    assert_eq!(
            cdr_periods_count,
            periods.len(),
            "The amount of `price::Report` periods should equal the periods given to the `price::periods` fn"
        );
    assert_eq!(
        periods.len(),
        70,
        "The `time_and_parking/cdr.json` has 70 `charging_periods`"
    );

    assert_approx_eq!(billable.charging_time, billed_charging_time);
    assert_approx_eq!(billable.energy, billed_energy);
    assert_approx_eq!(billable.parking_time, billed_parking_time,);

    assert_approx_eq!(totals.duration_charging, total_charging_time);
    assert_approx_eq!(totals.energy, total_energy.calculated);
    assert_approx_eq!(totals.duration_parking, total_parking_time.calculated);

    assert_approx_eq!(total_costs.duration_charging, total_time_cost.calculated,);
    assert_approx_eq!(total_costs.energy, total_energy_cost.calculated,);
    assert_approx_eq!(total_costs.fixed, total_fixed_cost.calculated);
    assert_approx_eq!(total_costs.duration_parking, total_parking_cost.calculated);
    assert_approx_eq!(total_costs.total(), total_cost.calculated);
}