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