1use chrono::{DateTime, NaiveDate, NaiveTime, TimeDelta, Utc};
10use rust_decimal::Decimal;
11
12use crate::{
13 currency, money::VatOrigin, tariff::v2x::DimensionType, Ampere, Kw, Kwh, Money, Price, Weekday,
14};
15
16use super::ir::{
17 Body, Bounds, Condition, ConditionPart, Dimension, Explanation, Fallback, Flat, FlatFee, Rate,
18 Scope, Section, TimeWindow, Validity,
19};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Language {
24 EnUS,
26
27 NlNl,
29}
30
31pub(super) fn render(explanation: &Explanation, language: Language) -> String {
33 let currency = explanation.currency;
34
35 match &explanation.body {
36 Body::Fallback(reason) => language.fallback(reason),
37 Body::Sections(sections) => sections
38 .iter()
39 .map(|section| render_section(section, currency, language))
40 .collect::<Vec<_>>()
41 .join("\n\n"),
42 }
43}
44
45fn render_section(section: &Section, currency: currency::Code, language: Language) -> String {
47 match section {
48 Section::Dimension(dimension) => render_dimension(dimension, currency, language),
49 Section::Flat(flat) => render_flat(flat, currency, language),
50 Section::Bounds(bounds) => language.bounds(bounds, currency),
51 Section::Validity(validity) => language.validity(validity),
52 }
53}
54
55fn render_dimension(dimension: &Dimension, currency: currency::Code, language: Language) -> String {
58 let subject = language.dimension_subject(dimension.kind);
59
60 let entries: Vec<(String, String)> = dimension
64 .tiers
65 .iter()
66 .map(|tier| {
67 let condition = render_condition(&tier.condition, dimension.kind, language);
68 let body = language.rate_body(&tier.rate, dimension.kind, currency);
69 let step = tier
70 .step
71 .map(|step| language.inline_step_note(step, dimension.kind))
72 .unwrap_or_default();
73 (condition, format!("{body}{step}"))
74 })
75 .collect();
76
77 let section = render_tier_list(subject, &entries);
78
79 let section = match dimension.uniform_step {
80 Some(step) => format!(
81 "{section}\n\n_{}_",
82 language.dimension_step_note(step, dimension.kind)
83 ),
84 None => section,
85 };
86
87 if dimension.dropped_unreachable {
88 format!("{section}\n\n_{}_", language.dropped_tiers_note())
89 } else {
90 section
91 }
92}
93
94fn render_flat(flat: &Flat, currency: currency::Code, language: Language) -> String {
96 let label = language.flat_fee_label();
97
98 let entries: Vec<(String, String)> = flat
99 .tiers
100 .iter()
101 .map(|tier| {
102 let condition = render_condition(&tier.condition, DimensionType::Flat, language);
103 let fee = language.flat_fee_body(&tier.fee, currency);
104 (condition, fee)
105 })
106 .collect();
107
108 render_tier_list(label, &entries)
109}
110
111fn render_tier_list(label: &str, entries: &[(String, String)]) -> String {
117 match entries {
118 [(condition, body)] if condition.is_empty() => format!("**{label}:** {body}."),
119 [(condition, body)] => format!("**{label}:** {condition}, {body}."),
120 _ => {
121 let bullets: Vec<String> = entries
122 .iter()
123 .map(|(condition, body)| format!("- {}: {body}", capitalize_first(condition)))
124 .collect();
125 format!("**{label}:**\n\n{}", bullets.join("\n"))
126 }
127 }
128}
129
130fn render_condition(condition: &Condition, kind: DimensionType, language: Language) -> String {
133 match condition {
134 Condition::Always => String::new(),
135 Condition::Otherwise => language.otherwise().to_owned(),
136 Condition::Remaining => language.remaining(kind),
137 Condition::When(parts) => parts
138 .iter()
139 .map(|part| language.condition_part(part))
140 .collect::<Vec<_>>()
141 .join(", "),
142 }
143}
144
145fn capitalize_first(text: &str) -> String {
147 let mut chars = text.chars();
148 match chars.next() {
149 Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
150 None => String::new(),
151 }
152}
153
154impl Language {
155 fn dimension_subject(self, kind: DimensionType) -> &'static str {
157 match kind {
158 DimensionType::Energy => match self {
159 Language::EnUS => "Energy",
160 Language::NlNl => "Energietarief",
161 },
162 DimensionType::Time => match self {
163 Language::EnUS => "Charging time",
164 Language::NlNl => "Tarief tijdens het laden",
165 },
166 DimensionType::ParkingTime => match self {
167 Language::EnUS => "Idle time (connected but not charging)",
168 Language::NlNl => "Parkeertijd (aangesloten maar niet aan het laden)",
169 },
170 DimensionType::Flat => "",
172 }
173 }
174
175 fn dimension_unit(self, kind: DimensionType) -> &'static str {
177 match kind {
178 DimensionType::Energy => "per kWh",
179 DimensionType::Time | DimensionType::ParkingTime => match self {
180 Language::EnUS => "per hour",
181 Language::NlNl => "per uur",
182 },
183 DimensionType::Flat => "",
184 }
185 }
186
187 fn remaining(self, kind: DimensionType) -> String {
190 let phrase = match kind {
191 DimensionType::Energy => match self {
192 Language::EnUS => "for the remaining energy",
193 Language::NlNl => "de resterende energie",
194 },
195 DimensionType::Time => match self {
196 Language::EnUS => "for the remaining charging time",
197 Language::NlNl => "de resterende laadtijd",
198 },
199 DimensionType::ParkingTime => match self {
200 Language::EnUS => "for the remaining idle time",
201 Language::NlNl => "de resterende parkeertijd",
202 },
203 DimensionType::Flat => "",
204 };
205 phrase.to_owned()
206 }
207
208 fn otherwise(self) -> &'static str {
210 match self {
211 Language::EnUS => "otherwise",
212 Language::NlNl => "anders",
213 }
214 }
215
216 fn flat_fee_label(self) -> &'static str {
218 match self {
219 Language::EnUS => "Flat fee",
220 Language::NlNl => "Vast tarief",
221 }
222 }
223
224 fn rate_body(self, rate: &Rate, kind: DimensionType, currency: currency::Code) -> String {
226 match rate {
227 Rate::Free => match self {
228 Language::EnUS => "free".to_owned(),
229 Language::NlNl => "gratis".to_owned(),
230 },
231 Rate::Priced { amount, vat } => format!(
232 "{} {}{}",
233 self.money(*amount, currency),
234 self.dimension_unit(kind),
235 self.vat_clause(*vat)
236 ),
237 }
238 }
239
240 fn flat_fee_body(self, fee: &FlatFee, currency: currency::Code) -> String {
242 match fee {
243 FlatFee::NoFee => match self {
244 Language::EnUS => "no fee".to_owned(),
245 Language::NlNl => "geen kosten".to_owned(),
246 },
247 FlatFee::Charged { amount, vat } => {
248 let per_session = match self {
249 Language::EnUS => "per session",
250 Language::NlNl => "per sessie",
251 };
252 format!(
253 "{} {per_session}{}",
254 self.money(*amount, currency),
255 self.vat_clause(*vat)
256 )
257 }
258 }
259 }
260
261 fn condition_part(self, part: &ConditionPart) -> String {
263 match part {
264 ConditionPart::TimeWindow(window) => self.time_window(window),
265 ConditionPart::Weekdays(days) => {
266 let names: Vec<&str> = days.iter().copied().map(|day| self.weekday(day)).collect();
267 let on = match self {
268 Language::EnUS => "on",
269 Language::NlNl => "op",
270 };
271 format!("{on} {}", names.join(", "))
272 }
273 ConditionPart::DateRange { start, end } => self.date_range(*start, *end),
274 ConditionPart::MinPower(power) => match self {
275 Language::EnUS => format!("while charging at {} or more", self.kw(*power)),
276 Language::NlNl => format!("bij laden op {} of meer", self.kw(*power)),
277 },
278 ConditionPart::MaxPower(power) => match self {
279 Language::EnUS => format!("while charging below {}", self.kw(*power)),
280 Language::NlNl => format!("bij laden onder {}", self.kw(*power)),
281 },
282 ConditionPart::MinCurrent(current) => match self {
283 Language::EnUS => format!("at {} or more", self.ampere(*current)),
284 Language::NlNl => format!("bij {} of meer", self.ampere(*current)),
285 },
286 ConditionPart::MaxCurrent(current) => match self {
287 Language::EnUS => format!("below {}", self.ampere(*current)),
288 Language::NlNl => format!("onder {}", self.ampere(*current)),
289 },
290 ConditionPart::DurationScope(scope) => self.duration_scope(scope),
291 ConditionPart::EnergyScope(scope) => self.energy_scope(scope),
292 }
293 }
294
295 fn time_window(self, window: &TimeWindow) -> String {
297 match (self, window) {
298 (Language::EnUS, TimeWindow::Empty { start, end }) => {
299 format!("never (its window {} to {} is empty)", hm(*start), hm(*end))
300 }
301 (Language::EnUS, TimeWindow::Wrapping { start, end }) => {
302 format!("between {} and {} the next day", hm(*start), hm(*end))
303 }
304 (Language::EnUS, TimeWindow::Between { start, end }) => {
305 format!("between {} and {}", hm(*start), hm(*end))
306 }
307 (Language::EnUS, TimeWindow::From { start }) => {
308 format!("from {} onwards", hm(*start))
309 }
310 (Language::EnUS, TimeWindow::Before { end }) => format!("before {}", hm(*end)),
311 (Language::NlNl, TimeWindow::Empty { start, end }) => {
312 format!(
313 "nooit (het venster {} tot {} is leeg)",
314 hm(*start),
315 hm(*end)
316 )
317 }
318 (Language::NlNl, TimeWindow::Wrapping { start, end }) => {
319 format!("tussen {} en {} de volgende dag", hm(*start), hm(*end))
320 }
321 (Language::NlNl, TimeWindow::Between { start, end }) => {
322 format!("tussen {} en {}", hm(*start), hm(*end))
323 }
324 (Language::NlNl, TimeWindow::From { start }) => format!("vanaf {}", hm(*start)),
325 (Language::NlNl, TimeWindow::Before { end }) => format!("voor {}", hm(*end)),
326 }
327 }
328
329 fn date_range(self, start: Option<NaiveDate>, end: Option<NaiveDate>) -> String {
331 match (start, end) {
332 (Some(start), Some(end)) => match self {
333 Language::EnUS => format!("from {} until {}", date(start), date(end)),
334 Language::NlNl => format!("van {} tot {}", date(start), date(end)),
335 },
336 (Some(start), None) => match self {
337 Language::EnUS => format!("from {} onwards", date(start)),
338 Language::NlNl => format!("vanaf {}", date(start)),
339 },
340 (None, Some(end)) => match self {
341 Language::EnUS => format!("until {}", date(end)),
342 Language::NlNl => format!("tot {}", date(end)),
343 },
344 (None, None) => String::new(),
346 }
347 }
348
349 fn duration_scope(self, scope: &Scope<TimeDelta>) -> String {
351 match (self, scope) {
352 (Language::EnUS, Scope::UpTo(max)) => {
353 format!("for the first {}", self.duration(*max))
354 }
355 (Language::EnUS, Scope::After(min)) => {
356 format!("after the first {}", self.duration(*min))
357 }
358 (Language::EnUS, Scope::Between(min, max)) => format!(
359 "between {} and {} into the session",
360 self.duration(*min),
361 self.duration(*max)
362 ),
363 (Language::NlNl, Scope::UpTo(max)) => {
364 format!("het eerste {}", self.duration(*max))
365 }
366 (Language::NlNl, Scope::After(min)) => format!("na de eerste {}", self.duration(*min)),
367 (Language::NlNl, Scope::Between(min, max)) => format!(
368 "tussen {} en {} tijdens de sessie",
369 self.duration(*min),
370 self.duration(*max)
371 ),
372 }
373 }
374
375 fn energy_scope(self, scope: &Scope<Kwh>) -> String {
377 match (self, scope) {
378 (Language::EnUS, Scope::UpTo(max)) => format!("for the first {}", self.kwh(*max)),
379 (Language::EnUS, Scope::After(min)) => format!("after the first {}", self.kwh(*min)),
380 (Language::EnUS, Scope::Between(min, max)) => {
381 format!("from {} to {}", self.kwh(*min), self.kwh(*max))
382 }
383 (Language::NlNl, Scope::UpTo(max)) => format!("het eerste {}", self.kwh(*max)),
384 (Language::NlNl, Scope::After(min)) => format!("na de eerste {}", self.kwh(*min)),
385 (Language::NlNl, Scope::Between(min, max)) => {
386 format!("van {} tot {}", self.kwh(*min), self.kwh(*max))
387 }
388 }
389 }
390
391 fn bounds(self, bounds: &Bounds, currency: currency::Code) -> String {
393 let mut sentences = Vec::new();
394
395 if let Some(min) = bounds.min {
396 sentences.push(match self {
397 Language::EnUS => {
398 format!(
399 "A session always costs at least {}.",
400 self.price(min, currency)
401 )
402 }
403 Language::NlNl => {
404 format!(
405 "Een sessie kost altijd minstens {}.",
406 self.price(min, currency)
407 )
408 }
409 });
410 }
411 if let Some(max) = bounds.max {
412 sentences.push(match self {
413 Language::EnUS => {
414 format!(
415 "A session never costs more than {}.",
416 self.price(max, currency)
417 )
418 }
419 Language::NlNl => {
420 format!(
421 "Een sessie kost nooit meer dan {}.",
422 self.price(max, currency)
423 )
424 }
425 });
426 }
427
428 sentences.join(" ")
429 }
430
431 fn validity(self, validity: &Validity) -> String {
433 match (self, validity) {
434 (Language::EnUS, Validity::Between { start, end }) => format!(
435 "This tariff is only valid from {} until {} (UTC).",
436 datetime(*start),
437 datetime(*end)
438 ),
439 (Language::EnUS, Validity::From { start }) => format!(
440 "This tariff only becomes active on {} (UTC).",
441 datetime(*start)
442 ),
443 (Language::EnUS, Validity::Until { end }) => format!(
444 "This tariff is no longer valid from {} (UTC).",
445 datetime(*end)
446 ),
447 (Language::NlNl, Validity::Between { start, end }) => format!(
448 "Dit tarief is alleen geldig van {} tot {} (UTC).",
449 datetime(*start),
450 datetime(*end)
451 ),
452 (Language::NlNl, Validity::From { start }) => {
453 format!("Dit tarief wordt pas actief op {} (UTC).", datetime(*start))
454 }
455 (Language::NlNl, Validity::Until { end }) => {
456 format!(
457 "Dit tarief is niet langer geldig vanaf {} (UTC).",
458 datetime(*end)
459 )
460 }
461 }
462 }
463
464 fn fallback(self, reason: &Fallback) -> String {
466 match (self, reason) {
467 (Language::EnUS, Fallback::ReservationOnly) => {
468 "This tariff never charges a regular charging session: every element applies only \
469 to reservation sessions."
470 .to_owned()
471 }
472 (Language::EnUS, Fallback::NoPriceComponents) => {
473 "This tariff charges nothing: none of its applicable elements define a price \
474 component."
475 .to_owned()
476 }
477 (Language::EnUS, Fallback::FreeFlatOnly) => {
478 "This tariff is free: its only charge is a flat fee of zero.".to_owned()
479 }
480 (Language::NlNl, Fallback::ReservationOnly) => {
481 "Dit tarief brengt nooit kosten in rekening voor een gewone laadsessie: elk element \
482 geldt alleen voor reserveringssessies."
483 .to_owned()
484 }
485 (Language::NlNl, Fallback::NoPriceComponents) => {
486 "Dit tarief brengt niets in rekening: geen van de toepasselijke elementen \
487 definieert een prijscomponent."
488 .to_owned()
489 }
490 (Language::NlNl, Fallback::FreeFlatOnly) => {
491 "Dit tarief is gratis: er zijn geen kosten.".to_owned()
492 }
493 }
494 }
495
496 fn dimension_step_note(self, step_size: u64, kind: DimensionType) -> String {
499 let magnitude = self.step_magnitude(step_size, kind);
500 match self {
501 Language::EnUS => format!("Billed in steps of {magnitude}, rounded up."),
502 Language::NlNl => format!("Berekend in stappen van {magnitude}, naar boven afgerond."),
503 }
504 }
505
506 fn inline_step_note(self, step_size: u64, kind: DimensionType) -> String {
508 let magnitude = self.step_magnitude(step_size, kind);
509 match self {
510 Language::EnUS => format!(" (billed in steps of {magnitude}, rounded up)"),
511 Language::NlNl => {
512 format!(" (berekend in stappen van {magnitude}, naar boven afgerond)")
513 }
514 }
515 }
516
517 fn dropped_tiers_note(self) -> &'static str {
519 match self {
520 Language::EnUS => {
521 "Any later tiers never apply, because an earlier rate already matches every session."
522 }
523 Language::NlNl => {
524 "Latere niveaus gelden nooit, omdat een eerder tarief al op elke sessie van toepassing is."
525 }
526 }
527 }
528
529 fn step_magnitude(self, step_size: u64, kind: DimensionType) -> String {
536 match kind {
537 DimensionType::Energy => {
538 let kwh = Kwh::from_watt_hours(Decimal::from(step_size));
539 format!("{} kWh", self.decimal(Decimal::from(kwh).normalize()))
540 }
541 DimensionType::Time | DimensionType::ParkingTime => {
542 self.duration_seconds(i64::try_from(step_size).unwrap_or(i64::MAX))
543 }
544 DimensionType::Flat => String::new(),
545 }
546 }
547
548 fn money(self, money: Money, currency: currency::Code) -> String {
553 let amount = Decimal::from(money);
554 let symbol = currency.into_symbol();
555
556 let digits = if amount != Decimal::ZERO && amount.round_dp(2) == Decimal::ZERO {
557 self.decimal(amount.normalize())
558 } else {
559 self.decimal_fixed(amount)
560 };
561
562 match self {
563 Language::EnUS => format!("{symbol}{digits}"),
564 Language::NlNl => format!("{symbol} {digits}"),
565 }
566 }
567
568 fn price(self, price: Price, currency: currency::Code) -> String {
570 let incl_vat = match self {
571 Language::EnUS => "incl. VAT",
572 Language::NlNl => "incl. btw",
573 };
574 match price.incl_vat {
575 Some(incl) => format!(
576 "{} ({} {incl_vat})",
577 self.money(price.excl_vat, currency),
578 self.money(incl, currency)
579 ),
580 None => self.money(price.excl_vat, currency),
581 }
582 }
583
584 fn vat_clause(self, vat: VatOrigin) -> String {
586 match vat {
587 VatOrigin::Unknown | VatOrigin::NotProvided => String::new(),
589 VatOrigin::Provided(vat) => {
590 let percent = self.decimal(Decimal::from(vat).normalize());
591 match self {
592 Language::EnUS => format!(" (excl. {percent} VAT)"),
593 Language::NlNl => format!(" (excl. {percent} btw)"),
594 }
595 }
596 }
597 }
598
599 fn kwh(self, value: Kwh) -> String {
601 format!("{} kWh", self.decimal(Decimal::from(value).normalize()))
602 }
603
604 fn kw(self, value: Kw) -> String {
606 format!("{} kW", self.decimal(Decimal::from(value).normalize()))
607 }
608
609 fn ampere(self, value: Ampere) -> String {
611 format!("{} A", self.decimal(Decimal::from(value).normalize()))
612 }
613
614 fn duration(self, duration: TimeDelta) -> String {
616 self.duration_seconds(duration.num_seconds().max(0))
617 }
618
619 fn duration_seconds(self, total_seconds: i64) -> String {
621 let total_seconds = total_seconds.max(0);
622 let hours = total_seconds / 3600;
623 let minutes = (total_seconds % 3600) / 60;
624 let seconds = total_seconds % 60;
625
626 let mut parts = Vec::new();
627
628 if hours > 0 {
629 parts.push(self.time_unit(hours, TimeUnit::Hour));
630 }
631 if minutes > 0 {
632 parts.push(self.time_unit(minutes, TimeUnit::Minute));
633 }
634 if seconds > 0 {
635 parts.push(self.time_unit(seconds, TimeUnit::Second));
636 }
637
638 if parts.is_empty() {
639 self.time_unit(0, TimeUnit::Second)
640 } else {
641 parts.join(" ")
642 }
643 }
644
645 fn time_unit(self, count: i64, unit: TimeUnit) -> String {
647 let noun = match (self, unit) {
648 (Language::EnUS, TimeUnit::Hour) if count == 1 => "hour",
649 (Language::EnUS, TimeUnit::Hour) => "hours",
650 (Language::EnUS, TimeUnit::Minute) if count == 1 => "minute",
651 (Language::EnUS, TimeUnit::Minute) => "minutes",
652 (Language::EnUS, TimeUnit::Second) if count == 1 => "second",
653 (Language::EnUS, TimeUnit::Second) => "seconds",
654 (Language::NlNl, TimeUnit::Hour) => "uur",
656 (Language::NlNl, TimeUnit::Minute) if count == 1 => "minuut",
657 (Language::NlNl, TimeUnit::Minute) => "minuten",
658 (Language::NlNl, TimeUnit::Second) if count == 1 => "seconde",
659 (Language::NlNl, TimeUnit::Second) => "seconden",
660 };
661 format!("{count} {noun}")
662 }
663
664 fn weekday(self, day: Weekday) -> &'static str {
666 match (self, day) {
667 (Language::EnUS, Weekday::Monday) => "Monday",
668 (Language::EnUS, Weekday::Tuesday) => "Tuesday",
669 (Language::EnUS, Weekday::Wednesday) => "Wednesday",
670 (Language::EnUS, Weekday::Thursday) => "Thursday",
671 (Language::EnUS, Weekday::Friday) => "Friday",
672 (Language::EnUS, Weekday::Saturday) => "Saturday",
673 (Language::EnUS, Weekday::Sunday) => "Sunday",
674 (Language::NlNl, Weekday::Monday) => "maandag",
675 (Language::NlNl, Weekday::Tuesday) => "dinsdag",
676 (Language::NlNl, Weekday::Wednesday) => "woensdag",
677 (Language::NlNl, Weekday::Thursday) => "donderdag",
678 (Language::NlNl, Weekday::Friday) => "vrijdag",
679 (Language::NlNl, Weekday::Saturday) => "zaterdag",
680 (Language::NlNl, Weekday::Sunday) => "zondag",
681 }
682 }
683
684 fn decimal(self, value: Decimal) -> String {
686 let text = value.to_string();
687 match self {
688 Language::EnUS => text,
689 Language::NlNl => text.replace('.', ","),
690 }
691 }
692
693 fn decimal_fixed(self, value: Decimal) -> String {
695 let text = format!("{value:.2}");
696 match self {
697 Language::EnUS => text,
698 Language::NlNl => text.replace('.', ","),
699 }
700 }
701}
702
703#[derive(Clone, Copy)]
705enum TimeUnit {
706 Hour,
707 Minute,
708 Second,
709}
710
711fn hm(time: NaiveTime) -> String {
713 time.format("%H:%M").to_string()
714}
715
716fn date(value: NaiveDate) -> String {
718 value.to_string()
719}
720
721fn datetime(value: DateTime<Utc>) -> String {
723 value.format("%Y-%m-%d %H:%M").to_string()
724}