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