Skip to main content

ocpi_tariffs/
price.rs

1//! Price a CDR using a tariff and compare the prices embedded in the CDR with the prices computed here.
2
3#[cfg(test)]
4pub mod test;
5
6#[cfg(test)]
7mod test_normalize_periods;
8
9#[cfg(test)]
10mod test_periods;
11
12#[cfg(test)]
13mod test_real_world;
14
15#[cfg(test)]
16mod test_validate_cdr;
17
18#[cfg(test)]
19mod test_current_and_power_restrictions;
20
21#[cfg(test)]
22mod test_reservation_restriction;
23
24#[cfg(test)]
25mod test_min_max_price;
26
27#[cfg(test)]
28mod test_warning_path_map;
29
30mod tariff;
31pub(crate) mod v211;
32pub(crate) mod v221;
33
34use std::{collections::BTreeMap, fmt, ops::Range};
35
36use chrono::{DateTime, Datelike as _, TimeDelta, Utc};
37use chrono_tz::Tz;
38use rust_decimal::Decimal;
39use tariff::Tariff;
40use tracing::{debug, instrument, trace};
41
42use crate::{
43    country, currency, datetime,
44    duration::{self, AsHms as _, Hms},
45    from_warning_all, json,
46    money::{self, VatOrigin},
47    number::{self, RoundDecimal as _},
48    string,
49    warning::{
50        self, GatherDeferredWarnings as _, GatherWarnings as _, IntoCaveat as _,
51        IntoCaveatDeferred as _, WithElement as _,
52    },
53    Ampere, Caveat, Cost, DisplayOption, Kw, Kwh, Money, Price, SaturatingAdd as _,
54    SaturatingSub as _, Versioned as _,
55};
56
57pub type Verdict<T> = crate::Verdict<T, Warning>;
58type VerdictDeferred<T> = warning::VerdictDeferred<T, Warning>;
59
60/// A normalized/expanded form of a charging period to make the pricing calculation simpler.
61///
62/// The simplicity comes through avoiding having to look up the next period to figure out the end
63/// of the current period.
64#[derive(Debug)]
65struct PeriodNormalized {
66    /// The set of quantities consumed across the duration of the `Period`.
67    consumed: Consumed,
68
69    /// A snapshot of the values of various quantities at the start of the charge period.
70    start_snapshot: TotalsSnapshot,
71
72    /// A snapshot of the values of various quantities at the end of the charge period.
73    end_snapshot: TotalsSnapshot,
74}
75
76/// The set of quantities consumed across the duration of the `Period`.
77#[derive(Clone)]
78#[cfg_attr(test, derive(Default))]
79pub(crate) struct Consumed {
80    /// The peak current during this period.
81    pub current_max: Option<Ampere>,
82
83    /// The lowest current during this period.
84    pub current_min: Option<Ampere>,
85
86    /// The charging time consumed in this period.
87    pub duration_charging: Option<TimeDelta>,
88
89    /// The parking/idle time consumed in this period.
90    pub duration_idle: Option<TimeDelta>,
91
92    /// The energy consumed in this period.
93    pub energy: Option<Kwh>,
94
95    /// The maximum power reached during this period.
96    pub power_max: Option<Kw>,
97
98    /// The minimum power reached during this period.
99    pub power_min: Option<Kw>,
100}
101
102impl fmt::Debug for Consumed {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        f.debug_struct("Consumed")
105            .field("current_max", &self.current_max)
106            .field("current_min", &self.current_min)
107            .field(
108                "duration_charging",
109                &self.duration_charging.map(|dt| dt.as_hms()),
110            )
111            .field("duration_idle", &self.duration_idle.map(|dt| dt.as_hms()))
112            .field("energy", &self.energy)
113            .field("power_max", &self.power_max)
114            .field("power_min", &self.power_min)
115            .finish()
116    }
117}
118
119/// A snapshot of the values of various quantities at the start and end of the charge period.
120#[derive(Clone)]
121struct TotalsSnapshot {
122    /// The `DateTime` this snapshot of total quantities was taken.
123    date_time: DateTime<Utc>,
124
125    /// The total energy consumed during a charging period.
126    energy: Kwh,
127
128    /// The local timezone.
129    local_timezone: Tz,
130
131    /// The total charging duration during a charging period.
132    duration_charging: TimeDelta,
133
134    /// The total period duration during a charging period.
135    duration_total: TimeDelta,
136}
137
138impl fmt::Debug for TotalsSnapshot {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        f.debug_struct("TotalsSnapshot")
141            .field("date_time", &self.date_time)
142            .field("energy", &self.energy)
143            .field("local_timezone", &self.local_timezone)
144            .field("duration_charging", &self.duration_charging.as_hms())
145            .field("duration_total", &self.duration_total.as_hms())
146            .finish()
147    }
148}
149
150impl TotalsSnapshot {
151    /// Create a snapshot where all quantities are zero.
152    fn zero(date_time: DateTime<Utc>, local_timezone: Tz) -> Self {
153        Self {
154            date_time,
155            energy: Kwh::zero(),
156            local_timezone,
157            duration_charging: TimeDelta::zero(),
158            duration_total: TimeDelta::zero(),
159        }
160    }
161
162    /// Create a new snapshot based on the current snapshot with consumed quantities added.
163    fn next(&self, consumed: &Consumed, date_time: DateTime<Utc>) -> Self {
164        let duration = date_time.signed_duration_since(self.date_time);
165
166        let mut next = Self {
167            date_time,
168            energy: self.energy,
169            local_timezone: self.local_timezone,
170            duration_charging: self.duration_charging,
171            duration_total: self.duration_total.saturating_add(duration),
172        };
173
174        if let Some(duration) = consumed.duration_charging {
175            next.duration_charging = next.duration_charging.saturating_add(duration);
176        }
177
178        if let Some(energy) = consumed.energy {
179            next.energy = next.energy.saturating_add(energy);
180        }
181        next
182    }
183
184    /// Return the local time of this snapshot.
185    fn local_time(&self) -> chrono::NaiveTime {
186        self.date_time.with_timezone(&self.local_timezone).time()
187    }
188
189    /// Return the local date of this snapshot.
190    fn local_date(&self) -> chrono::NaiveDate {
191        self.date_time
192            .with_timezone(&self.local_timezone)
193            .date_naive()
194    }
195
196    /// Return the local `Weekday` of this snapshot.
197    fn local_weekday(&self) -> chrono::Weekday {
198        self.date_time.with_timezone(&self.local_timezone).weekday()
199    }
200}
201
202/// Structure containing the charge session priced according to the specified tariff.
203/// The fields prefixed `total` correspond to CDR fields with the same name.
204pub struct Report {
205    /// Charge session details per period.
206    pub periods: Vec<PeriodReport>,
207
208    /// The index of the tariff that was used for pricing.
209    pub tariff_used: TariffOrigin,
210
211    /// A list of reports for each tariff found in the CDR or supplied to the [`cdr::price`](crate::cdr::price) function.
212    ///
213    /// The order of the `tariff::Report`s are the same as the order in which they are given.
214    pub tariff_reports: Vec<TariffReport>,
215
216    /// Time-zone that was either specified or detected.
217    pub timezone: String,
218
219    /* Billed Quantities */
220    /// The total charging time after applying step-size.
221    pub billed_charging_time: Option<TimeDelta>,
222
223    /// The total energy after applying step-size.
224    pub billed_energy: Option<Kwh>,
225
226    /// The total idle time after applying step-size.
227    pub billed_idle_time: Option<TimeDelta>,
228
229    /* Totals */
230    /// Total duration of the charging session (excluding not charging), in hours.
231    ///
232    /// This is a total that has no direct source field in the `CDR` as it is calculated in the
233    /// [`cdr::price`](crate::cdr::price) function.
234    pub total_charging_time: Option<TimeDelta>,
235
236    /// Total energy charged, in kWh.
237    pub total_energy: Total<Kwh, Option<Kwh>>,
238
239    /// Total duration of the charging session where the EV was not charging (no energy was transferred between EVSE and EV).
240    ///
241    /// See: `total_parking_time` field in <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#131-cdr-object>.
242    /// Note: We use `total_idle_time` as it's clearer than `total_parking_time`.
243    /// Some people interpret `parking` to mean the total time that the vehicle is standing by the charge point.
244    /// The OCPI spec defines `parking` as the time spent not charging.
245    pub total_idle_time: Total<Option<TimeDelta>>,
246
247    /// Total duration of the charging session (including the duration of charging and idle phases).
248    pub total_time: Total<TimeDelta>,
249
250    /* Costs */
251    /// Total sum of all the costs of this transaction in the specified currency.
252    pub total_cost: Total<Price, Option<Price>>,
253
254    /// Total sum of all the cost of all the energy used, in the specified currency.
255    pub total_energy_cost: Total<Option<Price>>,
256
257    /// Total sum of all the fixed costs in the specified currency, except fixed price components of `parking` and `reservation`.
258    /// The cost not depending on amount of time/energy used etc. Can contain costs like a start tariff.
259    pub total_fixed_cost: Total<Option<Price>>,
260
261    /// Total sum of all the costs related to idleness during this transaction. This includes fixed price components, in the specified currency.
262    ///
263    /// See: `total_parking_cost` field in <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#131-cdr-object>.
264    /// Note: We use `total_idle_cost` as it's clearer than `total_parking_cost`.
265    /// Some people interpret `parking` to mean the total time that the vehicle is standing by the charge point.
266    /// The OCPI spec defines `parking` as the time spent not charging.
267    pub total_idle_cost: Total<Option<Price>>,
268
269    /// Total sum of all the cost related to duration of charging during this transaction, in the specified currency.
270    ///
271    /// See: `total_time_cost` field in <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#131-cdr-object>.
272    /// Note: We use `total_charging_time_cost` as it's clearer than `total_time_cost`.
273    pub total_charging_time_cost: Total<Option<Price>>,
274}
275
276impl fmt::Debug for Report {
277    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278        f.debug_struct("Report")
279            .field("periods", &self.periods)
280            .field("tariff_used", &self.tariff_used)
281            .field("tariff_reports", &self.tariff_reports)
282            .field("timezone", &self.timezone)
283            .field(
284                "billed_charging_time",
285                &self.billed_charging_time.map(|dt| dt.as_hms()),
286            )
287            .field("billed_energy", &self.billed_energy)
288            .field(
289                "billed_idle_time",
290                &self.billed_idle_time.map(|dt| dt.as_hms()),
291            )
292            .field(
293                "total_charging_time",
294                &self.total_charging_time.map(|dt| dt.as_hms()),
295            )
296            .field("total_energy", &self.total_energy)
297            .field("total_idle_time", &self.total_idle_time)
298            .field("total_time", &self.total_time)
299            .field("total_cost", &self.total_cost)
300            .field("total_energy_cost", &self.total_energy_cost)
301            .field("total_fixed_cost", &self.total_fixed_cost)
302            .field("total_idle_cost", &self.total_idle_cost)
303            .field("total_charging_time_cost", &self.total_charging_time_cost)
304            .finish()
305    }
306}
307
308/// The warnings that happen when pricing a CDR.
309#[derive(Debug)]
310pub enum Warning {
311    Country(country::Warning),
312    Currency(currency::Warning),
313    DateTime(datetime::Warning),
314    Decode(json::decode::Warning),
315    Duration(duration::Warning),
316
317    /// The `$.country` field should be an alpha-2 country code.
318    ///
319    /// The alpha-3 code can be converted into an alpha-3 but the caller should be warned.
320    CountryShouldBeAlpha2,
321
322    Money(money::Warning),
323
324    /// The CDR has no charging periods.
325    NoPeriods,
326
327    /// No valid tariff has been found in the list of provided tariffs.
328    /// The tariff list can be sourced from either the tariffs contained in the CDR or from a list
329    /// provided by the caller.
330    ///
331    /// A valid tariff must have a start date-time before the start of the session and an end
332    /// date-time after the start of the session.
333    ///
334    /// If the CDR does not contain any tariffs consider providing a them using [`TariffSource`]
335    /// when calling [`cdr::price`](crate::cdr::price).
336    NoValidTariff,
337
338    Number(number::Warning),
339
340    /// The `start_date_time` of at least one of the `charging_periods` is outside of the
341    /// CDR's `start_date_time`-`end_date_time` range.
342    PeriodsOutsideStartEndDateTime {
343        cdr_range: Range<DateTime<Utc>>,
344        period_range: PeriodRange,
345    },
346
347    String(string::Warning),
348
349    /// Converting the `tariff::Versioned` into a structured `tariff::v221::Tariff` caused an
350    /// unrecoverable error.
351    Tariff(crate::tariff::Warning),
352
353    /// A feature rejected the schema IR for a CDR object because a required field was missing
354    /// or invalid. The located cause is reported by the schema validation warnings.
355    /// (see [`warning::Rejected`]).
356    Rejected,
357}
358
359impl fmt::Display for Warning {
360    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361        match self {
362            Self::Country(warning) => write!(f, "{warning}"),
363            Self::CountryShouldBeAlpha2 => {
364                f.write_str("The `$.country` field should be an alpha-2 country code.")
365            }
366            Self::Currency(warning) => write!(f, "{warning}"),
367            Self::DateTime(warning) => write!(f, "{warning}"),
368            Self::Decode(warning) => write!(f, "{warning}"),
369            Self::Duration(warning) => write!(f, "{warning}"),
370            Self::Money(warning) => write!(f, "{warning}"),
371            Self::NoPeriods => f.write_str("The CDR has no charging periods"),
372            Self::NoValidTariff => {
373                f.write_str("No valid tariff has been found in the list of provided tariffs")
374            }
375            Self::Number(warning) => write!(f, "{warning}"),
376            Self::PeriodsOutsideStartEndDateTime {
377                cdr_range: Range { start, end },
378                period_range,
379            } => {
380                write!(
381                    f,
382                    "The CDR's charging period time range is not contained within the `start_date_time` \
383                    and `end_date_time`; cdr: [start: {start}, end: {end}], period: {period_range}",
384                )
385            }
386            Self::String(warning) => write!(f, "{warning}"),
387            Self::Tariff(warnings) => {
388                write!(f, "Tariff warnings: {warnings:?}")
389            }
390            Self::Rejected => f.write_str(
391                "The schema IR for a CDR object was rejected; see the schema validation warnings.",
392            ),
393        }
394    }
395}
396
397impl crate::Warning for Warning {
398    fn id(&self) -> warning::Id {
399        match self {
400            Self::Country(warning) => warning.id(),
401            Self::CountryShouldBeAlpha2 => warning::Id::from_static("country_should_be_alpha_2"),
402            Self::Currency(warning) => warning.id(),
403            Self::DateTime(warning) => warning.id(),
404            Self::Decode(warning) => warning.id(),
405            Self::Duration(warning) => warning.id(),
406            Self::Money(warning) => warning.id(),
407            Self::NoPeriods => warning::Id::from_static("no_periods"),
408            Self::NoValidTariff => warning::Id::from_static("no_valid_tariff"),
409            Self::Number(warning) => warning.id(),
410            Self::PeriodsOutsideStartEndDateTime { .. } => {
411                warning::Id::from_static("periods_outside_start_end_date_time")
412            }
413            Self::String(warning) => warning.id(),
414            Self::Tariff(warning) => warning.id(),
415            Self::Rejected => warning::Id::from_static("rejected"),
416        }
417    }
418
419    fn is_rejected(&self) -> bool {
420        matches!(self, Self::Rejected)
421    }
422}
423
424impl From<warning::Rejected> for Warning {
425    fn from(_: warning::Rejected) -> Self {
426        Self::Rejected
427    }
428}
429
430from_warning_all!(
431    country::Warning => Warning::Country,
432    currency::Warning => Warning::Currency,
433    datetime::Warning => Warning::DateTime,
434    duration::Warning => Warning::Duration,
435    json::decode::Warning => Warning::Decode,
436    money::Warning => Warning::Money,
437    number::Warning => Warning::Number,
438    string::Warning => Warning::String,
439    crate::tariff::Warning => Warning::Tariff
440);
441
442/// A report of parsing and using the referenced tariff to price a CDR.
443#[derive(Debug)]
444pub struct TariffReport {
445    /// The id of the tariff.
446    pub origin: TariffOrigin,
447
448    /// Warnings from parsing a tariff.
449    ///
450    /// Each entry in the map is an element path and a list of associated warnings.
451    pub warnings: BTreeMap<json::Path, Vec<crate::tariff::Warning>>,
452}
453
454/// The origin data for a tariff.
455#[derive(Clone, Debug)]
456pub struct TariffOrigin {
457    /// The index of the tariff in the CDR JSON or in the list of override tariffs.
458    pub index: usize,
459
460    /// The value of the `id` field in the tariff JSON.
461    pub id: String,
462
463    // The currency code of the tariff.
464    pub currency: currency::Code,
465}
466
467/// A CDR charge period in a normalized form ready for pricing.
468#[derive(Debug)]
469pub(crate) struct Period {
470    /// The start time of this period.
471    pub start_date_time: DateTime<Utc>,
472
473    /// The quantities consumed during this period.
474    pub consumed: Consumed,
475}
476
477/// A structure containing a report for each dimension of a CDRs charging [`Period`].
478#[derive(Debug)]
479pub struct Dimensions {
480    /// Energy consumed. `None` if the CDR period had no energy dimension.
481    pub energy: Option<Dimension<Kwh>>,
482
483    /// Flat fee without unit for `step_size`.
484    pub flat: Dimension<()>,
485
486    /// Duration of time charging. `None` if the CDR period had no time dimension.
487    pub duration_charging: Option<Dimension<TimeDelta>>,
488
489    /// Duration of time not charging. `None` if the CDR period had no parking-time dimension.
490    pub duration_idle: Option<Dimension<TimeDelta>>,
491}
492
493impl Dimensions {
494    /// Create a new `Dimensions` object.
495    fn new(components: ComponentSet, consumed: &Consumed) -> Self {
496        let ComponentSet {
497            energy: energy_price,
498            flat: flat_price,
499            duration_charging: duration_charging_price,
500            duration_idle: duration_idle_price,
501        } = components;
502
503        let Consumed {
504            duration_charging,
505            duration_idle,
506            energy,
507            current_max: _,
508            current_min: _,
509            power_max: _,
510            power_min: _,
511        } = consumed;
512
513        Self {
514            energy: (*energy).map(|e| Dimension {
515                price: energy_price,
516                volume: e,
517                billed_volume: e,
518            }),
519            flat: Dimension {
520                price: flat_price,
521                volume: (),
522                billed_volume: (),
523            },
524            duration_charging: (*duration_charging).map(|dc| Dimension {
525                price: duration_charging_price,
526                volume: dc,
527                billed_volume: dc,
528            }),
529            duration_idle: (*duration_idle).map(|di| Dimension {
530                price: duration_idle_price,
531                volume: di,
532                billed_volume: di,
533            }),
534        }
535    }
536}
537
538#[derive(Debug)]
539/// A report for a single dimension during a single charging [`Period`].
540pub struct Dimension<V> {
541    /// The price component that was active during this period for this dimension.
542    /// It could be that no price component was active during this period for this dimension in
543    /// which case `price` is `None`.
544    pub price: Option<Component>,
545
546    /// The volume of this dimension during this period, as received in the provided charge detail record.
547    pub volume: V,
548
549    /// The value of `volume` after a potential step size was applied.
550    /// Step size is applied over the total volume during the whole session of a dimension. But the
551    /// resulting additional volume should be billed according to the price component in this
552    /// period.
553    ///
554    /// If no step-size was applied for this period, the volume is exactly equal to the `volume`
555    /// field.
556    pub billed_volume: V,
557}
558
559impl<V: Cost> Dimension<V> {
560    /// The total cost of this dimension during a period.
561    pub fn cost(&self) -> Option<Price> {
562        let Some(price_component) = &self.price else {
563            return None;
564        };
565
566        let excl_vat = self.billed_volume.cost(price_component.price);
567
568        let incl_vat = match price_component.vat {
569            VatOrigin::Provided(vat) => Some(excl_vat.apply_vat(vat)),
570            VatOrigin::NotProvided => Some(excl_vat),
571            VatOrigin::Unknown => None,
572        };
573
574        Some(Price { excl_vat, incl_vat })
575    }
576}
577
578/// A set of price `Component`s, one for each dimension.
579///
580/// See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#142-pricecomponent-class>.
581/// See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#145-tariffdimensiontype-enum>.
582#[derive(Debug)]
583pub struct ComponentSet {
584    /// Energy consumed.
585    pub energy: Option<Component>,
586
587    /// Flat fee without unit for `step_size`.
588    pub flat: Option<Component>,
589
590    /// Duration of time charging.
591    pub duration_charging: Option<Component>,
592
593    /// Duration of time not charging.
594    pub duration_idle: Option<Component>,
595}
596
597impl ComponentSet {
598    /// Returns true if all components are `Some`.
599    fn has_all_components(&self) -> bool {
600        let Self {
601            energy,
602            flat,
603            duration_charging,
604            duration_idle,
605        } = self;
606
607        flat.is_some() && energy.is_some() && duration_idle.is_some() && duration_charging.is_some()
608    }
609}
610
611/// A Price Component describes how a certain amount of a certain dimension being consumed
612/// translates into an amount of money owed.
613///
614/// See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#142-pricecomponent-class>.
615#[derive(Clone, Debug)]
616pub struct Component {
617    /// Price per unit (excl. VAT) for this dimension.
618    price: Money,
619
620    /// Applicable VAT percentage for this tariff dimension. If omitted, no VAT is applicable.
621    /// Not providing a VAT is different from 0% VAT, which would be a value of 0.0 here.
622    vat: VatOrigin,
623
624    /// Minimum amount to be billed. That is, the dimension will be billed in this `step_size` blocks.
625    /// Consumed amounts are rounded up to the smallest multiple of `step_size` that is greater than
626    /// the consumed amount.
627    ///
628    /// For example: if type is TIME and `step_size` has a value of 300, then time will be billed in
629    /// blocks of 5 minutes. If 6 minutes were consumed, 10 minutes (2 blocks of `step_size`) will
630    /// be billed.
631    step_size: u64,
632}
633
634impl Component {
635    /// Create a new `Component` object.
636    fn new(component: &crate::tariff::v221::PriceComponent) -> Self {
637        let crate::tariff::v221::PriceComponent {
638            price,
639            vat,
640            step_size,
641            dimension_type: _,
642        } = component;
643
644        Self {
645            price: *price,
646            vat: *vat,
647            step_size: *step_size,
648        }
649    }
650
651    /// Return the price of the `Component`.
652    pub fn price(&self) -> Money {
653        self.price
654    }
655}
656
657/// A related source and calculated pair of total amounts.
658///
659/// This is used to express the source and calculated amounts for the total fields of a `CDR`.
660///
661/// - `total_cost`
662/// - `total_fixed_cost`
663/// - `total_energy`
664/// - `total_energy_cost`
665/// - `total_time`
666/// - `total_time_cost`
667/// - `total_parking_time`
668/// - `total_parking_cost`
669#[derive(Debug)]
670pub struct Total<TCdr, TCalc = TCdr> {
671    /// The source value from the `CDR`.
672    pub cdr: TCdr,
673
674    /// The value calculated by the [`cdr::price`](crate::cdr::price) function.
675    pub calculated: TCalc,
676}
677
678/// The range of time the CDR periods span.
679#[derive(Debug)]
680pub enum PeriodRange {
681    /// There are many periods in the CDR and so the range is from the `start_date_time` of the first to
682    /// the `start_date_time` of the last.
683    Many(Range<DateTime<Utc>>),
684
685    /// There is one period in the CDR and so one `start_date_time`.
686    Single(DateTime<Utc>),
687}
688
689impl fmt::Display for PeriodRange {
690    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
691        match self {
692            PeriodRange::Many(Range { start, end }) => write!(f, "[start: {start}, end: {end}]"),
693            PeriodRange::Single(date_time) => write!(f, "{date_time}"),
694        }
695    }
696}
697
698/// Where should the tariffs come from when pricing a `CDR`.
699///
700/// Used with [`cdr::price`](crate::cdr::price).
701#[derive(Debug)]
702pub enum TariffSource<'buf> {
703    /// Use the tariffs from the `CDR`.
704    UseCdr,
705
706    /// Ignore the tariffs from the `CDR` and use these instead.
707    Override(Vec<crate::tariff::Versioned<'buf>>),
708}
709
710impl<'buf> TariffSource<'buf> {
711    /// Convenience method to provide a single override tariff.
712    pub fn single(tariff: crate::tariff::Versioned<'buf>) -> Self {
713        Self::Override(vec![tariff])
714    }
715}
716
717/// Price a CDR.
718///
719/// See: [`crate::cdr::price`].
720#[instrument(skip_all)]
721pub(super) fn cdr(
722    cdr_elem: &crate::cdr::Versioned<'_>,
723    tariff_source: TariffSource<'_>,
724    timezone: Tz,
725) -> Verdict<Report> {
726    let cdr = cdr_elem.to_v221()?;
727
728    match tariff_source {
729        TariffSource::UseCdr => {
730            debug!("Using tariffs from CDR");
731            let tariffs = cdr_elem.tariffs_to_v221()?.ignore_warnings();
732
733            Ok(price_v221_cdr_with_tariffs(
734                cdr_elem, cdr, tariffs, timezone,
735            )?)
736        }
737        TariffSource::Override(tariffs) => {
738            debug!("Using override tariffs");
739            let tariffs = tariffs
740                .iter()
741                .map(crate::tariff::Versioned::to_v221)
742                .collect::<Result<Vec<_>, _>>()?;
743
744            Ok(price_v221_cdr_with_tariffs(
745                cdr_elem, cdr, tariffs, timezone,
746            )?)
747        }
748    }
749}
750
751/// Price a single charge-session using a tariff selected from a list.
752///
753/// Returns a report containing the totals, subtotals, and a breakdown of the calculation.
754/// Price a single charge-session using a single tariff.
755///
756/// Returns a report containing the totals, subtotals, and a breakdown of the calculation.
757fn price_v221_cdr_with_tariffs(
758    cdr_elem: &crate::cdr::Versioned<'_>,
759    cdr: Caveat<v221::Cdr, Warning>,
760    tariffs: Vec<Caveat<crate::tariff::v221::Tariff<'_>, crate::tariff::Warning>>,
761    timezone: Tz,
762) -> Verdict<Report> {
763    debug!(?timezone, version = ?cdr_elem.version(), "Pricing CDR");
764    let (cdr, mut warnings) = cdr.into_parts();
765    let v221::Cdr {
766        start_date_time,
767        end_date_time,
768        charging_periods,
769        totals: cdr_totals,
770    } = cdr;
771
772    // Convert each versioned tariff JSON to a structured tariff.
773    //
774    // This generates a list of `TariffReport`s that are returned to the caller in the `Report`.
775    // One of the structured tariffs is selected for use in the `price_periods` function.
776    let (tariff_reports, tariffs): (Vec<_>, Vec<_>) = tariffs
777        .into_iter()
778        .enumerate()
779        .map(|(index, tariff)| {
780            let (tariff, warnings) = tariff.into_parts();
781            (
782                TariffReport {
783                    origin: TariffOrigin {
784                        index,
785                        id: tariff.id.to_string(),
786                        currency: tariff.currency,
787                    },
788                    warnings: warnings.into_path_map(),
789                },
790                tariff,
791            )
792        })
793        .unzip();
794
795    debug!(tariffs = ?tariffs.iter().map(|t| t.id).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
796
797    let tariffs_normalized = tariff::normalize_all(&tariffs);
798    let Some((tariff_index, tariff)) =
799        tariff::find_first_active(tariffs_normalized, start_date_time)
800    else {
801        return warnings.bail(cdr_elem.as_element(), Warning::NoValidTariff);
802    };
803
804    debug!(tariff_index, id = ?tariff.id(), "Found active tariff");
805    debug!(%timezone, "Found timezone");
806
807    // Convert the CDRs periods to the API input period.
808    let periods = charging_periods.into_iter().map(Period::from).collect();
809
810    let periods = normalize_periods(periods, end_date_time, timezone);
811    let price_cdr_report = price_periods(&periods, &tariff)
812        .with_element(cdr_elem.as_element())?
813        .gather_warnings_into(&mut warnings);
814
815    if tariff.has_reservation_elements() {
816        warnings.insert(
817            cdr_elem.as_element(),
818            Warning::Tariff(crate::tariff::Warning::ReservationElementSkipped),
819        );
820    }
821
822    let mut report = generate_report(
823        &cdr_totals,
824        timezone,
825        tariff_reports,
826        price_cdr_report,
827        TariffOrigin {
828            index: tariff_index,
829            id: tariff.id().to_owned(),
830            currency: tariff.currency(),
831        },
832    );
833
834    if let Some(total_cost) = report.total_cost.calculated.as_mut() {
835        if let Some(min_price) = tariff.min_price() {
836            if *total_cost < min_price {
837                *total_cost = min_price;
838                warnings.insert(
839                    cdr_elem.as_element(),
840                    crate::tariff::Warning::TotalCostClampedToMin.into(),
841                );
842            }
843        }
844
845        if let Some(max_price) = tariff.max_price() {
846            if *total_cost > max_price {
847                *total_cost = max_price;
848                warnings.insert(
849                    cdr_elem.as_element(),
850                    crate::tariff::Warning::TotalCostClampedToMax.into(),
851                );
852            }
853        }
854    }
855
856    Ok(report.into_caveat(warnings))
857}
858
859/// Price a list of normalized [`Period`]s using a [`VersionedJson`](crate::tariff::VersionedJson) tariff.
860pub(crate) fn periods(
861    end_date_time: DateTime<Utc>,
862    timezone: Tz,
863    tariff_elem: &crate::tariff::v221::Tariff<'_>,
864    mut periods: Vec<Period>,
865) -> VerdictDeferred<PeriodsReport> {
866    // Make sure the periods are sorted by time as the start date of one period determines the end
867    // date of the previous period.
868    periods.sort_by_key(|p| p.start_date_time);
869    let tariff = Tariff::from_v221(tariff_elem);
870    let periods = normalize_periods(periods, end_date_time, timezone);
871    price_periods(&periods, &tariff)
872}
873
874fn normalize_periods(
875    periods: Vec<Period>,
876    end_date_time: DateTime<Utc>,
877    local_timezone: Tz,
878) -> Vec<PeriodNormalized> {
879    debug!("Normalizing CDR periods");
880
881    // Each new period is linked to the previous periods data.
882    let mut previous_end_snapshot = Option::<TotalsSnapshot>::None;
883
884    // The end-date of the first period is the start-date of the second and so on.
885    let end_dates = {
886        let mut end_dates = periods
887            .iter()
888            .skip(1)
889            .map(|p| p.start_date_time)
890            .collect::<Vec<_>>();
891
892        // The last end-date is the end-date of the CDR.
893        end_dates.push(end_date_time);
894        end_dates
895    };
896
897    let periods = periods
898        .into_iter()
899        .zip(end_dates)
900        .enumerate()
901        .map(|(index, (period, end_date_time))| {
902            trace!(index, "processing\n{period:#?}");
903            let Period {
904                start_date_time,
905                consumed,
906            } = period;
907
908            let period = if let Some(prev_end_snapshot) = previous_end_snapshot.take() {
909                let start_snapshot = prev_end_snapshot;
910                let end_snapshot = start_snapshot.next(&consumed, end_date_time);
911
912                let period = PeriodNormalized {
913                    consumed,
914                    start_snapshot,
915                    end_snapshot,
916                };
917                trace!("Adding new period based on the last added\n{period:#?}");
918                period
919            } else {
920                let start_snapshot = TotalsSnapshot::zero(start_date_time, local_timezone);
921                let end_snapshot = start_snapshot.next(&consumed, end_date_time);
922
923                let period = PeriodNormalized {
924                    consumed,
925                    start_snapshot,
926                    end_snapshot,
927                };
928                trace!("Adding new period\n{period:#?}");
929                period
930            };
931
932            previous_end_snapshot.replace(period.end_snapshot.clone());
933            period
934        })
935        .collect::<Vec<_>>();
936
937    periods
938}
939
940/// Price the given set of CDR periods using a normalized `Tariff`.
941fn price_periods(periods: &[PeriodNormalized], tariff: &Tariff) -> VerdictDeferred<PeriodsReport> {
942    debug!(count = periods.len(), "Pricing CDR periods");
943
944    if tracing::enabled!(tracing::Level::TRACE) {
945        trace!("# CDR period list:");
946        for period in periods {
947            trace!("{period:#?}");
948        }
949    }
950
951    let period_totals = period_totals(periods, tariff);
952    let (billed, mut warnings) = period_totals.calculate_billed()?.into_parts();
953
954    if tariff.has_reservation_elements() {
955        warnings.insert(Warning::Tariff(
956            crate::tariff::Warning::ReservationElementSkipped,
957        ));
958    }
959
960    let (billable, periods, totals) = billed;
961    let total_costs = total_costs(&periods, tariff);
962    let report = PeriodsReport {
963        billable,
964        periods,
965        totals,
966        total_costs,
967    };
968
969    Ok(report.into_caveat_deferred(warnings))
970}
971
972/// The internal report generated from the [`periods`] fn.
973pub(crate) struct PeriodsReport {
974    /// The billable dimensions calculated by applying the step-size to each dimension.
975    pub billable: Billable,
976
977    /// A list of reports for each charging period that occurred during a session.
978    pub periods: Vec<PeriodReport>,
979
980    /// The totals for each dimension.
981    pub totals: Totals,
982
983    /// The total costs for each dimension.
984    pub total_costs: TotalCosts,
985}
986
987/// A report for a single charging period that occurred during a session.
988///
989/// A charging period is a period of time that has relevance for the total costs of a CDR.
990/// During a charging session, different parameters change all the time, like the amount of energy used,
991/// or the time of day. These changes can result in another [`PriceComponent`](https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#142-pricecomponent-class) of the Tariff becoming active.
992#[derive(Debug)]
993pub struct PeriodReport {
994    /// The start time of this period.
995    pub start_date_time: DateTime<Utc>,
996
997    /// The end time of this period.
998    pub end_date_time: DateTime<Utc>,
999
1000    /// A structure that contains results per dimension.
1001    pub dimensions: Dimensions,
1002}
1003
1004impl PeriodReport {
1005    /// The total cost of all dimensions in this period.
1006    pub fn cost(&self) -> Option<Price> {
1007        [
1008            self.dimensions
1009                .duration_charging
1010                .as_ref()
1011                .and_then(Dimension::cost),
1012            self.dimensions
1013                .duration_idle
1014                .as_ref()
1015                .and_then(Dimension::cost),
1016            self.dimensions.flat.cost(),
1017            self.dimensions.energy.as_ref().and_then(Dimension::cost),
1018        ]
1019        .into_iter()
1020        .fold(None, |accum, next| {
1021            if accum.is_none() && next.is_none() {
1022                None
1023            } else {
1024                Some(
1025                    accum
1026                        .unwrap_or_default()
1027                        .saturating_add(next.unwrap_or_default()),
1028                )
1029            }
1030        })
1031    }
1032}
1033
1034/// A [`PeriodReport`] under construction during the step-size calculation.
1035///
1036/// After step sizes are applied the step-size fields are dropped and this converts into
1037/// a [`PeriodReport`].
1038#[derive(Debug)]
1039struct PeriodReportScratch {
1040    start_date_time: DateTime<Utc>,
1041    end_date_time: DateTime<Utc>,
1042    dimensions: Dimensions,
1043    step_size_duration_charging: Option<Component>,
1044    step_size_duration_idle: Option<Component>,
1045    step_size_energy: Option<Component>,
1046}
1047
1048impl From<PeriodReportScratch> for PeriodReport {
1049    fn from(scratch: PeriodReportScratch) -> Self {
1050        Self {
1051            start_date_time: scratch.start_date_time,
1052            end_date_time: scratch.end_date_time,
1053            dimensions: scratch.dimensions,
1054        }
1055    }
1056}
1057
1058/// The result of normalizing the CDR charging periods.
1059#[derive(Debug)]
1060struct PeriodTotals {
1061    /// The list of periods under construction.
1062    periods: Vec<PeriodReportScratch>,
1063
1064    /// The totals for each dimension.
1065    totals: Totals,
1066}
1067
1068/// The totals for each dimension.
1069#[derive(Debug, Default)]
1070pub(crate) struct Totals {
1071    /// The total energy used during a session.
1072    pub energy: Option<Kwh>,
1073
1074    /// The total charging time used during a session.
1075    ///
1076    /// Some if the charging happened during the session.
1077    pub duration_charging: Option<TimeDelta>,
1078
1079    /// The total idle time during a session.
1080    ///
1081    /// Some if there was idle time during the session.
1082    pub duration_idle: Option<TimeDelta>,
1083}
1084
1085impl PeriodTotals {
1086    /// Apply step sizes and convert scratch periods into final [`PeriodReport`]s.
1087    fn calculate_billed(self) -> VerdictDeferred<(Billable, Vec<PeriodReport>, Totals)> {
1088        let mut warnings = warning::SetDeferred::new();
1089        let Self {
1090            mut periods,
1091            totals,
1092        } = self;
1093
1094        let billable =
1095            apply_step_sizes(&mut periods, &totals)?.gather_deferred_warnings_into(&mut warnings);
1096
1097        let periods = periods.into_iter().map(PeriodReport::from).collect();
1098
1099        Ok((billable, periods, totals).into_caveat_deferred(warnings))
1100    }
1101}
1102
1103/// The billable dimensions calculated by applying the step-size to each dimension.
1104#[derive(Debug)]
1105pub(crate) struct Billable {
1106    /// The billable charging time.
1107    duration_charging: Option<TimeDelta>,
1108
1109    /// The billable idle time.
1110    duration_idle: Option<TimeDelta>,
1111
1112    /// The billable energy use.
1113    energy: Option<Kwh>,
1114}
1115
1116/// Map the `session::ChargePeriod`s to a normalized `Period` and calculate the step size and
1117/// totals for each dimension.
1118fn period_totals(periods: &[PeriodNormalized], tariff: &Tariff) -> PeriodTotals {
1119    let mut has_flat_fee = false;
1120    let mut totals = Totals::default();
1121
1122    debug!(
1123        tariff_id = tariff.id(),
1124        period_count = periods.len(),
1125        "Accumulating dimension totals for each period"
1126    );
1127
1128    let periods = periods
1129        .iter()
1130        .enumerate()
1131        .map(|(index, period)| {
1132            let mut component_set = tariff.active_components(period);
1133            trace!(
1134                index,
1135                "Creating charge period with Dimension\n{period:#?}\n{component_set:#?}"
1136            );
1137
1138            if component_set.flat.is_some() {
1139                if has_flat_fee {
1140                    component_set.flat = None;
1141                } else {
1142                    has_flat_fee = true;
1143                }
1144            }
1145
1146            // Extract step-size components before consuming the component_set.
1147            let step_size_duration_charging = if period.consumed.duration_charging.is_some() {
1148                component_set.duration_charging.clone()
1149            } else {
1150                None
1151            };
1152            let step_size_duration_idle = if period.consumed.duration_idle.is_some() {
1153                component_set.duration_idle.clone()
1154            } else {
1155                None
1156            };
1157            let step_size_energy = if period.consumed.energy.is_some() {
1158                component_set.energy.clone()
1159            } else {
1160                None
1161            };
1162
1163            let dimensions = Dimensions::new(component_set, &period.consumed);
1164
1165            trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
1166
1167            if let Some(dim) = &dimensions.duration_charging {
1168                let acc = totals.duration_charging.get_or_insert_default();
1169                *acc = acc.saturating_add(dim.volume);
1170            }
1171
1172            if let Some(dim) = &dimensions.energy {
1173                let acc = totals.energy.get_or_insert_default();
1174                *acc = acc.saturating_add(dim.volume);
1175            }
1176
1177            if let Some(dim) = &dimensions.duration_idle {
1178                let acc = totals.duration_idle.get_or_insert_default();
1179                *acc = acc.saturating_add(dim.volume);
1180            }
1181
1182            trace!(period_index = index, ?totals, "Update totals");
1183
1184            PeriodReportScratch {
1185                start_date_time: period.start_snapshot.date_time,
1186                end_date_time: period.end_snapshot.date_time,
1187                dimensions,
1188                step_size_duration_charging,
1189                step_size_duration_idle,
1190                step_size_energy,
1191            }
1192        })
1193        .collect::<Vec<_>>();
1194
1195    PeriodTotals { periods, totals }
1196}
1197
1198/// The total costs for each dimension.
1199#[derive(Debug, Default)]
1200pub(crate) struct TotalCosts {
1201    /// The [`Price`] for all energy used during a session.
1202    pub energy: Option<Price>,
1203
1204    /// The [`Price`] for all flat rates applied during a session.
1205    pub fixed: Option<Price>,
1206
1207    /// The [`Price`] for all charging time used during a session.
1208    pub duration_charging: Option<Price>,
1209
1210    /// The [`Price`] for all idle time used during a session.
1211    pub duration_idle: Option<Price>,
1212}
1213
1214impl TotalCosts {
1215    /// Summate each dimension total into a single total.
1216    ///
1217    /// Return `None` if there are no cost dimensions otherwise return `Some`.
1218    pub(crate) fn total(&self) -> Option<Price> {
1219        let Self {
1220            energy,
1221            fixed,
1222            duration_charging,
1223            duration_idle,
1224        } = self;
1225        debug!(
1226            energy = %DisplayOption(*energy),
1227            fixed = %DisplayOption(*fixed),
1228            duration_charging = %DisplayOption(*duration_charging),
1229            duration_idle = %DisplayOption(*duration_idle),
1230            "Calculating total costs."
1231        );
1232        [energy, fixed, duration_charging, duration_idle]
1233            .into_iter()
1234            .fold(None, |accum: Option<Price>, next| match (accum, next) {
1235                (None, None) => None,
1236                _ => Some(
1237                    accum
1238                        .unwrap_or_default()
1239                        .saturating_add(next.unwrap_or_default()),
1240                ),
1241            })
1242    }
1243}
1244
1245/// Accumulate total costs per dimension across all periods.
1246fn total_costs(periods: &[PeriodReport], tariff: &Tariff) -> TotalCosts {
1247    let mut total_costs = TotalCosts::default();
1248
1249    debug!(
1250        tariff_id = tariff.id(),
1251        period_count = periods.len(),
1252        "Accumulating dimension costs for each period"
1253    );
1254    for (index, period) in periods.iter().enumerate() {
1255        let dimensions = &period.dimensions;
1256
1257        trace!(period_index = index, "Processing period");
1258
1259        let energy_cost = dimensions.energy.as_ref().and_then(Dimension::cost);
1260        let fixed_cost = dimensions.flat.cost();
1261        let duration_charging_cost = dimensions
1262            .duration_charging
1263            .as_ref()
1264            .and_then(Dimension::cost);
1265        let duration_idle_cost = dimensions.duration_idle.as_ref().and_then(Dimension::cost);
1266
1267        trace!(?total_costs.energy, ?energy_cost, "Energy cost");
1268        trace!(?total_costs.duration_charging, ?duration_charging_cost, "Charging cost");
1269        trace!(?total_costs.duration_idle, ?duration_idle_cost, "Idle cost");
1270        trace!(?total_costs.fixed, ?fixed_cost, "Fixed cost");
1271
1272        total_costs.energy = match (total_costs.energy, energy_cost) {
1273            (None, None) => None,
1274            (total, period) => Some(
1275                total
1276                    .unwrap_or_default()
1277                    .saturating_add(period.unwrap_or_default()),
1278            ),
1279        };
1280
1281        total_costs.duration_charging =
1282            match (total_costs.duration_charging, duration_charging_cost) {
1283                (None, None) => None,
1284                (total, period) => Some(
1285                    total
1286                        .unwrap_or_default()
1287                        .saturating_add(period.unwrap_or_default()),
1288                ),
1289            };
1290
1291        total_costs.duration_idle = match (total_costs.duration_idle, duration_idle_cost) {
1292            (None, None) => None,
1293            (total, period) => Some(
1294                total
1295                    .unwrap_or_default()
1296                    .saturating_add(period.unwrap_or_default()),
1297            ),
1298        };
1299
1300        total_costs.fixed = match (total_costs.fixed, fixed_cost) {
1301            (None, None) => None,
1302            (total, period) => Some(
1303                total
1304                    .unwrap_or_default()
1305                    .saturating_add(period.unwrap_or_default()),
1306            ),
1307        };
1308
1309        trace!(period_index = index, ?total_costs, "Update totals");
1310    }
1311
1312    total_costs
1313}
1314
1315fn generate_report(
1316    cdr_totals: &v221::cdr::Totals,
1317    timezone: Tz,
1318    tariff_reports: Vec<TariffReport>,
1319    price_periods_report: PeriodsReport,
1320    tariff_used: TariffOrigin,
1321) -> Report {
1322    let PeriodsReport {
1323        billable,
1324        periods,
1325        totals,
1326        total_costs,
1327    } = price_periods_report;
1328    trace!("Update billed totals {billable:#?}");
1329
1330    let total_cost = total_costs.total();
1331
1332    debug!(total_cost = %DisplayOption(total_cost.as_ref()));
1333
1334    let total_time = {
1335        debug!(
1336            period_start = %DisplayOption(periods.first().map(|p| p.start_date_time)),
1337            period_end = %DisplayOption(periods.last().map(|p| p.end_date_time)),
1338            "Calculating `total_time`"
1339        );
1340
1341        periods
1342            .first()
1343            .zip(periods.last())
1344            .map(|(first, last)| {
1345                last.end_date_time
1346                    .signed_duration_since(first.start_date_time)
1347            })
1348            .unwrap_or_default()
1349    };
1350    debug!(total_time = %Hms(total_time));
1351
1352    let report = Report {
1353        periods,
1354        tariff_used,
1355        timezone: timezone.to_string(),
1356        billed_idle_time: billable.duration_idle,
1357        billed_energy: billable.energy.round_to_ocpi_scale(),
1358        billed_charging_time: billable.duration_charging,
1359        tariff_reports,
1360        total_charging_time: totals.duration_charging,
1361        total_cost: Total {
1362            cdr: cdr_totals.cost.round_to_ocpi_scale(),
1363            calculated: total_cost.round_to_ocpi_scale(),
1364        },
1365        total_charging_time_cost: Total {
1366            cdr: cdr_totals.duration_charging_cost.round_to_ocpi_scale(),
1367            calculated: total_costs.duration_charging.round_to_ocpi_scale(),
1368        },
1369        total_time: Total {
1370            cdr: cdr_totals.duration_charging,
1371            calculated: total_time,
1372        },
1373        total_idle_cost: Total {
1374            cdr: cdr_totals.duration_idle_cost.round_to_ocpi_scale(),
1375            calculated: total_costs.duration_idle.round_to_ocpi_scale(),
1376        },
1377        total_idle_time: Total {
1378            cdr: cdr_totals.duration_idle,
1379            calculated: totals.duration_idle,
1380        },
1381        total_energy_cost: Total {
1382            cdr: cdr_totals.energy_cost.round_to_ocpi_scale(),
1383            calculated: total_costs.energy.round_to_ocpi_scale(),
1384        },
1385        total_energy: Total {
1386            cdr: cdr_totals.energy.round_to_ocpi_scale(),
1387            calculated: totals.energy.round_to_ocpi_scale(),
1388        },
1389        total_fixed_cost: Total {
1390            cdr: cdr_totals.fixed_cost.round_to_ocpi_scale(),
1391            calculated: total_costs.fixed.round_to_ocpi_scale(),
1392        },
1393    };
1394
1395    trace!("{report:#?}");
1396
1397    report
1398}
1399
1400/// Apply step sizes by iterating in reverse over the scratch periods to find the last period
1401/// that had an active component for each dimension, then mutating its `billed_volume`.
1402fn apply_step_sizes(
1403    periods: &mut [PeriodReportScratch],
1404    totals: &Totals,
1405) -> VerdictDeferred<Billable> {
1406    let mut warnings = warning::SetDeferred::new();
1407
1408    let has_idle_step_size = periods.iter().any(|p| p.step_size_duration_idle.is_some());
1409
1410    let duration_charging = if let Some(total) = totals.duration_charging {
1411        let mut result = Some(total);
1412        for period in periods.iter_mut().rev() {
1413            let Some(step) = period
1414                .step_size_duration_charging
1415                .as_ref()
1416                .map(|c| c.step_size)
1417            else {
1418                continue;
1419            };
1420            if has_idle_step_size {
1421                result = Some(total);
1422            } else if let Some(dim) = period.dimensions.duration_charging.as_mut() {
1423                let dt = duration_step_size(total, &mut dim.billed_volume, step)?
1424                    .gather_deferred_warnings_into(&mut warnings);
1425                result = Some(dt);
1426            }
1427            break;
1428        }
1429        result
1430    } else {
1431        None
1432    };
1433
1434    let duration_idle = if let Some(total) = totals.duration_idle {
1435        let mut result = Some(total);
1436        for period in periods.iter_mut().rev() {
1437            let Some(step) = period.step_size_duration_idle.as_ref().map(|c| c.step_size) else {
1438                continue;
1439            };
1440            if let Some(dim) = period.dimensions.duration_idle.as_mut() {
1441                let dt = duration_step_size(total, &mut dim.billed_volume, step)?
1442                    .gather_deferred_warnings_into(&mut warnings);
1443                result = Some(dt);
1444            }
1445            break;
1446        }
1447        result
1448    } else {
1449        None
1450    };
1451
1452    let energy = if let Some(total) = totals.energy {
1453        let mut result = Some(total);
1454        for period in periods.iter_mut().rev() {
1455            let Some(step) = period.step_size_energy.as_ref().map(|c| c.step_size) else {
1456                continue;
1457            };
1458            if step == 0 {
1459                result = Some(total);
1460            } else {
1461                let step_dec = Decimal::from(step);
1462                if let Some(dim) = period.dimensions.energy.as_mut() {
1463                    let Some(watt_hours) = total.watt_hours().checked_div(step_dec) else {
1464                        return warnings.bail(duration::Warning::Overflow.into());
1465                    };
1466                    let total_billed_volume =
1467                        Kwh::from_watt_hours(watt_hours.ceil().saturating_mul(step_dec));
1468                    let period_delta_volume = total_billed_volume.saturating_sub(total);
1469                    dim.billed_volume = dim.billed_volume.saturating_add(period_delta_volume);
1470                    result = Some(total_billed_volume);
1471                }
1472            }
1473            break;
1474        }
1475        result
1476    } else {
1477        None
1478    };
1479
1480    Ok(Billable {
1481        duration_charging,
1482        duration_idle,
1483        energy,
1484    }
1485    .into_caveat_deferred(warnings))
1486}
1487
1488/// Return the duration as a `Decimal` amount of seconds.
1489fn delta_as_seconds_dec(delta: TimeDelta) -> Decimal {
1490    Decimal::from(delta.num_milliseconds())
1491        .checked_div(Decimal::from(duration::MILLIS_IN_SEC))
1492        .expect("Can't overflow; See test `as_seconds_dec_should_not_overflow`")
1493}
1494
1495/// Create a `HoursDecimal` from a `Decimal` amount of seconds.
1496fn delta_from_seconds_dec(seconds: Decimal) -> VerdictDeferred<TimeDelta> {
1497    let millis = seconds.saturating_mul(Decimal::from(duration::MILLIS_IN_SEC));
1498    let Ok(millis) = i64::try_from(millis) else {
1499        return Err(warning::ErrorSetDeferred::with_warn(
1500            duration::Warning::Overflow.into(),
1501        ));
1502    };
1503    let Some(delta) = TimeDelta::try_milliseconds(millis) else {
1504        return Err(warning::ErrorSetDeferred::with_warn(
1505            duration::Warning::Overflow.into(),
1506        ));
1507    };
1508    Ok(delta.into_caveat_deferred(warning::SetDeferred::new()))
1509}
1510
1511/// Apply a duration based step size for either `time` or `idle_time`.
1512fn duration_step_size(
1513    total_volume: TimeDelta,
1514    period_billed_volume: &mut TimeDelta,
1515    step_size: u64,
1516) -> VerdictDeferred<TimeDelta> {
1517    if step_size == 0 {
1518        return Ok(total_volume.into_caveat_deferred(warning::SetDeferred::new()));
1519    }
1520
1521    let total_seconds = delta_as_seconds_dec(total_volume);
1522    let step_size = Decimal::from(step_size);
1523
1524    let Some(x) = total_seconds.checked_div(step_size) else {
1525        return Err(warning::ErrorSetDeferred::with_warn(
1526            duration::Warning::Overflow.into(),
1527        ));
1528    };
1529    let total_billed_volume = delta_from_seconds_dec(x.ceil().saturating_mul(step_size))?;
1530
1531    let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1532    *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1533
1534    Ok(total_billed_volume)
1535}