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