Skip to main content

ocpi_tariffs/
price.rs

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