ocpi-tariffs 0.51.0

OCPI tariff calculations
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
//! Render an [`Explanation`] into Markdown prose in a chosen [`Language`].
//!
//! [`build`](super::tariff::build) has already made every semantic decision, so this module only
//! chooses words, number formatting and word order. The Markdown skeleton (bold labels, bulleted
//! tier lists, paragraph spacing) is shared across languages because the supported languages are
//! all Germanic and phrase these structures alike; only the vocabulary carried by the [`Language`]
//! methods differs. To add a language, add a variant and fill in each method's match arm.

use chrono::{DateTime, NaiveDate, NaiveTime, TimeDelta, Utc};
use rust_decimal::Decimal;

use crate::{
    currency, money::VatOrigin, tariff::v2x::DimensionType, Ampere, Kw, Kwh, Money, Price, Weekday,
};

use super::ir::{
    Body, Bounds, Condition, ConditionPart, Dimension, Explanation, Fallback, Flat, FlatFee, Rate,
    Scope, Section, TimeWindow, Validity,
};

/// A language an explanation can be rendered in. All supported languages are Germanic.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Language {
    /// English (`en-US`).
    EnUS,

    /// Dutch (`nl-NL`).
    NlNl,
}

/// Render a built explanation as Markdown in the given language.
pub(super) fn render(explanation: &Explanation, language: Language) -> String {
    let currency = explanation.currency;

    match &explanation.body {
        Body::Fallback(reason) => language.fallback(reason),
        Body::Sections(sections) => sections
            .iter()
            .map(|section| render_section(section, currency, language))
            .collect::<Vec<_>>()
            .join("\n\n"),
    }
}

/// Render one top-level section as its own paragraph (or bulleted block).
fn render_section(section: &Section, currency: currency::Code, language: Language) -> String {
    match section {
        Section::Dimension(dimension) => render_dimension(dimension, currency, language),
        Section::Flat(flat) => render_flat(flat, currency, language),
        Section::Bounds(bounds) => language.bounds(bounds, currency),
        Section::Validity(validity) => language.validity(validity),
    }
}

/// Render a metered dimension: a single tier reads as one line, several tiers as a bulleted list,
/// followed by any shared billing-step note and unreachable-tier note.
fn render_dimension(dimension: &Dimension, currency: currency::Code, language: Language) -> String {
    let subject = language.dimension_subject(dimension.kind);

    // Each tier becomes (condition, rate body). A per-tier billing-step note is folded onto the
    // body; it is only ever present when the dimension has several tiers, so it never shows on the
    // single-line form.
    let entries: Vec<(String, String)> = dimension
        .tiers
        .iter()
        .map(|tier| {
            let condition = render_condition(&tier.condition, dimension.kind, language);
            let body = language.rate_body(&tier.rate, dimension.kind, currency);
            let step = tier
                .step
                .map(|step| language.inline_step_note(step, dimension.kind))
                .unwrap_or_default();
            (condition, format!("{body}{step}"))
        })
        .collect();

    let section = render_tier_list(subject, &entries);

    let section = match dimension.uniform_step {
        Some(step) => format!(
            "{section}\n\n_{}_",
            language.dimension_step_note(step, dimension.kind)
        ),
        None => section,
    };

    if dimension.dropped_unreachable {
        format!("{section}\n\n_{}_", language.dropped_tiers_note())
    } else {
        section
    }
}

/// Render the flat fee: a single tier reads as one line, several as a bulleted list.
fn render_flat(flat: &Flat, currency: currency::Code, language: Language) -> String {
    let label = language.flat_fee_label();

    let entries: Vec<(String, String)> = flat
        .tiers
        .iter()
        .map(|tier| {
            let condition = render_condition(&tier.condition, DimensionType::Flat, language);
            let fee = language.flat_fee_body(&tier.fee, currency);
            (condition, fee)
        })
        .collect();

    render_tier_list(label, &entries)
}

/// Render a labelled list of `(condition, body)` tiers as a Markdown section.
///
/// A single unconditional tier reads as `**Label:** body.`; a single conditional tier as
/// `**Label:** condition, body.`; and several tiers as a bulleted list, one `- Condition: body` per
/// tier. Shared by the metered dimensions and the flat fee so they lay out identically.
fn render_tier_list(label: &str, entries: &[(String, String)]) -> String {
    match entries {
        [(condition, body)] if condition.is_empty() => format!("**{label}:** {body}."),
        [(condition, body)] => format!("**{label}:** {condition}, {body}."),
        _ => {
            let bullets: Vec<String> = entries
                .iter()
                .map(|(condition, body)| format!("- {}: {body}", capitalize_first(condition)))
                .collect();
            format!("**{label}:**\n\n{}", bullets.join("\n"))
        }
    }
}

/// Render a tier's condition. `kind` is only used by the "remaining ..." catch-all, which names the
/// dimension's own quantity.
fn render_condition(condition: &Condition, kind: DimensionType, language: Language) -> String {
    match condition {
        Condition::Always => String::new(),
        Condition::Otherwise => language.otherwise().to_owned(),
        Condition::Remaining => language.remaining(kind),
        Condition::When(parts) => parts
            .iter()
            .map(|part| language.condition_part(part))
            .collect::<Vec<_>>()
            .join(", "),
    }
}

/// Capitalize the first character of a string, leaving the rest untouched.
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(),
    }
}

impl Language {
    /// The sentence subject / label for a metered dimension, e.g. "Charging time".
    fn dimension_subject(self, kind: DimensionType) -> &'static str {
        match kind {
            DimensionType::Energy => match self {
                Language::EnUS => "Energy",
                Language::NlNl => "Energietarief",
            },
            DimensionType::Time => match self {
                Language::EnUS => "Charging time",
                Language::NlNl => "Tarief tijdens het laden",
            },
            DimensionType::ParkingTime => match self {
                Language::EnUS => "Idle time (connected but not charging)",
                Language::NlNl => "Parkeertijd (aangesloten maar niet aan het laden)",
            },
            // The flat fee is not a metered dimension; it has its own label.
            DimensionType::Flat => "",
        }
    }

    /// The per-unit rate phrase for a metered dimension, e.g. "per hour".
    fn dimension_unit(self, kind: DimensionType) -> &'static str {
        match kind {
            DimensionType::Energy => "per kWh",
            DimensionType::Time | DimensionType::ParkingTime => match self {
                Language::EnUS => "per hour",
                Language::NlNl => "per uur",
            },
            DimensionType::Flat => "",
        }
    }

    /// The catch-all phrase naming the dimension's own remaining quantity, e.g. "for the remaining
    /// charging time".
    fn remaining(self, kind: DimensionType) -> String {
        let phrase = match kind {
            DimensionType::Energy => match self {
                Language::EnUS => "for the remaining energy",
                Language::NlNl => "de resterende energie",
            },
            DimensionType::Time => match self {
                Language::EnUS => "for the remaining charging time",
                Language::NlNl => "de resterende laadtijd",
            },
            DimensionType::ParkingTime => match self {
                Language::EnUS => "for the remaining idle time",
                Language::NlNl => "de resterende parkeertijd",
            },
            DimensionType::Flat => "",
        };
        phrase.to_owned()
    }

    /// The catch-all word used when an earlier tier was gated by a non-consumption qualifier.
    fn otherwise(self) -> &'static str {
        match self {
            Language::EnUS => "otherwise",
            Language::NlNl => "anders",
        }
    }

    /// The label for the flat-fee section.
    fn flat_fee_label(self) -> &'static str {
        match self {
            Language::EnUS => "Flat fee",
            Language::NlNl => "Vast tarief",
        }
    }

    /// The rate body of a metered tier: "free" or an amount with its per-unit phrase and VAT.
    fn rate_body(self, rate: &Rate, kind: DimensionType, currency: currency::Code) -> String {
        match rate {
            Rate::Free => match self {
                Language::EnUS => "free".to_owned(),
                Language::NlNl => "gratis".to_owned(),
            },
            Rate::Priced { amount, vat } => format!(
                "{} {}{}",
                self.money(*amount, currency),
                self.dimension_unit(kind),
                self.vat_clause(*vat)
            ),
        }
    }

    /// The fee body of a flat tier: "no fee" or an amount charged per session, with VAT.
    fn flat_fee_body(self, fee: &FlatFee, currency: currency::Code) -> String {
        match fee {
            FlatFee::NoFee => match self {
                Language::EnUS => "no fee".to_owned(),
                Language::NlNl => "geen kosten".to_owned(),
            },
            FlatFee::Charged { amount, vat } => {
                let per_session = match self {
                    Language::EnUS => "per session",
                    Language::NlNl => "per sessie",
                };
                format!(
                    "{} {per_session}{}",
                    self.money(*amount, currency),
                    self.vat_clause(*vat)
                )
            }
        }
    }

    /// Render one clause of a tier's condition as a lowercase phrase.
    fn condition_part(self, part: &ConditionPart) -> String {
        match part {
            ConditionPart::TimeWindow(window) => self.time_window(window),
            ConditionPart::Weekdays(days) => {
                let names: Vec<&str> = days.iter().copied().map(|day| self.weekday(day)).collect();
                let on = match self {
                    Language::EnUS => "on",
                    Language::NlNl => "op",
                };
                format!("{on} {}", names.join(", "))
            }
            ConditionPart::DateRange { start, end } => self.date_range(*start, *end),
            ConditionPart::MinPower(power) => match self {
                Language::EnUS => format!("while charging at {} or more", self.kw(*power)),
                Language::NlNl => format!("bij laden op {} of meer", self.kw(*power)),
            },
            ConditionPart::MaxPower(power) => match self {
                Language::EnUS => format!("while charging below {}", self.kw(*power)),
                Language::NlNl => format!("bij laden onder {}", self.kw(*power)),
            },
            ConditionPart::MinCurrent(current) => match self {
                Language::EnUS => format!("at {} or more", self.ampere(*current)),
                Language::NlNl => format!("bij {} of meer", self.ampere(*current)),
            },
            ConditionPart::MaxCurrent(current) => match self {
                Language::EnUS => format!("below {}", self.ampere(*current)),
                Language::NlNl => format!("onder {}", self.ampere(*current)),
            },
            ConditionPart::DurationScope(scope) => self.duration_scope(scope),
            ConditionPart::EnergyScope(scope) => self.energy_scope(scope),
        }
    }

    /// Render a time-of-day window clause.
    fn time_window(self, window: &TimeWindow) -> String {
        match (self, window) {
            (Language::EnUS, TimeWindow::Empty { start, end }) => {
                format!("never (its window {} to {} is empty)", hm(*start), hm(*end))
            }
            (Language::EnUS, TimeWindow::Wrapping { start, end }) => {
                format!("between {} and {} the next day", hm(*start), hm(*end))
            }
            (Language::EnUS, TimeWindow::Between { start, end }) => {
                format!("between {} and {}", hm(*start), hm(*end))
            }
            (Language::EnUS, TimeWindow::From { start }) => {
                format!("from {} onwards", hm(*start))
            }
            (Language::EnUS, TimeWindow::Before { end }) => format!("before {}", hm(*end)),
            (Language::NlNl, TimeWindow::Empty { start, end }) => {
                format!(
                    "nooit (het venster {} tot {} is leeg)",
                    hm(*start),
                    hm(*end)
                )
            }
            (Language::NlNl, TimeWindow::Wrapping { start, end }) => {
                format!("tussen {} en {} de volgende dag", hm(*start), hm(*end))
            }
            (Language::NlNl, TimeWindow::Between { start, end }) => {
                format!("tussen {} en {}", hm(*start), hm(*end))
            }
            (Language::NlNl, TimeWindow::From { start }) => format!("vanaf {}", hm(*start)),
            (Language::NlNl, TimeWindow::Before { end }) => format!("voor {}", hm(*end)),
        }
    }

    /// Render a calendar-date range clause. At least one of `start`/`end` is present.
    fn date_range(self, start: Option<NaiveDate>, end: Option<NaiveDate>) -> String {
        match (start, end) {
            (Some(start), Some(end)) => match self {
                Language::EnUS => format!("from {} until {}", date(start), date(end)),
                Language::NlNl => format!("van {} tot {}", date(start), date(end)),
            },
            (Some(start), None) => match self {
                Language::EnUS => format!("from {} onwards", date(start)),
                Language::NlNl => format!("vanaf {}", date(start)),
            },
            (None, Some(end)) => match self {
                Language::EnUS => format!("until {}", date(end)),
                Language::NlNl => format!("tot {}", date(end)),
            },
            // Never built with both ends absent.
            (None, None) => String::new(),
        }
    }

    /// Render an elapsed-session-duration scope clause.
    fn duration_scope(self, scope: &Scope<TimeDelta>) -> String {
        match (self, scope) {
            (Language::EnUS, Scope::UpTo(max)) => {
                format!("for the first {}", self.duration(*max))
            }
            (Language::EnUS, Scope::After(min)) => {
                format!("after the first {}", self.duration(*min))
            }
            (Language::EnUS, Scope::Between(min, max)) => format!(
                "between {} and {} into the session",
                self.duration(*min),
                self.duration(*max)
            ),
            (Language::NlNl, Scope::UpTo(max)) => {
                format!("het eerste {}", self.duration(*max))
            }
            (Language::NlNl, Scope::After(min)) => format!("na de eerste {}", self.duration(*min)),
            (Language::NlNl, Scope::Between(min, max)) => format!(
                "tussen {} en {} tijdens de sessie",
                self.duration(*min),
                self.duration(*max)
            ),
        }
    }

    /// Render a consumed-energy scope clause.
    fn energy_scope(self, scope: &Scope<Kwh>) -> String {
        match (self, scope) {
            (Language::EnUS, Scope::UpTo(max)) => format!("for the first {}", self.kwh(*max)),
            (Language::EnUS, Scope::After(min)) => format!("after the first {}", self.kwh(*min)),
            (Language::EnUS, Scope::Between(min, max)) => {
                format!("from {} to {}", self.kwh(*min), self.kwh(*max))
            }
            (Language::NlNl, Scope::UpTo(max)) => format!("het eerste {}", self.kwh(*max)),
            (Language::NlNl, Scope::After(min)) => format!("na de eerste {}", self.kwh(*min)),
            (Language::NlNl, Scope::Between(min, max)) => {
                format!("van {} tot {}", self.kwh(*min), self.kwh(*max))
            }
        }
    }

    /// The overall `min_price`/`max_price` bounds, as one or two sentences.
    fn bounds(self, bounds: &Bounds, currency: currency::Code) -> String {
        let mut sentences = Vec::new();

        if let Some(min) = bounds.min {
            sentences.push(match self {
                Language::EnUS => {
                    format!(
                        "A session always costs at least {}.",
                        self.price(min, currency)
                    )
                }
                Language::NlNl => {
                    format!(
                        "Een sessie kost altijd minstens {}.",
                        self.price(min, currency)
                    )
                }
            });
        }
        if let Some(max) = bounds.max {
            sentences.push(match self {
                Language::EnUS => {
                    format!(
                        "A session never costs more than {}.",
                        self.price(max, currency)
                    )
                }
                Language::NlNl => {
                    format!(
                        "Een sessie kost nooit meer dan {}.",
                        self.price(max, currency)
                    )
                }
            });
        }

        sentences.join(" ")
    }

    /// The validity window of the tariff itself.
    fn validity(self, validity: &Validity) -> String {
        match (self, validity) {
            (Language::EnUS, Validity::Between { start, end }) => format!(
                "This tariff is only valid from {} until {} (UTC).",
                datetime(*start),
                datetime(*end)
            ),
            (Language::EnUS, Validity::From { start }) => format!(
                "This tariff only becomes active on {} (UTC).",
                datetime(*start)
            ),
            (Language::EnUS, Validity::Until { end }) => format!(
                "This tariff is no longer valid from {} (UTC).",
                datetime(*end)
            ),
            (Language::NlNl, Validity::Between { start, end }) => format!(
                "Dit tarief is alleen geldig van {} tot {} (UTC).",
                datetime(*start),
                datetime(*end)
            ),
            (Language::NlNl, Validity::From { start }) => {
                format!("Dit tarief wordt pas actief op {} (UTC).", datetime(*start))
            }
            (Language::NlNl, Validity::Until { end }) => {
                format!(
                    "Dit tarief is niet langer geldig vanaf {} (UTC).",
                    datetime(*end)
                )
            }
        }
    }

    /// The reason a tariff produces no charging narrative.
    fn fallback(self, reason: &Fallback) -> String {
        match (self, reason) {
            (Language::EnUS, Fallback::ReservationOnly) => {
                "This tariff never charges a regular charging session: every element applies only \
                 to reservation sessions."
                    .to_owned()
            }
            (Language::EnUS, Fallback::NoPriceComponents) => {
                "This tariff charges nothing: none of its applicable elements define a price \
                 component."
                    .to_owned()
            }
            (Language::EnUS, Fallback::FreeFlatOnly) => {
                "This tariff is free: its only charge is a flat fee of zero.".to_owned()
            }
            (Language::NlNl, Fallback::ReservationOnly) => {
                "Dit tarief brengt nooit kosten in rekening voor een gewone laadsessie: elk element \
                 geldt alleen voor reserveringssessies."
                    .to_owned()
            }
            (Language::NlNl, Fallback::NoPriceComponents) => {
                "Dit tarief brengt niets in rekening: geen van de toepasselijke elementen \
                 definieert een prijscomponent."
                    .to_owned()
            }
            (Language::NlNl, Fallback::FreeFlatOnly) => {
                "Dit tarief is gratis: er zijn geen kosten.".to_owned()
            }
        }
    }

    /// The note describing a billing step shared by every tier of a dimension (without the
    /// surrounding italics).
    fn dimension_step_note(self, step_size: u64, kind: DimensionType) -> String {
        let magnitude = self.step_magnitude(step_size, kind);
        match self {
            Language::EnUS => format!("Billed in steps of {magnitude}, rounded up."),
            Language::NlNl => format!("Berekend in stappen van {magnitude}, naar boven afgerond."),
        }
    }

    /// The inline note describing a single tier's own billing step, as a trailing clause.
    fn inline_step_note(self, step_size: u64, kind: DimensionType) -> String {
        let magnitude = self.step_magnitude(step_size, kind);
        match self {
            Language::EnUS => format!(" (billed in steps of {magnitude}, rounded up)"),
            Language::NlNl => {
                format!(" (berekend in stappen van {magnitude}, naar boven afgerond)")
            }
        }
    }

    /// The note that unreachable tiers were dropped (without the surrounding italics).
    fn dropped_tiers_note(self) -> &'static str {
        match self {
            Language::EnUS => {
                "Any later tiers never apply, because an earlier rate already matches every session."
            }
            Language::NlNl => {
                "Latere niveaus gelden nooit, omdat een eerder tarief al op elke sessie van toepassing is."
            }
        }
    }

    /// The magnitude of a billing step in the unit appropriate to the dimension, e.g. "0.1 kWh" or
    /// "1 minute".
    ///
    /// The energy `step_size` is given in Wh by the spec, but it is shown in kWh because readers
    /// expect energy in kWh and tend to read "Wh" as a typo. Time steps are humanized (60 -> "1
    /// minute", 900 -> "15 minutes") rather than shown as a raw second count.
    fn step_magnitude(self, step_size: u64, kind: DimensionType) -> String {
        match kind {
            DimensionType::Energy => {
                let kwh = Kwh::from_watt_hours(Decimal::from(step_size));
                format!("{} kWh", self.decimal(Decimal::from(kwh).normalize()))
            }
            DimensionType::Time | DimensionType::ParkingTime => {
                self.duration_seconds(i64::try_from(step_size).unwrap_or(i64::MAX))
            }
            DimensionType::Flat => String::new(),
        }
    }

    /// Format a `Money` amount with its currency symbol.
    ///
    /// Two decimals reads best for ordinary prices, but a small nonzero rate must never collapse to
    /// a "0.00" string and read as free; in that case the value's own precision is used instead.
    fn money(self, money: Money, currency: currency::Code) -> String {
        let amount = Decimal::from(money);
        let symbol = currency.into_symbol();

        let digits = if amount != Decimal::ZERO && amount.round_dp(2) == Decimal::ZERO {
            self.decimal(amount.normalize())
        } else {
            self.decimal_fixed(amount)
        };

        match self {
            Language::EnUS => format!("{symbol}{digits}"),
            Language::NlNl => format!("{symbol} {digits}"),
        }
    }

    /// Format a `Price` (which may carry a VAT-inclusive value) with its currency symbol.
    fn price(self, price: Price, currency: currency::Code) -> String {
        let incl_vat = match self {
            Language::EnUS => "incl. VAT",
            Language::NlNl => "incl. btw",
        };
        match price.incl_vat {
            Some(incl) => format!(
                "{} ({} {incl_vat})",
                self.money(price.excl_vat, currency),
                self.money(incl, currency)
            ),
            None => self.money(price.excl_vat, currency),
        }
    }

    /// A trailing clause describing the VAT applied to a rate, or an empty string when none applies.
    fn vat_clause(self, vat: VatOrigin) -> String {
        match vat {
            // `v2.1.1` tariffs carry no VAT information, so saying nothing is the honest choice.
            VatOrigin::Unknown | VatOrigin::NotProvided => String::new(),
            VatOrigin::Provided(vat) => {
                let percent = self.decimal(Decimal::from(vat).normalize());
                match self {
                    Language::EnUS => format!(" (excl. {percent} VAT)"),
                    Language::NlNl => format!(" (excl. {percent} btw)"),
                }
            }
        }
    }

    /// Format a `Kwh` value without trailing zeros, e.g. "20 kWh".
    fn kwh(self, value: Kwh) -> String {
        format!("{} kWh", self.decimal(Decimal::from(value).normalize()))
    }

    /// Format a `Kw` value without trailing zeros, e.g. "11 kW".
    fn kw(self, value: Kw) -> String {
        format!("{} kW", self.decimal(Decimal::from(value).normalize()))
    }

    /// Format an `Ampere` value without trailing zeros, e.g. "16 A".
    fn ampere(self, value: Ampere) -> String {
        format!("{} A", self.decimal(Decimal::from(value).normalize()))
    }

    /// Render a duration as a friendly phrase such as "3 hours" or "1 hour 30 minutes".
    fn duration(self, duration: TimeDelta) -> String {
        self.duration_seconds(duration.num_seconds().max(0))
    }

    /// Render a whole number of seconds as a friendly phrase such as "3 hours" or "1 minute".
    fn duration_seconds(self, total_seconds: i64) -> String {
        let total_seconds = total_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(self.time_unit(hours, TimeUnit::Hour));
        }
        if minutes > 0 {
            parts.push(self.time_unit(minutes, TimeUnit::Minute));
        }
        if seconds > 0 {
            parts.push(self.time_unit(seconds, TimeUnit::Second));
        }

        if parts.is_empty() {
            self.time_unit(0, TimeUnit::Second)
        } else {
            parts.join(" ")
        }
    }

    /// Format a count with a singular/plural time unit, e.g. "1 hour" / "3 hours".
    fn time_unit(self, count: i64, unit: TimeUnit) -> String {
        let noun = match (self, unit) {
            (Language::EnUS, TimeUnit::Hour) if count == 1 => "hour",
            (Language::EnUS, TimeUnit::Hour) => "hours",
            (Language::EnUS, TimeUnit::Minute) if count == 1 => "minute",
            (Language::EnUS, TimeUnit::Minute) => "minutes",
            (Language::EnUS, TimeUnit::Second) if count == 1 => "second",
            (Language::EnUS, TimeUnit::Second) => "seconds",
            // Dutch "uur" does not inflect for number: "1 uur", "2 uur".
            (Language::NlNl, TimeUnit::Hour) => "uur",
            (Language::NlNl, TimeUnit::Minute) if count == 1 => "minuut",
            (Language::NlNl, TimeUnit::Minute) => "minuten",
            (Language::NlNl, TimeUnit::Second) if count == 1 => "seconde",
            (Language::NlNl, TimeUnit::Second) => "seconden",
        };
        format!("{count} {noun}")
    }

    /// The English name of a weekday; Dutch names are lowercase, as Dutch does not capitalize them.
    fn weekday(self, day: Weekday) -> &'static str {
        match (self, day) {
            (Language::EnUS, Weekday::Monday) => "Monday",
            (Language::EnUS, Weekday::Tuesday) => "Tuesday",
            (Language::EnUS, Weekday::Wednesday) => "Wednesday",
            (Language::EnUS, Weekday::Thursday) => "Thursday",
            (Language::EnUS, Weekday::Friday) => "Friday",
            (Language::EnUS, Weekday::Saturday) => "Saturday",
            (Language::EnUS, Weekday::Sunday) => "Sunday",
            (Language::NlNl, Weekday::Monday) => "maandag",
            (Language::NlNl, Weekday::Tuesday) => "dinsdag",
            (Language::NlNl, Weekday::Wednesday) => "woensdag",
            (Language::NlNl, Weekday::Thursday) => "donderdag",
            (Language::NlNl, Weekday::Friday) => "vrijdag",
            (Language::NlNl, Weekday::Saturday) => "zaterdag",
            (Language::NlNl, Weekday::Sunday) => "zondag",
        }
    }

    /// Render a normalized decimal, using the language's decimal separator.
    fn decimal(self, value: Decimal) -> String {
        let text = value.to_string();
        match self {
            Language::EnUS => text,
            Language::NlNl => text.replace('.', ","),
        }
    }

    /// Render a decimal fixed to two places, using the language's decimal separator.
    fn decimal_fixed(self, value: Decimal) -> String {
        let text = format!("{value:.2}");
        match self {
            Language::EnUS => text,
            Language::NlNl => text.replace('.', ","),
        }
    }
}

/// A unit of elapsed time, used to select the right singular/plural noun.
#[derive(Clone, Copy)]
enum TimeUnit {
    Hour,
    Minute,
    Second,
}

/// Format a `NaiveTime` as `HH:MM`. Shared across languages.
fn hm(time: NaiveTime) -> String {
    time.format("%H:%M").to_string()
}

/// Format a `NaiveDate` as an ISO `YYYY-MM-DD` date. Shared across languages for unambiguity.
fn date(value: NaiveDate) -> String {
    value.to_string()
}

/// Format a UTC instant as `YYYY-MM-DD HH:MM`. Shared across languages.
fn datetime(value: DateTime<Utc>) -> String {
    value.format("%Y-%m-%d %H:%M").to_string()
}