mod breakdown;
mod engine;
mod input;
mod policy;
pub use breakdown::{
AppliedComponent, CostBreakdown, DimensionCost, PriceLimitApplied, PricedSegment, PricingNote,
PricingNoteCode, TaxLine,
};
pub use engine::PricingEngine;
pub use input::{PricedPeriod, PricedSession};
pub use policy::{PricingPolicy, Quantisation};
use core::fmt;
use crate::types::{DateTime, LocalParts};
#[derive(Clone)]
pub struct TimeZone {
name: String,
zone: Option<tz::TimeZoneRef<'static>>,
}
impl TimeZone {
pub fn named(name: &str) -> Result<Self, PricingError> {
if name.eq_ignore_ascii_case("UTC") {
return Ok(Self::utc());
}
let zone = tzdb::tz_by_name(name)
.ok_or_else(|| PricingError::TimeZone(format!("unknown IANA time zone {name:?}")))?;
Ok(Self { name: name.to_owned(), zone: Some(zone) })
}
#[must_use]
pub fn utc() -> Self {
Self { name: "UTC".to_owned(), zone: None }
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
pub fn offset_seconds_at(&self, instant: DateTime) -> Result<i32, PricingError> {
let Some(zone) = self.zone else { return Ok(0) };
zone.find_local_time_type(instant.unix_timestamp())
.map(tz::LocalTimeType::ut_offset)
.map_err(|e| PricingError::TimeZone(format!("{}: {e}", self.name)))
}
pub fn to_local(&self, instant: DateTime) -> Result<LocalParts, PricingError> {
Ok(instant.local_parts(self.offset_seconds_at(instant)?))
}
}
impl fmt::Debug for TimeZone {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "TimeZone({})", self.name)
}
}
impl PartialEq for TimeZone {
fn eq(&self, other: &Self) -> bool {
self.name.eq_ignore_ascii_case(&other.name)
}
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum PricingError {
#[error("no tariff to price against")]
NoTariff,
#[error("charging period refers to tariff {0:?}, which was not provided")]
UnknownTariff(String),
#[error("no tariff is active at {0}")]
NoActiveTariff(DateTime),
#[error("time zone: {0}")]
TimeZone(String),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Number;
use crate::v2_3_0::tariffs::{
PriceComponent, PriceLimit, Tariff, TariffDimensionType, TariffElement, TariffRestrictions,
TaxIncluded,
};
fn n(s: &str) -> Number {
s.parse().unwrap()
}
fn dt(s: &str) -> DateTime {
s.parse().unwrap()
}
fn minutes(count: u32) -> Number {
Number::from(count) / Number::from(60u32)
}
#[track_caller]
fn assert_close(actual: Number, expected: Number) {
let dp = PricingPolicy::default().quantity_decimals;
assert_eq!(actual.round_dp(dp), expected.round_dp(dp), "expected {expected}, got {actual}");
}
fn component(
dimension: TariffDimensionType,
price: &str,
vat: Option<&str>,
step_size: u32,
) -> PriceComponent {
PriceComponent {
component_type: dimension,
price: n(price),
vat: vat.map(n),
step_size,
extensions: crate::types::Extensions::new(),
}
}
fn tariff(elements: Vec<TariffElement>) -> Tariff {
Tariff::builder()
.country_code("DE")
.party_id("ALL")
.id("1")
.currency("EUR")
.elements(elements)
.tax_included(TaxIncluded::No)
.last_updated(dt("2015-06-29T20:39:09Z"))
.build()
}
fn element(components: Vec<PriceComponent>) -> TariffElement {
TariffElement::builder().price_components(components).build()
}
fn restricted(components: Vec<PriceComponent>, r: TariffRestrictions) -> TariffElement {
TariffElement::builder().price_components(components).restrictions(r).build()
}
#[test]
fn spec_example_simple_025_per_kwh() {
let t = tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", Some("10.0"), 1)])]);
let session =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("20"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!(b.total_excl_vat, n("5.00"));
assert_eq!(b.total_incl_vat, n("5.50"));
}
#[test]
fn spec_example_energy_plus_start_fee() {
let t = tariff(vec![element(vec![
component(TariffDimensionType::Flat, "0.50", Some("20.0"), 1),
component(TariffDimensionType::Energy, "0.25", Some("10.0"), 1),
])]);
let session =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("20"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!(b.total_excl_vat, n("5.50"));
assert_eq!(b.total_incl_vat, n("6.10"));
assert_eq!(b.dimension_total(TariffDimensionType::Flat), n("0.50"));
}
#[test]
fn spec_example_flat_fee_is_charged_once_for_the_whole_session() {
let t = tariff(vec![element(vec![
component(TariffDimensionType::Flat, "0.50", None, 1),
component(TariffDimensionType::Energy, "0.25", None, 1),
])]);
let session = PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc())
.with_period(PricedPeriod {
energy_kwh: n("10"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
})
.with_period(PricedPeriod {
energy_kwh: n("10"),
..PricedPeriod::new(dt("2024-01-15T11:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!(b.dimension_total(TariffDimensionType::Flat), n("0.50"), "not 1.00");
assert_eq!(b.total_excl_vat, n("5.50"));
}
#[test]
fn spec_example_energy_step_size_rounds_the_session_total_once() {
let t = tariff(vec![
restricted(
vec![component(TariffDimensionType::Energy, "0.20", None, 500)],
TariffRestrictions { end_time: Some("17:00".parse().unwrap()), ..Default::default() },
),
element(vec![component(TariffDimensionType::Energy, "0.27", None, 500)]),
]);
let session = PricedSession::new(dt("2024-01-15T15:00:00Z"), TimeZone::utc())
.with_period(PricedPeriod {
energy_kwh: n("4.3"),
..PricedPeriod::new(dt("2024-01-15T15:00:00Z"))
})
.with_period(PricedPeriod {
energy_kwh: n("1.1"),
..PricedPeriod::new(dt("2024-01-15T17:30:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
let energy = b.dimension(TariffDimensionType::Energy).unwrap();
assert_eq!(energy.measured, n("5.4"));
assert_eq!(energy.billed, n("5.5"), "the session total is rounded, once");
assert_eq!(energy.segments[1].quantity, n("1.2"), "the surplus lands in the last segment");
assert_eq!(b.total_excl_vat, n("1.18"));
}
#[test]
fn charging_time_in_a_reserved_period_is_not_billed_at_the_reservation_rate() {
let t = tariff(vec![
restricted(
vec![component(TariffDimensionType::Time, "4.00", None, 1)],
TariffRestrictions {
reservation: Some(crate::v2_3_0::tariffs::ReservationRestrictionType::Reservation),
..Default::default()
},
),
element(vec![component(TariffDimensionType::Time, "1.00", None, 1)]),
]);
let session =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
charging_hours: n("1"),
reservation_hours: n("0.5"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!(b.total_excl_vat, n("3.00"));
let time = b.dimension(TariffDimensionType::Time).unwrap();
assert_eq!(time.segments.len(), 2, "the two are separate segments in the audit trail");
assert_eq!(time.segments[0].price, n("1.00"));
assert_eq!(time.segments[1].price, n("4.00"));
assert_eq!(time.measured, n("1.5"));
}
fn assert_taxes_add_up(b: &CostBreakdown) {
let summed: Number = b.taxes.iter().map(|t| t.amount).sum();
assert_eq!(
summed,
b.total_incl_vat - b.total_excl_vat,
"tax lines {:?} do not account for {} - {}",
b.taxes,
b.total_incl_vat,
b.total_excl_vat,
);
}
#[test]
fn a_minimum_price_carries_its_tax_with_it() {
let mut t =
tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", Some("21"), 1)])]);
t.min_price = Some(PriceLimit {
before_taxes: n("5.00"),
after_taxes: None,
extensions: crate::types::Extensions::new(),
});
let session =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("2"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!(b.total_excl_vat, n("5.00"));
assert_eq!(b.total_incl_vat, n("6.05"), "21% of the clamped base, not of the metered one");
assert_taxes_add_up(&b);
assert_eq!(b.taxes.len(), 1);
assert_eq!(b.taxes[0].percentage, Some(n("21")));
assert_eq!(b.limit_applied, Some(PriceLimitApplied::Minimum));
assert_eq!(b.notes_with(PricingNoteCode::TotalClamped).count(), 1);
}
#[test]
fn a_maximum_price_carries_its_tax_with_it_too() {
let mut t =
tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", Some("20"), 1)])]);
t.max_price = Some(PriceLimit {
before_taxes: n("10.00"),
after_taxes: None,
extensions: crate::types::Extensions::new(),
});
let session =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("100"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!(b.total_excl_vat, n("10.00"));
assert_eq!(b.total_incl_vat, n("12.00"));
assert_taxes_add_up(&b);
}
#[test]
fn several_vat_rates_keep_their_proportions_through_a_clamp() {
let mut t = tariff(vec![element(vec![
component(TariffDimensionType::Flat, "1.00", Some("20"), 0),
component(TariffDimensionType::Energy, "0.25", Some("10"), 1),
])]);
t.max_price = Some(PriceLimit {
before_taxes: n("3.00"),
after_taxes: None,
extensions: crate::types::Extensions::new(),
});
let session =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("40"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!(b.total_excl_vat, n("3.00"));
assert_taxes_add_up(&b);
assert_eq!(b.taxes.len(), 2, "both rates survive the clamp: {:?}", b.taxes);
assert!(b.taxes.iter().all(|t| t.percentage.is_some()));
}
#[test]
fn tax_that_no_rate_explains_is_reported_rather_than_invented() {
let mut t = tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", None, 1)])]);
t.min_price = Some(PriceLimit {
before_taxes: n("5.00"),
after_taxes: Some(n("6.00")),
extensions: crate::types::Extensions::new(),
});
let session =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("2"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!((b.total_excl_vat, b.total_incl_vat), (n("5.00"), n("6.00")));
assert_taxes_add_up(&b);
assert_eq!(b.taxes.len(), 1);
assert_eq!(b.taxes[0].percentage, None, "the amount is known, the rate is not");
assert_eq!(b.notes_with(PricingNoteCode::UnattributedTax).count(), 1);
}
#[test]
fn a_charging_period_that_outlasts_its_price_is_reported() {
let t = tariff(vec![
restricted(
vec![component(TariffDimensionType::Energy, "0.40", None, 1)],
TariffRestrictions { start_time: Some("17:00".parse().unwrap()), ..Default::default() },
),
element(vec![component(TariffDimensionType::Energy, "0.20", None, 1)]),
]);
let session = PricedSession::new(dt("2024-01-15T16:50:00Z"), TimeZone::utc())
.with_period(PricedPeriod {
energy_kwh: n("10"),
..PricedPeriod::new(dt("2024-01-15T16:50:00Z"))
})
.ending(dt("2024-01-15T17:30:00Z"));
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!(b.total_excl_vat, n("2.00"), "billed at the rate that applied when it began");
assert!(b.needs_review());
let spans: Vec<_> = b.notes_with(PricingNoteCode::PeriodSpansPriceChange).collect();
assert_eq!(spans.len(), 1, "{:?}", b.notes);
assert_eq!(spans[0].at, Some(dt("2024-01-15T16:50:00Z")));
assert!(spans[0].message.contains("ENERGY"), "{}", spans[0].message);
}
#[test]
fn a_period_that_ends_exactly_on_the_boundary_is_not_reported() {
let t = tariff(vec![
restricted(
vec![component(TariffDimensionType::Energy, "0.40", None, 1)],
TariffRestrictions { start_time: Some("17:00".parse().unwrap()), ..Default::default() },
),
element(vec![component(TariffDimensionType::Energy, "0.20", None, 1)]),
]);
let session = PricedSession::new(dt("2024-01-15T16:50:00Z"), TimeZone::utc())
.with_period(PricedPeriod { energy_kwh: n("5"), ..PricedPeriod::new(dt("2024-01-15T16:50:00Z")) })
.with_period(PricedPeriod { energy_kwh: n("5"), ..PricedPeriod::new(dt("2024-01-15T17:00:00Z")) })
.ending(dt("2024-01-15T17:30:00Z"));
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!(b.total_excl_vat, n("3.00"), "5 kWh at 0.20 then 5 at 0.40");
assert!(!b.needs_review(), "a well-formed CDR needs no review: {:?}", b.notes);
}
#[test]
fn periods_given_out_of_order_are_reported() {
let t = tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", None, 1)])]);
let session = PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc())
.with_period(PricedPeriod { energy_kwh: n("1"), ..PricedPeriod::new(dt("2024-01-15T11:00:00Z")) })
.with_period(PricedPeriod {
energy_kwh: n("1"),
..PricedPeriod::new(dt("2024-01-15T10:30:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!(b.total_excl_vat, n("0.50"), "the quantities are all there, so it still prices");
let out_of_order: Vec<_> = b.notes_with(PricingNoteCode::PeriodsOutOfOrder).collect();
assert_eq!(out_of_order.len(), 1, "{:?}", b.notes);
assert_eq!(out_of_order[0].at, Some(dt("2024-01-15T10:30:00Z")));
}
#[test]
fn a_tariff_that_describes_negative_tax_does_not_produce_a_negative_bill() {
let t = tariff(vec![element(vec![component(TariffDimensionType::Energy, "1.00", Some("-20"), 1)])]);
assert!(crate::types::Validate::validate(&t).is_err(), "the tariff is reported as malformed");
let session =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("10"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!(b.total_excl_vat, n("10.00"));
assert_eq!(b.total_incl_vat, n("10.00"), "held at the exclusive total, not 8.00");
assert_eq!(b.total_vat(), Number::ZERO);
assert_eq!(b.notes_with(PricingNoteCode::NegativeTax).count(), 1, "{:?}", b.notes);
}
#[test]
fn a_breakdown_survives_being_written_down() {
let t = tariff(vec![element(vec![
component(TariffDimensionType::Time, "1.00", Some("21"), 600),
component(TariffDimensionType::ParkingTime, "2.00", Some("21"), 600),
component(TariffDimensionType::Energy, "0.25", Some("21"), 1),
])]);
let session = PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc())
.with_period(PricedPeriod {
charging_hours: minutes(7),
energy_kwh: n("1") / n("3"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
})
.with_period(PricedPeriod {
parking_hours: minutes(16),
..PricedPeriod::new(dt("2024-01-15T10:07:00Z"))
});
let breakdown = PricingEngine::new().price(&session, &[t]).unwrap();
for dimension in &breakdown.dimensions {
assert!(
dimension.measured.json_round_trips(),
"{} measured {}",
dimension.dimension,
dimension.measured
);
assert!(
dimension.billed.json_round_trips(),
"{} billed {}",
dimension.dimension,
dimension.billed
);
for segment in &dimension.segments {
assert!(segment.quantity.json_round_trips(), "quantity {}", segment.quantity);
assert!(segment.cost.json_round_trips(), "cost {}", segment.cost);
}
}
let json = serde_json::to_string(&breakdown).unwrap();
let back: CostBreakdown = serde_json::from_str(&json).unwrap();
assert_eq!(back, breakdown, "the stored copy is the computed one");
}
#[test]
fn spec_example_parking_absorbs_the_time_rounding() {
let t = tariff(vec![element(vec![
component(TariffDimensionType::Time, "1.00", None, 600),
component(TariffDimensionType::ParkingTime, "2.00", None, 600),
])]);
let session = PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc())
.with_period(PricedPeriod {
charging_hours: minutes(21),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
})
.with_period(PricedPeriod {
parking_hours: minutes(16),
..PricedPeriod::new(dt("2024-01-15T10:21:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
let charging = b.dimension(TariffDimensionType::Time).unwrap();
let parking = b.dimension(TariffDimensionType::ParkingTime).unwrap();
assert_eq!(charging.billed, charging.measured, "charging is not rounded");
assert_close(parking.billed, minutes(20));
assert_eq!(b.total_excl_vat, n("1.02"));
}
#[test]
fn spec_example_time_alone_is_rounded_with_the_last_step_size() {
let t = tariff(vec![
restricted(
vec![component(TariffDimensionType::Time, "1.20", None, 1800)],
TariffRestrictions { end_time: Some("17:00".parse().unwrap()), ..Default::default() },
),
element(vec![component(TariffDimensionType::Time, "2.40", None, 900)]),
]);
let session = PricedSession::new(dt("2024-01-15T16:35:00Z"), TimeZone::utc())
.with_period(PricedPeriod {
charging_hours: minutes(25),
..PricedPeriod::new(dt("2024-01-15T16:35:00Z"))
})
.with_period(PricedPeriod {
charging_hours: minutes(10),
..PricedPeriod::new(dt("2024-01-15T17:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
let time = b.dimension(TariffDimensionType::Time).unwrap();
assert_close(time.measured, minutes(35));
assert_close(time.billed, minutes(45));
assert_close(time.segments[1].quantity, minutes(20));
assert_eq!(b.total_excl_vat, n("1.30"));
}
#[test]
fn spec_example_min_price_raises_a_cheap_session() {
let mut t =
tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", Some("10.0"), 1)])]);
t.min_price = Some(PriceLimit {
before_taxes: n("0.50"),
after_taxes: Some(n("0.55")),
extensions: crate::types::Extensions::new(),
});
let session =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("1"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t.clone()]).unwrap();
assert_eq!(b.total_excl_vat, n("0.50"));
assert_eq!(b.total_incl_vat, n("0.55"));
assert_eq!(b.limit_applied, Some(PriceLimitApplied::Minimum));
let big = PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("20"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&big, &[t]).unwrap();
assert_eq!(b.total_excl_vat, n("5.00"));
assert_eq!(b.limit_applied, None);
}
#[test]
fn spec_example_max_price_caps_an_expensive_session() {
let mut t = tariff(vec![element(vec![
component(TariffDimensionType::Flat, "0.50", Some("20.0"), 1),
component(TariffDimensionType::Energy, "0.25", Some("10.0"), 1),
])]);
t.max_price = Some(PriceLimit {
before_taxes: n("10.00"),
after_taxes: Some(n("11.00")),
extensions: crate::types::Extensions::new(),
});
let session =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("50"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t.clone()]).unwrap();
assert_eq!(b.total_excl_vat, n("10.00"));
assert_eq!(b.total_incl_vat, n("11.00"));
assert_eq!(b.limit_applied, Some(PriceLimitApplied::Maximum));
let smaller =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("30"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&smaller, &[t]).unwrap();
assert_eq!(b.total_excl_vat, n("8.00"));
assert_eq!(b.total_incl_vat, n("8.85"));
}
#[test]
fn spec_example_max_power_restrictions_switch_the_rate() {
let t = tariff(vec![
restricted(
vec![component(TariffDimensionType::Energy, "0.20", None, 1)],
TariffRestrictions { max_power: Some(n("16")), ..Default::default() },
),
restricted(
vec![component(TariffDimensionType::Energy, "0.35", None, 1)],
TariffRestrictions { max_power: Some(n("32")), ..Default::default() },
),
element(vec![component(TariffDimensionType::Energy, "0.50", None, 1)]),
]);
let period = |start: &str, kwh: &str, power: &str| PricedPeriod {
energy_kwh: n(kwh),
max_power_kw: Some(n(power)),
min_power_kw: Some(n(power)),
..PricedPeriod::new(dt(start))
};
let session = PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc())
.with_period(period("2024-01-15T10:00:00Z", "1", "6"))
.with_period(period("2024-01-15T10:10:00Z", "40", "48"))
.with_period(period("2024-01-15T11:00:00Z", "0.5", "4"));
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!(b.total_excl_vat, n("20.30"));
}
#[test]
fn spec_example_max_duration_makes_the_first_half_hour_free() {
let t = tariff(vec![
restricted(
vec![component(TariffDimensionType::Energy, "0.00", None, 1)],
TariffRestrictions { max_duration: Some(1800), ..Default::default() },
),
restricted(
vec![component(TariffDimensionType::Energy, "0.25", None, 1)],
TariffRestrictions { max_duration: Some(3600), ..Default::default() },
),
element(vec![component(TariffDimensionType::Energy, "0.40", None, 1)]),
]);
let session = PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc())
.with_period(PricedPeriod { energy_kwh: n("5"), ..PricedPeriod::new(dt("2024-01-15T10:00:00Z")) })
.with_period(PricedPeriod {
energy_kwh: n("1.2"),
..PricedPeriod::new(dt("2024-01-15T10:30:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!(b.total_excl_vat, n("0.30"));
}
#[test]
fn a_reservation_is_priced_by_its_own_tariff_element() {
let t = tariff(vec![
restricted(
vec![component(TariffDimensionType::Time, "5.00", Some("20.0"), 60)],
TariffRestrictions {
reservation: Some(crate::v2_3_0::tariffs::ReservationRestrictionType::Reservation),
..Default::default()
},
),
element(vec![
component(TariffDimensionType::Flat, "0.50", Some("20.0"), 1),
component(TariffDimensionType::Energy, "0.25", Some("10.0"), 1),
]),
]);
let session = PricedSession::new(dt("2024-01-15T09:45:00Z"), TimeZone::utc())
.with_period(PricedPeriod {
reservation_hours: minutes(15),
..PricedPeriod::new(dt("2024-01-15T09:45:00Z"))
})
.with_period(PricedPeriod {
energy_kwh: n("20"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!(b.total_excl_vat, n("6.75"));
assert_eq!(b.total_incl_vat, n("7.60"));
}
#[test]
fn a_free_of_charge_tariff_costs_nothing() {
let t = tariff(vec![element(vec![component(TariffDimensionType::Flat, "0.00", None, 0)])]);
assert!(t.is_free_of_charge());
let session =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("20"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
assert_eq!(b.total_excl_vat, Number::ZERO);
assert!(!b.notes.is_empty(), "the unpriced ENERGY is surfaced as a note");
}
#[test]
fn local_time_restrictions_follow_the_locations_time_zone() {
let t = tariff(vec![
restricted(
vec![component(TariffDimensionType::Energy, "0.20", None, 1)],
TariffRestrictions { end_time: Some("17:00".parse().unwrap()), ..Default::default() },
),
element(vec![component(TariffDimensionType::Energy, "0.40", None, 1)]),
]);
let berlin = TimeZone::named("Europe/Berlin").unwrap();
let price_at = |instant: &str| {
let session = PricedSession::new(dt(instant), berlin.clone())
.with_period(PricedPeriod { energy_kwh: n("1"), ..PricedPeriod::new(dt(instant)) });
PricingEngine::new().price(&session, std::slice::from_ref(&t)).unwrap().total_excl_vat
};
assert_eq!(price_at("2024-07-15T15:30:00Z"), n("0.40"), "17:30 CEST is after 17:00");
assert_eq!(price_at("2024-01-15T15:30:00Z"), n("0.20"), "16:30 CET is before 17:00");
}
#[test]
fn day_of_week_restrictions_use_local_days() {
use crate::v2_3_0::tariffs::DayOfWeek;
let t = tariff(vec![
restricted(
vec![component(TariffDimensionType::Energy, "0.10", None, 1)],
TariffRestrictions {
day_of_week: vec![DayOfWeek::Saturday, DayOfWeek::Sunday],
..Default::default()
},
),
element(vec![component(TariffDimensionType::Energy, "0.30", None, 1)]),
]);
let price_on = |instant: &str| {
let session = PricedSession::new(dt(instant), TimeZone::utc())
.with_period(PricedPeriod { energy_kwh: n("1"), ..PricedPeriod::new(dt(instant)) });
PricingEngine::new().price(&session, std::slice::from_ref(&t)).unwrap().total_excl_vat
};
assert_eq!(price_on("2024-01-13T10:00:00Z"), n("0.10"), "a Saturday");
assert_eq!(price_on("2024-01-15T10:00:00Z"), n("0.30"), "a Monday");
}
#[test]
fn the_breakdown_names_the_component_that_priced_each_segment() {
let t = tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", None, 1)])]);
let session =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("20"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
let applied: Vec<_> = b.applied_components().collect();
assert_eq!(applied.len(), 1);
assert_eq!(applied[0].tariff_id, "1");
assert_eq!(applied[0].element_index, 0);
assert_eq!(applied[0].component_index, 0);
assert!(applied[0].because.contains("local"), "{}", applied[0].because);
}
#[test]
fn a_period_can_name_which_tariff_applies_to_it() {
let cheap = Tariff {
id: "cheap".parse().unwrap(),
..tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.10", None, 1)])])
};
let dear = Tariff {
id: "dear".parse().unwrap(),
..tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.90", None, 1)])])
};
let session =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("1"),
tariff_id: Some("dear".to_owned()),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[cheap.clone(), dear]).unwrap();
assert_eq!(b.total_excl_vat, n("0.90"));
let missing =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("1"),
tariff_id: Some("nope".to_owned()),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
assert_eq!(
PricingEngine::new().price(&missing, &[cheap]).unwrap_err(),
PricingError::UnknownTariff("nope".to_owned())
);
}
#[test]
fn the_result_converts_to_a_price_of_either_version() {
let t = tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", Some("10.0"), 1)])]);
let session =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("20"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let b = PricingEngine::new().price(&session, &[t]).unwrap();
let new = b.to_price_v2_3_0();
assert_eq!(new.before_taxes, n("5.00"));
assert_eq!(new.after_taxes(), n("5.50"));
let old = b.to_price_v2_2_1();
assert_eq!((old.excl_vat, old.incl_vat.unwrap()), (n("5.00"), n("5.50")));
}
#[test]
fn disabling_step_size_bills_the_measured_quantity() {
let t = tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", None, 500)])]);
let session =
PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
energy_kwh: n("5.4"),
..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
});
let with_steps = PricingEngine::new().price(&session, std::slice::from_ref(&t)).unwrap();
assert_eq!(with_steps.dimension(TariffDimensionType::Energy).unwrap().billed, n("5.5"));
let ocpi_3_style = PricingEngine::with_policy(PricingPolicy::default().without_step_size());
let exact = ocpi_3_style.price(&session, &[t]).unwrap();
assert_eq!(exact.dimension(TariffDimensionType::Energy).unwrap().billed, n("5.4"));
assert_eq!(exact.total_excl_vat, n("1.35"));
}
#[test]
fn an_unknown_time_zone_is_an_error_not_a_silent_utc() {
assert!(matches!(TimeZone::named("Mars/Olympus"), Err(PricingError::TimeZone(_))));
assert_eq!(TimeZone::named("UTC").unwrap(), TimeZone::utc());
}
}