use chrono::{DateTime, NaiveTime, TimeDelta, Utc};
use rust_decimal::Decimal;
use crate::{
currency,
json::FromJson as _,
money::VatOrigin,
tariff::{
v221::{Element, Restrictions, Tariff},
v2x::DimensionType,
Warning,
},
warning::VerdictExt as _,
Ampere, Kw, Kwh, Money, Price, Verdict, Version, Versioned as _, Weekday,
};
pub(crate) fn explain(tariff: &crate::tariff::Versioned<'_>) -> Verdict<String, Warning> {
let parsed = match tariff.version() {
Version::V211 => {
crate::tariff::v211::Tariff::from_json(tariff.as_element()).map_caveat(Tariff::from)
}
Version::V221 => Tariff::from_json(tariff.as_element()),
};
parsed.map_caveat(|tariff| render(&tariff))
}
fn render(tariff: &Tariff<'_>) -> String {
let currency = tariff.currency;
let elements = &tariff.elements;
let mut paragraphs: Vec<String> = Vec::new();
if let Some(p) = narrate_dimension(elements, currency, DimensionType::Energy) {
paragraphs.push(p);
}
if let Some(p) = narrate_dimension(elements, currency, DimensionType::Time) {
paragraphs.push(p);
}
if let Some(p) = narrate_dimension(elements, currency, DimensionType::ParkingTime) {
paragraphs.push(p);
}
if let Some(p) = narrate_flat(elements, currency) {
paragraphs.push(p);
}
if let Some(p) = narrate_bounds(tariff.min_price, tariff.max_price, currency) {
paragraphs.push(p);
}
if let Some(p) = narrate_validity(tariff.start_date_time, tariff.end_date_time) {
paragraphs.push(p);
}
if paragraphs.is_empty() {
return fallback_reason(tariff);
}
paragraphs.join("\n\n")
}
fn fallback_reason(tariff: &Tariff<'_>) -> String {
let elements = &tariff.elements;
if elements.iter().all(is_reservation_only) {
return "This tariff never charges a regular charging session: every element applies only \
to reservation sessions."
.to_owned();
}
let has_components = elements
.iter()
.filter(|element| !is_reservation_only(element))
.any(|element| !element.price_components.is_empty());
if !has_components {
return "This tariff charges nothing: none of its applicable elements define a price \
component."
.to_owned();
}
"This tariff is free: its only charge is a flat fee of zero.".to_owned()
}
fn is_reservation_only(element: &Element) -> bool {
element
.restrictions
.as_ref()
.is_some_and(|restrictions| restrictions.reservation.is_some())
}
struct Band<'a> {
price: Money,
vat: VatOrigin,
step_size: u64,
restrictions: Option<&'a Restrictions>,
}
fn bands(elements: &[Element], dimension: DimensionType) -> Vec<Band<'_>> {
let mut bands = Vec::new();
for element in elements {
if is_reservation_only(element) {
continue;
}
if let Some(component) = element
.price_components
.iter()
.find(|component| component.dimension_type == dimension)
{
bands.push(Band {
price: component.price,
vat: component.vat,
step_size: component.step_size,
restrictions: element.restrictions.as_ref(),
});
}
}
bands
}
fn narrate_flat(elements: &[Element], currency: currency::Code) -> Option<String> {
let mut bands = bands(elements, DimensionType::Flat);
let condition_of = |restrictions: &Restrictions| -> Vec<String> {
let mut parts = restriction_parts(restrictions);
if let Some(bound) = duration_scope(restrictions) {
parts.push(bound);
}
if let Some(bound) = energy_scope(restrictions) {
parts.push(bound);
}
parts
};
let reachable = bands
.iter()
.position(|band| {
band.restrictions
.is_none_or(|restrictions| condition_of(restrictions).is_empty())
})
.map(|index| index.saturating_add(1))
.unwrap_or(bands.len());
bands.truncate(reachable);
let entries: Vec<(String, String, bool)> = bands
.iter()
.enumerate()
.map(|(index, band)| {
let condition = band.restrictions.map(&condition_of).unwrap_or_default();
let condition = if !condition.is_empty() {
condition.join(", ")
} else if index > 0 {
"otherwise".to_owned()
} else {
String::new()
};
let zero = is_free(band.price);
let fee = if zero {
"no fee".to_owned()
} else {
format!(
"{} per session{}",
money(band.price, currency),
vat_clause(band.vat)
)
};
(condition, fee, zero)
})
.collect();
match entries.as_slice() {
[] | [(_, _, true)] => None,
[(condition, fee, _)] if condition.is_empty() => Some(format!("**Flat fee:** {fee}.")),
[(condition, fee, _)] => Some(format!("**Flat fee:** {condition}, {fee}.")),
_ => {
let bullets: Vec<String> = entries
.iter()
.map(|(condition, fee, _)| format!("- {}: {fee}", capitalize_first(condition)))
.collect();
Some(format!("**Flat fee:**\n\n{}", bullets.join("\n")))
}
}
}
fn narrate_dimension(
elements: &[Element],
currency: currency::Code,
dimension: DimensionType,
) -> Option<String> {
let bands = bands(elements, dimension);
if bands.is_empty() {
return None;
}
let prose = DimensionProse::new(dimension)?;
let pieces: Vec<(Option<String>, Option<String>, Vec<String>)> = bands
.iter()
.map(|band| {
let primary = band
.restrictions
.and_then(|restrictions| primary_scope(restrictions, prose.tier));
let secondary = band
.restrictions
.and_then(|restrictions| secondary_scope(restrictions, prose.tier));
let parts = band.restrictions.map(restriction_parts).unwrap_or_default();
(primary, secondary, parts)
})
.collect();
let reachable = pieces
.iter()
.position(|(primary, secondary, parts)| {
primary.is_none() && secondary.is_none() && parts.is_empty()
})
.map(|index| index.saturating_add(1))
.unwrap_or(pieces.len());
let dropped = pieces.len().saturating_sub(reachable);
let first_step = bands.first().map(|band| band.step_size);
let uniform_step = first_step.is_some_and(|step| {
bands
.iter()
.take(reachable)
.all(|band| band.step_size == step)
});
let mut seen_bound = false;
let mut seen_qualifier = false;
let mut tiers: Vec<(String, String, String)> = Vec::with_capacity(reachable);
for (index, (band, (primary, secondary, mut condition))) in
bands.iter().zip(pieces).enumerate().take(reachable)
{
let has_qualifier = !condition.is_empty() || secondary.is_some();
if let Some(primary) = &primary {
condition.push(primary.clone());
}
if let Some(secondary) = &secondary {
condition.push(secondary.clone());
}
let condition = if !condition.is_empty() {
condition.join(", ")
} else if index > 0 && seen_bound && !seen_qualifier {
format!("for {}", prose.remaining)
} else if index > 0 {
"otherwise".to_owned()
} else {
String::new()
};
let free = is_free(band.price);
let body = if free {
"free".to_owned()
} else {
format!(
"{} {}{}",
money(band.price, currency),
prose.unit,
vat_clause(band.vat)
)
};
let step = if uniform_step || band.step_size == 1 || free {
String::new()
} else {
format!(
" (billed in {} steps, rounded up)",
step_phrase(band.step_size, prose.tier)
)
};
seen_bound |= primary.is_some();
seen_qualifier |= has_qualifier;
tiers.push((condition, body, step));
}
let section = match tiers.as_slice() {
[(condition, body, _)] if condition.is_empty() => {
format!("**{}:** {body}.", prose.subject)
}
[(condition, body, _)] => {
format!("**{}:** {condition}, {body}.", prose.subject)
}
_ => {
let bullets: Vec<String> = tiers
.iter()
.map(|(condition, body, step)| {
format!("- {}: {body}{step}", capitalize_first(condition))
})
.collect();
format!("**{}:**\n\n{}", prose.subject, bullets.join("\n"))
}
};
let section = match first_step.filter(|&step| uniform_step && step != 1) {
Some(step) => format!(
"{section}\n\n_Billed in {} steps, rounded up._",
step_phrase(step, prose.tier)
),
None => section,
};
let section = if dropped > 0 {
format!(
"{section}\n\n_Any later tiers never apply, because an earlier rate already matches \
every session._"
)
} else {
section
};
Some(section)
}
#[derive(Clone, Copy)]
struct DimensionProse {
subject: &'static str,
unit: &'static str,
remaining: &'static str,
tier: TierBasis,
}
#[derive(Clone, Copy)]
enum TierBasis {
Duration,
Energy,
}
impl DimensionProse {
fn new(dimension: DimensionType) -> Option<Self> {
let prose = match dimension {
DimensionType::Energy => DimensionProse {
subject: "Energy",
unit: "per kWh",
remaining: "the remaining energy",
tier: TierBasis::Energy,
},
DimensionType::Time => DimensionProse {
subject: "Charging time",
unit: "per hour",
remaining: "the remaining charging time",
tier: TierBasis::Duration,
},
DimensionType::ParkingTime => DimensionProse {
subject: "Idle time (connected but not charging)",
unit: "per hour",
remaining: "the remaining idle time",
tier: TierBasis::Duration,
},
DimensionType::Flat => return None,
};
Some(prose)
}
}
fn step_phrase(step_size: u64, tier: TierBasis) -> String {
match tier {
TierBasis::Energy => {
let kwh = Kwh::from_watt_hours(Decimal::from(step_size));
format!("{} kWh", Decimal::from(kwh).normalize())
}
TierBasis::Duration => format!("{step_size}-second"),
}
}
fn narrate_bounds(
min_price: Option<Price>,
max_price: Option<Price>,
currency: currency::Code,
) -> Option<String> {
let mut sentences = Vec::new();
if let Some(min) = min_price {
sentences.push(format!(
"A session always costs at least {}.",
price(min, currency)
));
}
if let Some(max) = max_price {
sentences.push(format!(
"A session never costs more than {}.",
price(max, currency)
));
}
if sentences.is_empty() {
None
} else {
Some(sentences.join(" "))
}
}
fn narrate_validity(
start_date_time: Option<DateTime<Utc>>,
end_date_time: Option<DateTime<Utc>>,
) -> Option<String> {
match (start_date_time, end_date_time) {
(Some(start), Some(end)) => Some(format!(
"This tariff is only valid from {} until {} (UTC).",
start.format("%Y-%m-%d %H:%M"),
end.format("%Y-%m-%d %H:%M"),
)),
(Some(start), None) => Some(format!(
"This tariff only becomes active on {} (UTC).",
start.format("%Y-%m-%d %H:%M"),
)),
(None, Some(end)) => Some(format!(
"This tariff is no longer valid from {} (UTC).",
end.format("%Y-%m-%d %H:%M"),
)),
(None, None) => None,
}
}
fn primary_scope(restrictions: &Restrictions, tier: TierBasis) -> Option<String> {
match tier {
TierBasis::Duration => duration_scope(restrictions),
TierBasis::Energy => energy_scope(restrictions),
}
}
fn secondary_scope(restrictions: &Restrictions, tier: TierBasis) -> Option<String> {
match tier {
TierBasis::Duration => energy_scope(restrictions),
TierBasis::Energy => duration_scope(restrictions),
}
}
fn duration_scope(restrictions: &Restrictions) -> Option<String> {
match (restrictions.min_duration, restrictions.max_duration) {
(None, Some(max)) => Some(format!("for the first {}", humanize_duration(max))),
(Some(min), None) => Some(format!("after the first {}", humanize_duration(min))),
(Some(min), Some(max)) => Some(format!(
"between {} and {} into the session",
humanize_duration(min),
humanize_duration(max)
)),
(None, None) => None,
}
}
fn energy_scope(restrictions: &Restrictions) -> Option<String> {
match (restrictions.min_kwh, restrictions.max_kwh) {
(None, Some(max)) => Some(format!("for the first {}", kwh(max))),
(Some(min), None) => Some(format!("after the first {}", kwh(min))),
(Some(min), Some(max)) => Some(format!("from {} to {}", kwh(min), kwh(max))),
(None, None) => None,
}
}
fn restriction_parts(restrictions: &Restrictions) -> Vec<String> {
let mut parts = Vec::new();
match (restrictions.start_time, restrictions.end_time) {
(Some(start), Some(end)) if start == end => {
parts.push(format!(
"never (its window {} to {} is empty)",
hm(start),
hm(end)
));
}
(Some(start), Some(end)) if end < start => {
parts.push(format!(
"between {} and {} the next day",
hm(start),
hm(end)
));
}
(Some(start), Some(end)) => parts.push(format!("between {} and {}", hm(start), hm(end))),
(Some(start), None) => parts.push(format!("from {} onwards", hm(start))),
(None, Some(end)) => parts.push(format!("before {}", hm(end))),
(None, None) => {}
}
if let Some(days) = restrictions.day_of_week.as_ref().filter(|d| !d.is_empty()) {
let names: Vec<&str> = days.iter().copied().map(weekday_name).collect();
parts.push(format!("on {}", names.join(", ")));
}
match (restrictions.start_date, restrictions.end_date) {
(Some(start), Some(end)) => parts.push(format!("from {start} until {end}")),
(Some(start), None) => parts.push(format!("from {start} onwards")),
(None, Some(end)) => parts.push(format!("until {end}")),
(None, None) => {}
}
if let Some(min) = restrictions.min_power {
parts.push(format!("while charging at {} or more", kw(min)));
}
if let Some(max) = restrictions.max_power {
parts.push(format!("while charging below {}", kw(max)));
}
if let Some(min) = restrictions.min_current {
parts.push(format!("at {} or more", ampere(min)));
}
if let Some(max) = restrictions.max_current {
parts.push(format!("below {}", ampere(max)));
}
parts
}
fn capitalize_first(text: &str) -> String {
let mut chars = text.chars();
match chars.next() {
Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
None => String::new(),
}
}
fn is_free(money: Money) -> bool {
Decimal::from(money) == Decimal::ZERO
}
fn money(money: Money, currency: currency::Code) -> String {
let amount = Decimal::from(money);
let symbol = currency.into_symbol();
if amount != Decimal::ZERO && amount.round_dp(2) == Decimal::ZERO {
format!("{symbol}{}", amount.normalize())
} else {
format!("{symbol}{amount:.2}")
}
}
fn price(price: Price, currency: currency::Code) -> String {
match price.incl_vat {
Some(incl) => {
format!(
"{} ({} incl. VAT)",
money(price.excl_vat, currency),
money(incl, currency)
)
}
None => money(price.excl_vat, currency),
}
}
fn kwh(value: Kwh) -> String {
format!("{} kWh", Decimal::from(value).normalize())
}
fn kw(value: Kw) -> String {
format!("{} kW", Decimal::from(value).normalize())
}
fn ampere(value: Ampere) -> String {
format!("{} A", Decimal::from(value).normalize())
}
fn hm(time: NaiveTime) -> String {
time.format("%H:%M").to_string()
}
fn vat_clause(vat: VatOrigin) -> String {
match vat {
VatOrigin::Unknown | VatOrigin::NotProvided => String::new(),
VatOrigin::Provided(vat) => format!(" (excl. {} VAT)", Decimal::from(vat).normalize()),
}
}
fn humanize_duration(duration: TimeDelta) -> String {
let total_seconds = duration.num_seconds().max(0);
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
let mut parts = Vec::new();
if hours > 0 {
parts.push(unit(hours, "hour"));
}
if minutes > 0 {
parts.push(unit(minutes, "minute"));
}
if seconds > 0 {
parts.push(unit(seconds, "second"));
}
if parts.is_empty() {
"0 seconds".to_owned()
} else {
parts.join(" ")
}
}
fn unit(count: i64, noun: &str) -> String {
if count == 1 {
format!("1 {noun}")
} else {
format!("{count} {noun}s")
}
}
fn weekday_name(day: Weekday) -> &'static str {
match day {
Weekday::Monday => "Monday",
Weekday::Tuesday => "Tuesday",
Weekday::Wednesday => "Wednesday",
Weekday::Thursday => "Thursday",
Weekday::Friday => "Friday",
Weekday::Saturday => "Saturday",
Weekday::Sunday => "Sunday",
}
}