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