use crate::types::{DateTime, LocalDate, LocalTime, Number};
use crate::v2_3_0::tariffs::{
DayOfWeek, PriceComponent, ReservationRestrictionType, Tariff, TariffDimensionType, TariffElement,
TariffRestrictions,
};
use super::PricingError;
use super::breakdown::{
AppliedComponent, CostBreakdown, DimensionCost, PriceLimitApplied, PricedSegment, PricingNote,
PricingNoteCode, TaxLine,
};
use super::input::{PricedPeriod, PricedSession};
use super::policy::PricingPolicy;
#[derive(Clone, Debug, Default)]
pub struct PricingEngine {
policy: PricingPolicy,
}
impl PricingEngine {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_policy(policy: PricingPolicy) -> Self {
Self { policy }
}
#[must_use]
pub const fn policy(&self) -> &PricingPolicy {
&self.policy
}
pub fn price(&self, session: &PricedSession, tariffs: &[Tariff]) -> Result<CostBreakdown, PricingError> {
if tariffs.is_empty() {
return Err(PricingError::NoTariff);
}
let mut notes: Vec<PricingNote> = Vec::new();
let mut segments: Vec<(TariffDimensionType, PricedSegment, u32)> = Vec::new();
let mut flat_charged = false;
if let Some(at) = session.first_out_of_order() {
notes.push(PricingNote::new(
PricingNoteCode::PeriodsOutOfOrder,
Some(at),
"this Charging Period does not start after the one before it; `step_size` and \
every duration-based restriction are evaluated against the order given, which \
is not a timeline this session could have had",
));
}
for (index, period) in session.periods.iter().enumerate() {
let tariff = Self::select_tariff(session, period, tariffs)?;
let context = RestrictionContext::build(session, index, period)?;
let end_context = RestrictionContext::build_at_end(session, index, period)?;
for (dimension, quantity, reserving) in period_quantities(period) {
if quantity.is_zero() {
continue;
}
let context = context.reserving(reserving);
let Some(found) = find_component(tariff, dimension, &context) else {
notes.push(PricingNote::new(
PricingNoteCode::NoPriceComponent,
Some(period.start),
format!(
"no {dimension} Price Component in tariff {} matched{}; \
the specification says there are then no costs for that dimension",
tariff.id,
if reserving { " for the reserved time" } else { "" },
),
));
continue;
};
if let Some(end) = end_context.as_ref().map(|c| c.reserving(reserving))
&& let Some(later) = find_component(tariff, dimension, &end)
&& (later.element_index, later.component_index)
!= (found.element_index, found.component_index)
{
notes.push(PricingNote::new(
PricingNoteCode::PeriodSpansPriceChange,
Some(period.start),
format!(
"the {dimension} Charging Period starting here outlasts the Price \
Component that prices it: element {} applies at the start and \
element {} by the time the period ends. A CPO SHALL start a new \
Charging Period at a price change, so this one should have been \
split; its {dimension} is billed in full at the earlier rate, \
because nothing in the period says how it divides",
found.element_index, later.element_index,
),
));
}
segments.push((
dimension,
PricedSegment {
start: period.start,
quantity,
price: found.component.price,
vat_percentage: found.component.vat,
cost: Number::ZERO, applied: found.applied(tariff, &context),
},
found.component.step_size,
));
}
let context = context.reserving(!period.reservation_hours.is_zero());
if !flat_charged && let Some(found) = find_component(tariff, TariffDimensionType::Flat, &context)
{
flat_charged = true;
segments.push((
TariffDimensionType::Flat,
PricedSegment {
start: period.start,
quantity: Number::ONE,
price: found.component.price,
vat_percentage: found.component.vat,
cost: Number::ZERO,
applied: found.applied(tariff, &context),
},
1,
));
}
}
let dimensions = self.quantise_and_cost(segments);
let tariff = Self::select_tariff_for_limits(session, tariffs)?;
Ok(self.finish(dimensions, tariff, notes))
}
fn quantise_and_cost(
&self,
segments: Vec<(TariffDimensionType, PricedSegment, u32)>,
) -> Vec<DimensionCost> {
use TariffDimensionType::{Energy, Flat, ParkingTime, Time};
let quantised_time_dimension =
if segments.iter().any(|(d, _, _)| *d == TariffDimensionType::ParkingTime) {
Some(ParkingTime)
} else {
segments.iter().find(|(d, _, _)| d.is_time_based()).map(|(d, _, _)| *d)
};
let mut by_dimension: Vec<(TariffDimensionType, Vec<(PricedSegment, u32)>)> = Vec::new();
for (dimension, segment, step) in segments {
match by_dimension.iter_mut().find(|(d, _)| *d == dimension) {
Some((_, list)) => list.push((segment, step)),
None => by_dimension.push((dimension, vec![(segment, step)])),
}
}
let mut out = Vec::with_capacity(by_dimension.len());
for (dimension, mut list) in by_dimension {
let measured: Number = list.iter().map(|(s, _)| s.quantity).sum();
let quantise = match dimension {
Energy => true,
Time | ParkingTime => quantised_time_dimension == Some(dimension),
Flat => false,
};
let billed = if quantise {
let step = list.last().map_or(1, |(_, step)| *step);
let unit_scale = match dimension {
Energy => 1000, _ => 3600, };
self.policy.quantisation.apply(measured, step, unit_scale)
} else {
measured
};
if billed != measured
&& let Some((last, _)) = list.last_mut()
{
last.quantity = last.quantity + (billed - measured);
}
let mut cost = Number::ZERO;
let mut vat = Number::ZERO;
let mut priced_segments = Vec::with_capacity(list.len());
for (mut segment, _) in list {
segment.cost = self.policy.round_component(segment.quantity * segment.price);
segment.quantity = self.policy.round_quantity(segment.quantity);
cost = cost + segment.cost;
if let Some(percentage) = segment.vat_percentage {
vat = vat + self.policy.round_component(segment.cost * percentage / Number::from(100u32));
}
priced_segments.push(segment);
}
out.push(DimensionCost {
dimension,
measured: self.policy.round_quantity(measured),
billed: self.policy.round_quantity(billed),
cost: self.policy.round_component(cost),
vat: self.policy.round_component(vat),
segments: priced_segments,
});
}
out
}
fn finish(
&self,
dimensions: Vec<DimensionCost>,
tariff: &Tariff,
mut notes: Vec<PricingNote>,
) -> CostBreakdown {
let mut taxes: Vec<TaxLine> = Vec::new();
for dimension in &dimensions {
for segment in &dimension.segments {
let Some(percentage) = segment.vat_percentage else { continue };
let amount = self.policy.round_component(segment.cost * percentage / Number::from(100u32));
match taxes.iter_mut().find(|t| t.percentage == Some(percentage)) {
Some(line) => {
line.taxable = line.taxable + segment.cost;
line.amount = line.amount + amount;
}
None => {
taxes.push(TaxLine { percentage: Some(percentage), taxable: segment.cost, amount });
}
}
}
}
taxes.sort_by_key(|a| a.percentage);
let raw_excl: Number = dimensions.iter().map(|d| d.cost).sum();
let raw_vat: Number = taxes.iter().map(|t| t.amount).sum();
let mut total_excl = self.policy.round_currency(raw_excl);
let mut total_incl = self.policy.round_currency(raw_excl + raw_vat);
let mut limit_applied = None;
if let Some(min) = tariff.min_price.as_ref() {
if total_excl < min.before_taxes {
total_excl = self.policy.round_currency(min.before_taxes);
limit_applied = Some(PriceLimitApplied::Minimum);
}
if let Some(after) = min.after_taxes
&& total_incl < after
{
total_incl = self.policy.round_currency(after);
limit_applied = Some(PriceLimitApplied::Minimum);
}
}
if let Some(max) = tariff.max_price.as_ref() {
if total_excl > max.before_taxes {
total_excl = self.policy.round_currency(max.before_taxes);
limit_applied = Some(PriceLimitApplied::Maximum);
}
if let Some(after) = max.after_taxes
&& total_incl > after
{
total_incl = self.policy.round_currency(after);
limit_applied = Some(PriceLimitApplied::Maximum);
}
}
let mut base_ratio = Number::ONE;
if let Some(applied) = limit_applied {
base_ratio = if raw_excl.is_zero() { Number::ONE } else { total_excl / raw_excl };
let bounded_after_tax = match applied {
PriceLimitApplied::Minimum => tariff.min_price.as_ref().and_then(|p| p.after_taxes),
PriceLimitApplied::Maximum => tariff.max_price.as_ref().and_then(|p| p.after_taxes),
};
if bounded_after_tax.is_none() {
let scaled_vat = if raw_excl.is_zero() { Number::ZERO } else { raw_vat * base_ratio };
total_incl = self.policy.round_currency(total_excl + scaled_vat);
}
total_incl = total_incl.max(total_excl);
notes.push(PricingNote::new(
PricingNoteCode::TotalClamped,
None,
format!(
"the session metered {raw_excl} before tax, which the tariff's {} price \
limit moved to {total_excl}; the tax lines were moved in proportion so they \
still account for the difference between the two totals",
match applied {
PriceLimitApplied::Minimum => "minimum",
PriceLimitApplied::Maximum => "maximum",
},
),
));
}
if total_incl < total_excl {
notes.push(PricingNote::new(
PricingNoteCode::NegativeTax,
None,
format!(
"the price components of this tariff describe {} of tax, which no tariff can \
mean; the inclusive total is held at the exclusive one. A VAT percentage \
below zero is what causes this, and `Tariff::validate` names the component",
total_incl - total_excl,
),
));
total_incl = total_excl;
}
self.present_taxes(&mut taxes, total_incl - total_excl, base_ratio, total_excl, &mut notes);
CostBreakdown {
dimensions,
total_excl_vat: total_excl,
total_incl_vat: total_incl,
taxes,
limit_applied,
notes,
}
}
fn present_taxes(
&self,
taxes: &mut Vec<TaxLine>,
owed: Number,
base_ratio: Number,
taxable_base: Number,
notes: &mut Vec<PricingNote>,
) {
let current: Number = taxes.iter().map(|t| t.amount).sum();
if taxes.is_empty() || current.is_zero() {
if owed.is_zero() {
for line in taxes.iter_mut() {
line.taxable = self.policy.round_currency(line.taxable * base_ratio);
line.amount = Number::ZERO;
}
return;
}
notes.push(PricingNote::new(
PricingNoteCode::UnattributedTax,
None,
format!(
"{owed} of tax is owed that no price component in this session accounts for; \
it comes from a price limit's `after_taxes` bound, which names an amount but \
not a rate",
),
));
taxes.clear();
taxes.push(TaxLine { percentage: None, taxable: taxable_base, amount: owed });
return;
}
let mut running = Number::ZERO;
let last = taxes.len() - 1;
for (i, line) in taxes.iter_mut().enumerate() {
line.taxable = self.policy.round_currency(line.taxable * base_ratio);
if i == last {
line.amount = owed - running;
} else {
line.amount = self.policy.round_currency(line.amount * owed / current);
running = running + line.amount;
}
}
}
fn select_tariff<'a>(
session: &PricedSession,
period: &PricedPeriod,
tariffs: &'a [Tariff],
) -> Result<&'a Tariff, PricingError> {
if let Some(id) = period.tariff_id.as_deref() {
return tariffs
.iter()
.find(|t| t.id.eq_ignore_case(id))
.ok_or_else(|| PricingError::UnknownTariff(id.to_owned()));
}
Self::select_by_preference(session, period.start, tariffs)
}
fn select_tariff_for_limits<'a>(
session: &PricedSession,
tariffs: &'a [Tariff],
) -> Result<&'a Tariff, PricingError> {
Self::select_by_preference(session, session.start, tariffs)
}
fn select_by_preference<'a>(
session: &PricedSession,
at: DateTime,
tariffs: &'a [Tariff],
) -> Result<&'a Tariff, PricingError> {
use crate::v2_3_0::sessions::ProfileType;
use crate::v2_3_0::tariffs::TariffType;
let wanted = if session.ad_hoc_payment {
Some(TariffType::AdHocPayment)
} else {
match session.profile_type {
Some(ProfileType::Cheap) => Some(TariffType::ProfileCheap),
Some(ProfileType::Fast) => Some(TariffType::ProfileFast),
Some(ProfileType::Green) => Some(TariffType::ProfileGreen),
Some(ProfileType::Regular) => Some(TariffType::Regular),
None => None,
}
};
let active: Vec<&Tariff> = tariffs.iter().filter(|t| t.is_active_at(at)).collect();
if active.is_empty() {
return Err(PricingError::NoActiveTariff(at));
}
if let Some(wanted) = wanted
&& let Some(t) = active.iter().find(|t| t.tariff_type == Some(wanted))
{
return Ok(t);
}
if let Some(t) = active.iter().find(|t| t.tariff_type.is_none()) {
return Ok(t);
}
Ok(active[0])
}
}
fn period_quantities(period: &PricedPeriod) -> [(TariffDimensionType, Number, bool); 4] {
[
(TariffDimensionType::Energy, period.energy_kwh, false),
(TariffDimensionType::Time, period.charging_hours, false),
(TariffDimensionType::Time, period.reservation_hours, true),
(TariffDimensionType::ParkingTime, period.parking_hours, false),
]
}
#[derive(Clone, Copy)]
struct RestrictionContext {
local_time: LocalTime,
local_date: LocalDate,
weekday: DayOfWeek,
energy_so_far: Number,
duration_so_far_seconds: i64,
current_lower: Option<Number>,
current_upper: Option<Number>,
power_lower: Option<Number>,
power_upper: Option<Number>,
is_reservation: bool,
reservation_expired: bool,
}
impl RestrictionContext {
fn build(session: &PricedSession, index: usize, period: &PricedPeriod) -> Result<Self, PricingError> {
let local = session.time_zone.to_local(period.start)?;
Ok(Self {
local_time: local.time,
local_date: local.date,
weekday: DayOfWeek::from_iso_number(local.iso_weekday).unwrap_or(DayOfWeek::Monday),
energy_so_far: session.energy_before(index),
duration_so_far_seconds: session.duration_before(index),
current_lower: period.current_for_lower_bound(),
current_upper: period.current_for_upper_bound(),
power_lower: period.power_for_lower_bound(),
power_upper: period.power_for_upper_bound(),
is_reservation: false,
reservation_expired: session.reservation_expired,
})
}
fn build_at_end(
session: &PricedSession,
index: usize,
period: &PricedPeriod,
) -> Result<Option<Self>, PricingError> {
let Some(end) = session.period_end(index) else { return Ok(None) };
let Some(last_instant) = DateTime::from_unix_timestamp(end.unix_timestamp() - 1).ok() else {
return Ok(None);
};
if last_instant <= period.start {
return Ok(None);
}
let local = session.time_zone.to_local(last_instant)?;
Ok(Some(Self {
local_time: local.time,
local_date: local.date,
weekday: DayOfWeek::from_iso_number(local.iso_weekday).unwrap_or(DayOfWeek::Monday),
energy_so_far: session.energy_before(index) + period.energy_kwh,
duration_so_far_seconds: last_instant.unix_timestamp() - session.start.unix_timestamp(),
current_lower: period.current_for_lower_bound(),
current_upper: period.current_for_upper_bound(),
power_lower: period.power_for_lower_bound(),
power_upper: period.power_for_upper_bound(),
is_reservation: false,
reservation_expired: session.reservation_expired,
}))
}
const fn reserving(&self, is_reservation: bool) -> Self {
Self { is_reservation, ..*self }
}
fn describe(&self) -> String {
format!(
"at {} {} local ({}), {} kWh and {}s into the session",
self.local_date, self.local_time, self.weekday, self.energy_so_far, self.duration_so_far_seconds
)
}
}
struct Found<'a> {
component: &'a PriceComponent,
element_index: usize,
component_index: usize,
}
impl Found<'_> {
fn applied(&self, tariff: &Tariff, context: &RestrictionContext) -> AppliedComponent {
AppliedComponent {
tariff_id: tariff.id.as_str().to_owned(),
element_index: self.element_index,
component_index: self.component_index,
because: context.describe(),
}
}
}
fn find_component<'a>(
tariff: &'a Tariff,
dimension: TariffDimensionType,
context: &RestrictionContext,
) -> Option<Found<'a>> {
for (element_index, element) in tariff.elements.iter().enumerate() {
if !restrictions_match(element, context) {
continue;
}
for (component_index, component) in element.price_components.iter().enumerate() {
if component.component_type == dimension {
return Some(Found { component, element_index, component_index });
}
}
}
None
}
fn restrictions_match(element: &TariffElement, context: &RestrictionContext) -> bool {
let Some(restrictions) = element.restrictions.as_ref() else {
return !context.is_reservation || element_prices_reservation_dimension(element);
};
matches(restrictions, context)
}
fn element_prices_reservation_dimension(element: &TariffElement) -> bool {
element
.price_components
.iter()
.any(|c| matches!(c.component_type, TariffDimensionType::Flat | TariffDimensionType::Time))
}
fn matches(r: &TariffRestrictions, context: &RestrictionContext) -> bool {
match r.reservation {
Some(ReservationRestrictionType::Reservation) => {
if !context.is_reservation || context.reservation_expired {
return false;
}
}
Some(ReservationRestrictionType::ReservationExpires) => {
if !context.is_reservation || !context.reservation_expired {
return false;
}
}
None => {
if context.is_reservation {
return false;
}
}
}
if let (Some(start), Some(end)) = (r.start_time, r.end_time) {
if !context.local_time.is_within(start, end) {
return false;
}
} else if let Some(start) = r.start_time {
if context.local_time < start {
return false;
}
} else if let Some(end) = r.end_time
&& context.local_time >= end
{
return false;
}
if r.start_date.is_some_and(|d| context.local_date < d) {
return false;
}
if r.end_date.is_some_and(|d| context.local_date >= d) {
return false;
}
if r.min_kwh.is_some_and(|min| context.energy_so_far < min) {
return false;
}
if r.max_kwh.is_some_and(|max| context.energy_so_far >= max) {
return false;
}
if let Some(min) = r.min_current
&& context.current_lower.is_none_or(|c| c < min)
{
return false;
}
if let Some(max) = r.max_current
&& context.current_upper.is_none_or(|c| c >= max)
{
return false;
}
if let Some(min) = r.min_power
&& context.power_lower.is_none_or(|p| p < min)
{
return false;
}
if let Some(max) = r.max_power
&& context.power_upper.is_none_or(|p| p >= max)
{
return false;
}
if let Some(min) = r.min_duration
&& context.duration_so_far_seconds < i64_of(min)
{
return false;
}
if let Some(max) = r.max_duration
&& context.duration_so_far_seconds >= i64_of(max)
{
return false;
}
if !r.day_of_week.is_empty() && !r.day_of_week.contains(&context.weekday) {
return false;
}
true
}
fn i64_of(value: u64) -> i64 {
i64::try_from(value).unwrap_or(i64::MAX)
}