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 let section = if dimension.dropped_unreachable {
88 format!("{section}\n\n_{}_", language.dropped_tiers_note())
89 } else {
90 section
91 };
92
93 if dimension.dropped_shadowed {
94 format!("{section}\n\n_{}_", language.shadowed_tiers_note())
95 } else {
96 section
97 }
98}
99
100fn render_flat(flat: &Flat, currency: currency::Code, language: Language) -> String {
102 let label = language.flat_fee_label();
103
104 let entries: Vec<(String, String)> = flat
105 .tiers
106 .iter()
107 .map(|tier| {
108 let condition = render_condition(&tier.condition, DimensionType::Flat, language);
109 let fee = language.flat_fee_body(&tier.fee, currency);
110 (condition, fee)
111 })
112 .collect();
113
114 let section = render_tier_list(label, &entries);
115
116 if flat.dropped_shadowed {
117 format!("{section}\n\n_{}_", language.shadowed_tiers_note())
118 } else {
119 section
120 }
121}
122
123fn render_tier_list(label: &str, entries: &[(String, String)]) -> String {
129 match entries {
130 [(condition, body)] if condition.is_empty() => format!("**{label}:** {body}."),
131 [(condition, body)] => format!("**{label}:** {condition}, {body}."),
132 _ => {
133 let bullets: Vec<String> = entries
134 .iter()
135 .map(|(condition, body)| format!("- {}: {body}", capitalize_first(condition)))
136 .collect();
137 format!("**{label}:**\n\n{}", bullets.join("\n"))
138 }
139 }
140}
141
142fn render_condition(condition: &Condition, kind: DimensionType, language: Language) -> String {
145 match condition {
146 Condition::Always => String::new(),
147 Condition::Otherwise => language.otherwise().to_owned(),
148 Condition::Remaining => language.remaining(kind),
149 Condition::When(parts) => parts
150 .iter()
151 .map(|part| language.condition_part(part))
152 .collect::<Vec<_>>()
153 .join(", "),
154 }
155}
156
157fn capitalize_first(text: &str) -> String {
159 let mut chars = text.chars();
160 match chars.next() {
161 Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
162 None => String::new(),
163 }
164}
165
166impl Language {
167 fn dimension_subject(self, kind: DimensionType) -> &'static str {
169 match kind {
170 DimensionType::Energy => match self {
171 Language::EnUS => "Energy",
172 Language::NlNl => "Energietarief",
173 },
174 DimensionType::Time => match self {
175 Language::EnUS => "Charging time",
176 Language::NlNl => "Tarief tijdens het laden",
177 },
178 DimensionType::ParkingTime => match self {
179 Language::EnUS => "Idle time (connected but not charging)",
180 Language::NlNl => "Parkeertijd (aangesloten maar niet aan het laden)",
181 },
182 DimensionType::Flat => "",
184 }
185 }
186
187 fn dimension_unit(self, kind: DimensionType) -> &'static str {
189 match kind {
190 DimensionType::Energy => "per kWh",
191 DimensionType::Time | DimensionType::ParkingTime => match self {
192 Language::EnUS => "per hour",
193 Language::NlNl => "per uur",
194 },
195 DimensionType::Flat => "",
196 }
197 }
198
199 fn remaining(self, kind: DimensionType) -> String {
202 let phrase = match kind {
203 DimensionType::Energy => match self {
204 Language::EnUS => "for the remaining energy",
205 Language::NlNl => "de resterende energie",
206 },
207 DimensionType::Time => match self {
208 Language::EnUS => "for the remaining charging time",
209 Language::NlNl => "de resterende laadtijd",
210 },
211 DimensionType::ParkingTime => match self {
212 Language::EnUS => "for the remaining idle time",
213 Language::NlNl => "de resterende parkeertijd",
214 },
215 DimensionType::Flat => "",
216 };
217 phrase.to_owned()
218 }
219
220 fn otherwise(self) -> &'static str {
222 match self {
223 Language::EnUS => "otherwise",
224 Language::NlNl => "anders",
225 }
226 }
227
228 fn flat_fee_label(self) -> &'static str {
230 match self {
231 Language::EnUS => "Flat fee",
232 Language::NlNl => "Vast tarief",
233 }
234 }
235
236 fn rate_body(self, rate: &Rate, kind: DimensionType, currency: currency::Code) -> String {
238 match rate {
239 Rate::Free => match self {
240 Language::EnUS => "free".to_owned(),
241 Language::NlNl => "gratis".to_owned(),
242 },
243 Rate::Priced { amount, vat } => format!(
244 "{} {}{}",
245 self.money(*amount, currency),
246 self.dimension_unit(kind),
247 self.vat_clause(*vat)
248 ),
249 }
250 }
251
252 fn flat_fee_body(self, fee: &FlatFee, currency: currency::Code) -> String {
254 match fee {
255 FlatFee::NoFee => match self {
256 Language::EnUS => "no fee".to_owned(),
257 Language::NlNl => "geen kosten".to_owned(),
258 },
259 FlatFee::Charged { amount, vat } => {
260 let per_session = match self {
261 Language::EnUS => "per session",
262 Language::NlNl => "per sessie",
263 };
264 format!(
265 "{} {per_session}{}",
266 self.money(*amount, currency),
267 self.vat_clause(*vat)
268 )
269 }
270 }
271 }
272
273 fn condition_part(self, part: &ConditionPart) -> String {
275 match part {
276 ConditionPart::TimeWindow(window) => self.time_window(window),
277 ConditionPart::Weekdays(days) => {
278 let names: Vec<&str> = days.iter().copied().map(|day| self.weekday(day)).collect();
279 let on = match self {
280 Language::EnUS => "on",
281 Language::NlNl => "op",
282 };
283 format!("{on} {}", names.join(", "))
284 }
285 ConditionPart::DateRange { start, end } => self.date_range(*start, *end),
286 ConditionPart::MinPower(power) => match self {
287 Language::EnUS => format!("while charging at {} or more", self.kw(*power)),
288 Language::NlNl => format!("bij laadsnelheid van {} of meer", self.kw(*power)),
289 },
290 ConditionPart::MaxPower(power) => match self {
291 Language::EnUS => format!("while charging below {}", self.kw(*power)),
292 Language::NlNl => format!("bij laadsnelheid onder {}", self.kw(*power)),
293 },
294 ConditionPart::MinCurrent(current) => match self {
295 Language::EnUS => format!("at {} or more", self.ampere(*current)),
296 Language::NlNl => format!("bij {} of meer", self.ampere(*current)),
297 },
298 ConditionPart::MaxCurrent(current) => match self {
299 Language::EnUS => format!("below {}", self.ampere(*current)),
300 Language::NlNl => format!("onder {}", self.ampere(*current)),
301 },
302 ConditionPart::DurationScope(scope) => self.duration_scope(scope),
303 ConditionPart::EnergyScope(scope) => self.energy_scope(scope),
304 }
305 }
306
307 fn time_window(self, window: &TimeWindow) -> String {
309 match (self, window) {
310 (Language::EnUS, TimeWindow::Empty { start, end }) => {
311 format!("never (its window {} to {} is empty)", hm(*start), hm(*end))
312 }
313 (
314 Language::EnUS,
315 TimeWindow::Wrapping { start, end } | TimeWindow::Between { start, end },
316 ) => {
317 format!("between {} and {}", hm(*start), hm(*end))
318 }
319 (Language::EnUS, TimeWindow::From { start }) => {
320 format!("from {} onwards", hm(*start))
321 }
322 (Language::EnUS, TimeWindow::Before { end }) => format!("before {}", hm(*end)),
323 (Language::NlNl, TimeWindow::Empty { start, end }) => {
324 format!(
325 "nooit (het venster {} tot {} is leeg)",
326 hm(*start),
327 hm(*end)
328 )
329 }
330 (
331 Language::NlNl,
332 TimeWindow::Wrapping { start, end } | TimeWindow::Between { start, end },
333 ) => {
334 format!("tussen {} en {}", hm(*start), hm(*end))
335 }
336 (Language::NlNl, TimeWindow::From { start }) => format!("vanaf {}", hm(*start)),
337 (Language::NlNl, TimeWindow::Before { end }) => format!("voor {}", hm(*end)),
338 }
339 }
340
341 fn date_range(self, start: Option<NaiveDate>, end: Option<NaiveDate>) -> String {
343 match (start, end) {
344 (Some(start), Some(end)) => match self {
345 Language::EnUS => format!("from {} until {}", date(start), date(end)),
346 Language::NlNl => format!("van {} tot {}", date(start), date(end)),
347 },
348 (Some(start), None) => match self {
349 Language::EnUS => format!("from {} onwards", date(start)),
350 Language::NlNl => format!("vanaf {}", date(start)),
351 },
352 (None, Some(end)) => match self {
353 Language::EnUS => format!("until {}", date(end)),
354 Language::NlNl => format!("tot {}", date(end)),
355 },
356 (None, None) => String::new(),
358 }
359 }
360
361 fn duration_scope(self, scope: &Scope<TimeDelta>) -> String {
363 match (self, scope) {
364 (Language::EnUS, Scope::UpTo(max)) => {
365 format!("for the first {}", self.duration(*max))
366 }
367 (Language::EnUS, Scope::After(min)) => {
368 format!("after the first {}", self.duration(*min))
369 }
370 (Language::EnUS, Scope::Between(min, max)) => format!(
371 "between {} and {} into the session",
372 self.duration(*min),
373 self.duration(*max)
374 ),
375 (Language::NlNl, Scope::UpTo(max)) => {
376 format!("het eerste {}", self.duration(*max))
377 }
378 (Language::NlNl, Scope::After(min)) => format!("na de eerste {}", self.duration(*min)),
379 (Language::NlNl, Scope::Between(min, max)) => format!(
380 "tussen {} en {} tijdens de sessie",
381 self.duration(*min),
382 self.duration(*max)
383 ),
384 }
385 }
386
387 fn energy_scope(self, scope: &Scope<Kwh>) -> String {
389 match (self, scope) {
390 (Language::EnUS, Scope::UpTo(max)) => format!("for the first {}", self.kwh(*max)),
391 (Language::EnUS, Scope::After(min)) => format!("after the first {}", self.kwh(*min)),
392 (Language::EnUS, Scope::Between(min, max)) => {
393 format!("from {} to {}", self.kwh(*min), self.kwh(*max))
394 }
395 (Language::NlNl, Scope::UpTo(max)) => format!("het eerste {}", self.kwh(*max)),
396 (Language::NlNl, Scope::After(min)) => format!("na de eerste {}", self.kwh(*min)),
397 (Language::NlNl, Scope::Between(min, max)) => {
398 format!("van {} tot {}", self.kwh(*min), self.kwh(*max))
399 }
400 }
401 }
402
403 fn bounds(self, bounds: &Bounds, currency: currency::Code) -> String {
405 let mut sentences = Vec::new();
406
407 if let Some(min) = bounds.min {
408 sentences.push(match self {
409 Language::EnUS => {
410 format!(
411 "A session always costs at least {}.",
412 self.price(min, currency)
413 )
414 }
415 Language::NlNl => {
416 format!(
417 "Een sessie kost altijd minstens {}.",
418 self.price(min, currency)
419 )
420 }
421 });
422 }
423 if let Some(max) = bounds.max {
424 sentences.push(match self {
425 Language::EnUS => {
426 format!(
427 "A session never costs more than {}.",
428 self.price(max, currency)
429 )
430 }
431 Language::NlNl => {
432 format!(
433 "Een sessie kost nooit meer dan {}.",
434 self.price(max, currency)
435 )
436 }
437 });
438 }
439
440 sentences.join(" ")
441 }
442
443 fn validity(self, validity: &Validity) -> String {
445 match (self, validity) {
446 (Language::EnUS, Validity::Between { start, end }) => format!(
447 "This tariff is only valid from {} until {} (UTC).",
448 datetime(*start),
449 datetime(*end)
450 ),
451 (Language::EnUS, Validity::From { start }) => format!(
452 "This tariff only becomes active on {} (UTC).",
453 datetime(*start)
454 ),
455 (Language::EnUS, Validity::Until { end }) => format!(
456 "This tariff is no longer valid from {} (UTC).",
457 datetime(*end)
458 ),
459 (Language::NlNl, Validity::Between { start, end }) => format!(
460 "Dit tarief is alleen geldig van {} tot {} (UTC).",
461 datetime(*start),
462 datetime(*end)
463 ),
464 (Language::NlNl, Validity::From { start }) => {
465 format!("Dit tarief wordt pas actief op {} (UTC).", datetime(*start))
466 }
467 (Language::NlNl, Validity::Until { end }) => {
468 format!(
469 "Dit tarief is niet langer geldig vanaf {} (UTC).",
470 datetime(*end)
471 )
472 }
473 }
474 }
475
476 fn fallback(self, reason: &Fallback) -> String {
478 match (self, reason) {
479 (Language::EnUS, Fallback::ReservationOnly) => {
480 "This tariff never charges a regular charging session: every element applies only \
481 to reservation sessions."
482 .to_owned()
483 }
484 (Language::EnUS, Fallback::NoPriceComponents) => {
485 "This tariff charges nothing: none of its applicable elements define a price \
486 component."
487 .to_owned()
488 }
489 (Language::EnUS, Fallback::FreeFlatOnly) => {
490 "This tariff is free: its only charge is a flat fee of zero.".to_owned()
491 }
492 (Language::NlNl, Fallback::ReservationOnly) => {
493 "Dit tarief brengt nooit kosten in rekening voor een gewone laadsessie: elk element \
494 geldt alleen voor reserveringssessies."
495 .to_owned()
496 }
497 (Language::NlNl, Fallback::NoPriceComponents) => {
498 "Dit tarief brengt niets in rekening: geen van de toepasselijke elementen \
499 definieert een prijscomponent."
500 .to_owned()
501 }
502 (Language::NlNl, Fallback::FreeFlatOnly) => {
503 "Dit tarief is gratis: er zijn geen kosten.".to_owned()
504 }
505 }
506 }
507
508 fn dimension_step_note(self, step_size: u64, kind: DimensionType) -> String {
511 let magnitude = self.step_magnitude(step_size, kind);
512 match self {
513 Language::EnUS => format!("Billed in steps of {magnitude}, rounded up."),
514 Language::NlNl => format!("Berekend in stappen van {magnitude}, naar boven afgerond."),
515 }
516 }
517
518 fn inline_step_note(self, step_size: u64, kind: DimensionType) -> String {
520 let magnitude = self.step_magnitude(step_size, kind);
521 match self {
522 Language::EnUS => format!(" (billed in steps of {magnitude}, rounded up)"),
523 Language::NlNl => {
524 format!(" (berekend in stappen van {magnitude}, naar boven afgerond)")
525 }
526 }
527 }
528
529 fn dropped_tiers_note(self) -> &'static str {
531 match self {
532 Language::EnUS => {
533 "Any later tiers never apply, because an earlier rate already matches every session."
534 }
535 Language::NlNl => {
536 "Latere niveaus gelden nooit, omdat een eerder tarief al op elke sessie van toepassing is."
537 }
538 }
539 }
540
541 fn shadowed_tiers_note(self) -> &'static str {
543 match self {
544 Language::EnUS => {
545 "Tiers that never apply are not listed, because an earlier rate already covers the same dates."
546 }
547 Language::NlNl => {
548 "Niveaus die nooit gelden zijn niet vermeld, omdat een eerder tarief dezelfde periode al dekt."
549 }
550 }
551 }
552
553 fn step_magnitude(self, step_size: u64, kind: DimensionType) -> String {
560 match kind {
561 DimensionType::Energy => {
562 let kwh = Kwh::from_watt_hours(Decimal::from(step_size));
563 format!("{} kWh", self.decimal(Decimal::from(kwh).normalize()))
564 }
565 DimensionType::Time | DimensionType::ParkingTime => {
566 self.duration_seconds(i64::try_from(step_size).unwrap_or(i64::MAX))
567 }
568 DimensionType::Flat => String::new(),
569 }
570 }
571
572 fn money(self, money: Money, currency: currency::Code) -> String {
577 let amount = Decimal::from(money);
578
579 let symbol =
580 if let currency::Code::Eur | currency::Code::Gbp | currency::Code::Usd = currency {
581 currency.into_symbol()
582 } else {
583 currency.into_str()
584 };
585
586 let digits = if amount != Decimal::ZERO && amount.round_dp(2) == Decimal::ZERO {
587 self.decimal(amount.normalize())
588 } else {
589 self.decimal_fixed(amount)
590 };
591
592 match self {
593 Language::EnUS => format!("{symbol}{digits}"),
594 Language::NlNl => format!("{symbol} {digits}"),
595 }
596 }
597
598 fn price(self, price: Price, currency: currency::Code) -> String {
600 let incl_vat = match self {
601 Language::EnUS => "incl. VAT",
602 Language::NlNl => "incl. btw",
603 };
604 match price.incl_vat {
605 Some(incl) => format!(
606 "{} ({} {incl_vat})",
607 self.money(price.excl_vat, currency),
608 self.money(incl, currency)
609 ),
610 None => self.money(price.excl_vat, currency),
611 }
612 }
613
614 fn vat_clause(self, vat: VatOrigin) -> String {
616 match vat {
617 VatOrigin::Unknown | VatOrigin::NotProvided => String::new(),
619 VatOrigin::Provided(vat) => {
620 let percent = self.decimal(Decimal::from(vat).normalize());
621 match self {
622 Language::EnUS => format!(" (excl. {percent} VAT)"),
623 Language::NlNl => format!(" (excl. {percent} btw)"),
624 }
625 }
626 }
627 }
628
629 fn kwh(self, value: Kwh) -> String {
631 format!("{} kWh", self.decimal(Decimal::from(value).normalize()))
632 }
633
634 fn kw(self, value: Kw) -> String {
636 format!("{} kW", self.decimal(Decimal::from(value).normalize()))
637 }
638
639 fn ampere(self, value: Ampere) -> String {
641 format!("{} A", self.decimal(Decimal::from(value).normalize()))
642 }
643
644 fn duration(self, duration: TimeDelta) -> String {
646 self.duration_seconds(duration.num_seconds().max(0))
647 }
648
649 fn duration_seconds(self, total_seconds: i64) -> String {
651 let total_seconds = total_seconds.max(0);
652 let hours = total_seconds / 3600;
653 let minutes = (total_seconds % 3600) / 60;
654 let seconds = total_seconds % 60;
655
656 let mut parts = Vec::new();
657
658 if hours > 0 {
659 parts.push(self.time_unit(hours, TimeUnit::Hour));
660 }
661 if minutes > 0 {
662 parts.push(self.time_unit(minutes, TimeUnit::Minute));
663 }
664 if seconds > 0 {
665 parts.push(self.time_unit(seconds, TimeUnit::Second));
666 }
667
668 if parts.is_empty() {
669 self.time_unit(0, TimeUnit::Second)
670 } else {
671 parts.join(" ")
672 }
673 }
674
675 fn time_unit(self, count: i64, unit: TimeUnit) -> String {
677 let noun = match (self, unit) {
678 (Language::EnUS, TimeUnit::Hour) if count == 1 => "hour",
679 (Language::EnUS, TimeUnit::Hour) => "hours",
680 (Language::EnUS, TimeUnit::Minute) if count == 1 => "minute",
681 (Language::EnUS, TimeUnit::Minute) => "minutes",
682 (Language::EnUS, TimeUnit::Second) if count == 1 => "second",
683 (Language::EnUS, TimeUnit::Second) => "seconds",
684 (Language::NlNl, TimeUnit::Hour) => "uur",
686 (Language::NlNl, TimeUnit::Minute) if count == 1 => "minuut",
687 (Language::NlNl, TimeUnit::Minute) => "minuten",
688 (Language::NlNl, TimeUnit::Second) if count == 1 => "seconde",
689 (Language::NlNl, TimeUnit::Second) => "seconden",
690 };
691 format!("{count} {noun}")
692 }
693
694 fn weekday(self, day: Weekday) -> &'static str {
696 match (self, day) {
697 (Language::EnUS, Weekday::Monday) => "Monday",
698 (Language::EnUS, Weekday::Tuesday) => "Tuesday",
699 (Language::EnUS, Weekday::Wednesday) => "Wednesday",
700 (Language::EnUS, Weekday::Thursday) => "Thursday",
701 (Language::EnUS, Weekday::Friday) => "Friday",
702 (Language::EnUS, Weekday::Saturday) => "Saturday",
703 (Language::EnUS, Weekday::Sunday) => "Sunday",
704 (Language::NlNl, Weekday::Monday) => "maandag",
705 (Language::NlNl, Weekday::Tuesday) => "dinsdag",
706 (Language::NlNl, Weekday::Wednesday) => "woensdag",
707 (Language::NlNl, Weekday::Thursday) => "donderdag",
708 (Language::NlNl, Weekday::Friday) => "vrijdag",
709 (Language::NlNl, Weekday::Saturday) => "zaterdag",
710 (Language::NlNl, Weekday::Sunday) => "zondag",
711 }
712 }
713
714 fn decimal(self, value: Decimal) -> String {
716 let text = value.to_string();
717 match self {
718 Language::EnUS => text,
719 Language::NlNl => text.replace('.', ","),
720 }
721 }
722
723 fn decimal_fixed(self, value: Decimal) -> String {
725 let text = format!("{value:.2}");
726 match self {
727 Language::EnUS => text,
728 Language::NlNl => text.replace('.', ","),
729 }
730 }
731}
732
733#[derive(Clone, Copy)]
735enum TimeUnit {
736 Hour,
737 Minute,
738 Second,
739}
740
741fn hm(time: NaiveTime) -> String {
743 time.format("%H:%M").to_string()
744}
745
746fn date(value: NaiveDate) -> String {
748 value.to_string()
749}
750
751fn datetime(value: DateTime<Utc>) -> String {
753 value.format("%Y-%m-%d %H:%M").to_string()
754}