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 source_version = cdr_elem.version();
772    let cdr = parse_cdr(cdr_elem)?;
773
774    match tariff_source {
775        TariffSource::UseCdr => {
776            let (v221::cdr::WithTariffs { cdr, tariffs }, warnings) = cdr.into_parts();
777            debug!("Using tariffs from CDR");
778            let tariffs = tariffs
779                .iter()
780                .map(|elem| {
781                    // Parse the nested tariffs based on the original version of the CDR.
782                    match source_version {
783                        Version::V221 => crate::tariff::v221::Tariff::from_json(elem),
784                        Version::V211 => {
785                            let tariff = crate::tariff::v211::Tariff::from_json(elem);
786                            // `ocpi-tariffs` considers the `v221` tariff to be the "normalized" version.
787                            // A `v211` tariff is converted to a `v221` tariff for various operations.
788                            tariff.map_caveat(crate::tariff::v221::Tariff::from)
789                        }
790                    }
791                })
792                .collect::<Result<Vec<_>, _>>()?;
793
794            let cdr = cdr.into_caveat(warnings);
795
796            Ok(price_v221_cdr_with_tariffs(
797                cdr_elem, cdr, tariffs, timezone,
798            )?)
799        }
800        TariffSource::Override(tariffs) => {
801            let cdr = cdr.map(v221::cdr::WithTariffs::discard_tariffs);
802
803            debug!("Using override tariffs");
804            let tariffs = tariffs
805                .iter()
806                .map(tariff::parse)
807                .collect::<Result<Vec<_>, _>>()?;
808
809            Ok(price_v221_cdr_with_tariffs(
810                cdr_elem, cdr, tariffs, timezone,
811            )?)
812        }
813    }
814}
815
816/// Price a single charge-session using a tariff selected from a list.
817///
818/// Returns a report containing the totals, subtotals, and a breakdown of the calculation.
819/// Price a single charge-session using a single tariff.
820///
821/// Returns a report containing the totals, subtotals, and a breakdown of the calculation.
822fn price_v221_cdr_with_tariffs(
823    cdr_elem: &crate::cdr::Versioned<'_>,
824    cdr: Caveat<v221::Cdr, Warning>,
825    tariffs: Vec<Caveat<crate::tariff::v221::Tariff<'_>, crate::tariff::Warning>>,
826    timezone: Tz,
827) -> Verdict<Report> {
828    debug!(?timezone, version = ?cdr_elem.version(), "Pricing CDR");
829    let (cdr, mut warnings) = cdr.into_parts();
830    let v221::Cdr {
831        start_date_time,
832        end_date_time,
833        charging_periods,
834        totals: cdr_totals,
835    } = cdr;
836
837    // Convert each versioned tariff JSON to a structured tariff.
838    //
839    // This generates a list of `TariffReport`s that are returned to the caller in the `Report`.
840    // One of the structured tariffs is selected for use in the `price_periods` function.
841    let (tariff_reports, tariffs): (Vec<_>, Vec<_>) = tariffs
842        .into_iter()
843        .enumerate()
844        .map(|(index, tariff)| {
845            let (tariff, warnings) = tariff.into_parts();
846            (
847                TariffReport {
848                    origin: TariffOrigin {
849                        index,
850                        id: tariff.id.to_string(),
851                        currency: tariff.currency,
852                    },
853                    warnings: warnings.into_path_map(),
854                },
855                tariff,
856            )
857        })
858        .unzip();
859
860    debug!(tariffs = ?tariffs.iter().map(|t| t.id).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
861
862    let tariffs_normalized = tariff::normalize_all(&tariffs);
863    let Some((tariff_index, tariff)) =
864        tariff::find_first_active(tariffs_normalized, start_date_time)
865    else {
866        return warnings.bail(Warning::NoValidTariff, cdr_elem.as_element());
867    };
868
869    debug!(tariff_index, id = ?tariff.id(), "Found active tariff");
870    debug!(%timezone, "Found timezone");
871    // Convert the CDRs periods to the API input period.
872    let periods = charging_periods
873        .into_iter()
874        .map(Period::try_from)
875        .collect::<Result<Vec<_>, _>>()
876        .map_err(|err| warning::ErrorSet::with_warn(Warning::Parse(err), cdr_elem.as_element()))?;
877
878    let periods = normalize_periods(periods, end_date_time, timezone);
879    let price_cdr_report = price_periods(&periods, &tariff)
880        .with_element(cdr_elem.as_element())?
881        .gather_warnings_into(&mut warnings);
882
883    let report = generate_report(
884        &cdr_totals,
885        timezone,
886        tariff_reports,
887        price_cdr_report,
888        TariffOrigin {
889            index: tariff_index,
890            id: tariff.id().to_string(),
891            currency: tariff.currency(),
892        },
893    );
894
895    Ok(report.into_caveat(warnings))
896}
897
898/// Price a list of normalized [`Period`]s using a [`Versioned`](crate::tariff::Versioned) tariff.
899pub(crate) fn periods(
900    end_date_time: DateTime<Utc>,
901    timezone: Tz,
902    tariff_elem: &crate::tariff::v221::Tariff<'_>,
903    mut periods: Vec<Period>,
904) -> VerdictDeferred<PeriodsReport> {
905    // Make sure the periods are sorted by time as the start date of one period determines the end
906    // date of the previous period.
907    periods.sort_by_key(|p| p.start_date_time);
908    let tariff = Tariff::from_v221(tariff_elem);
909    let periods = normalize_periods(periods, end_date_time, timezone);
910    price_periods(&periods, &tariff)
911}
912
913fn normalize_periods(
914    periods: Vec<Period>,
915    end_date_time: DateTime<Utc>,
916    local_timezone: Tz,
917) -> Vec<PeriodNormalized> {
918    debug!("Normalizing CDR periods");
919
920    // Each new period is linked to the previous periods data.
921    let mut previous_end_snapshot = Option::<TotalsSnapshot>::None;
922
923    // The end-date of the first period is the start-date of the second and so on.
924    let end_dates = {
925        let mut end_dates = periods
926            .iter()
927            .skip(1)
928            .map(|p| p.start_date_time)
929            .collect::<Vec<_>>();
930
931        // The last end-date is the end-date of the CDR.
932        end_dates.push(end_date_time);
933        end_dates
934    };
935
936    let periods = periods
937        .into_iter()
938        .zip(end_dates)
939        .enumerate()
940        .map(|(index, (period, end_date_time))| {
941            trace!(index, "processing\n{period:#?}");
942            let Period {
943                start_date_time,
944                consumed,
945            } = period;
946
947            let period = if let Some(prev_end_snapshot) = previous_end_snapshot.take() {
948                let start_snapshot = prev_end_snapshot;
949                let end_snapshot = start_snapshot.next(&consumed, end_date_time);
950
951                let period = PeriodNormalized {
952                    consumed,
953                    start_snapshot,
954                    end_snapshot,
955                };
956                trace!("Adding new period based on the last added\n{period:#?}");
957                period
958            } else {
959                let start_snapshot = TotalsSnapshot::zero(start_date_time, local_timezone);
960                let end_snapshot = start_snapshot.next(&consumed, end_date_time);
961
962                let period = PeriodNormalized {
963                    consumed,
964                    start_snapshot,
965                    end_snapshot,
966                };
967                trace!("Adding new period\n{period:#?}");
968                period
969            };
970
971            previous_end_snapshot.replace(period.end_snapshot.clone());
972            period
973        })
974        .collect::<Vec<_>>();
975
976    periods
977}
978
979/// Price the given set of CDR periods using a normalized `Tariff`.
980fn price_periods(periods: &[PeriodNormalized], tariff: &Tariff) -> VerdictDeferred<PeriodsReport> {
981    debug!(count = periods.len(), "Pricing CDR periods");
982
983    if tracing::enabled!(tracing::Level::TRACE) {
984        trace!("# CDR period list:");
985        for period in periods {
986            trace!("{period:#?}");
987        }
988    }
989
990    let period_totals = period_totals(periods, tariff);
991    let (billed, warnings) = period_totals.calculate_billed()?.into_parts();
992    let (billable, periods, totals) = billed;
993    let total_costs = total_costs(&periods, tariff);
994    let report = PeriodsReport {
995        billable,
996        periods,
997        totals,
998        total_costs,
999    };
1000
1001    Ok(report.into_caveat_deferred(warnings))
1002}
1003
1004/// The internal report generated from the [`periods`] fn.
1005pub(crate) struct PeriodsReport {
1006    /// The billable dimensions calculated by applying the step-size to each dimension.
1007    pub billable: Billable,
1008
1009    /// A list of reports for each charging period that occurred during a session.
1010    pub periods: Vec<PeriodReport>,
1011
1012    /// The totals for each dimension.
1013    pub totals: Totals,
1014
1015    /// The total costs for each dimension.
1016    pub total_costs: TotalCosts,
1017}
1018
1019/// A report for a single charging period that occurred during a session.
1020///
1021/// A charging period is a period of time that has relevance for the total costs of a CDR.
1022/// During a charging session, different parameters change all the time, like the amount of energy used,
1023/// 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.
1024#[derive(Debug)]
1025pub struct PeriodReport {
1026    /// The start time of this period.
1027    pub start_date_time: DateTime<Utc>,
1028
1029    /// The end time of this period.
1030    pub end_date_time: DateTime<Utc>,
1031
1032    /// A structure that contains results per dimension.
1033    pub dimensions: Dimensions,
1034}
1035
1036impl PeriodReport {
1037    fn new(period: &PeriodNormalized, dimensions: Dimensions) -> Self {
1038        Self {
1039            start_date_time: period.start_snapshot.date_time,
1040            end_date_time: period.end_snapshot.date_time,
1041            dimensions,
1042        }
1043    }
1044
1045    /// The total cost of all dimensions in this period.
1046    pub fn cost(&self) -> Option<Price> {
1047        [
1048            self.dimensions.duration_charging.cost(),
1049            self.dimensions.duration_parking.cost(),
1050            self.dimensions.flat.cost(),
1051            self.dimensions.energy.cost(),
1052        ]
1053        .into_iter()
1054        .fold(None, |accum, next| {
1055            if accum.is_none() && next.is_none() {
1056                None
1057            } else {
1058                Some(
1059                    accum
1060                        .unwrap_or_default()
1061                        .saturating_add(next.unwrap_or_default()),
1062                )
1063            }
1064        })
1065    }
1066}
1067
1068/// The result of normalizing the CDR charging periods.
1069#[derive(Debug)]
1070struct PeriodTotals {
1071    /// The list of normalized periods.
1072    periods: Vec<PeriodReport>,
1073
1074    /// The computed step size.
1075    step_size: StepSize,
1076
1077    /// The totals for each dimension.
1078    totals: Totals,
1079}
1080
1081/// The totals for each dimension.
1082#[derive(Debug, Default)]
1083pub(crate) struct Totals {
1084    /// The total energy used during a session.
1085    pub energy: Option<Kwh>,
1086
1087    /// The total charging time used during a session.
1088    ///
1089    /// Some if the charging happened during the session.
1090    pub duration_charging: Option<TimeDelta>,
1091
1092    /// The total parking time used during a session.
1093    ///
1094    /// Some if there was idle time during the session.
1095    pub duration_parking: Option<TimeDelta>,
1096}
1097
1098impl PeriodTotals {
1099    /// Calculate the billed dimensions by applying the step-size to each dimension.
1100    ///
1101    /// Applying the step size can mutate the dimension values contained in the `Period`.
1102    fn calculate_billed(self) -> VerdictDeferred<(Billable, Vec<PeriodReport>, Totals)> {
1103        let mut warnings = warning::SetDeferred::new();
1104        let Self {
1105            mut periods,
1106            step_size,
1107            totals,
1108        } = self;
1109        let charging_time = totals
1110            .duration_charging
1111            .map(|dt| step_size.apply_time(&mut periods, dt))
1112            .transpose()?
1113            .gather_deferred_warnings_into(&mut warnings);
1114        let energy = totals
1115            .energy
1116            .map(|kwh| step_size.apply_energy(&mut periods, kwh))
1117            .transpose()?
1118            .gather_deferred_warnings_into(&mut warnings);
1119        let parking_time = totals
1120            .duration_parking
1121            .map(|dt| step_size.apply_parking_time(&mut periods, dt))
1122            .transpose()?
1123            .gather_deferred_warnings_into(&mut warnings);
1124        let billed = Billable {
1125            charging_time,
1126            energy,
1127            parking_time,
1128        };
1129        Ok((billed, periods, totals).into_caveat_deferred(warnings))
1130    }
1131}
1132
1133/// The billable dimensions calculated by applying the step-size to each dimension.
1134#[derive(Debug)]
1135pub(crate) struct Billable {
1136    /// The billable charging time.
1137    charging_time: Option<TimeDelta>,
1138
1139    /// The billable energy use.
1140    energy: Option<Kwh>,
1141
1142    /// The billable parking time.
1143    parking_time: Option<TimeDelta>,
1144}
1145
1146/// Map the `session::ChargePeriod`s to a normalized `Period` and calculate the step size and
1147/// totals for each dimension.
1148fn period_totals(periods: &[PeriodNormalized], tariff: &Tariff) -> PeriodTotals {
1149    let mut has_flat_fee = false;
1150    let mut step_size = StepSize::new();
1151    let mut totals = Totals::default();
1152
1153    debug!(
1154        tariff_id = tariff.id(),
1155        period_count = periods.len(),
1156        "Accumulating dimension totals for each period"
1157    );
1158
1159    let periods = periods
1160        .iter()
1161        .enumerate()
1162        .map(|(index, period)| {
1163            let mut component_set = tariff.active_components(period);
1164            trace!(
1165                index,
1166                "Creating charge period with Dimension\n{period:#?}\n{component_set:#?}"
1167            );
1168
1169            if component_set.flat.is_some() {
1170                if has_flat_fee {
1171                    component_set.flat = None;
1172                } else {
1173                    has_flat_fee = true;
1174                }
1175            }
1176
1177            step_size.update(index, &component_set, period);
1178
1179            trace!(period_index = index, "Step size updated\n{step_size:#?}");
1180
1181            let dimensions = Dimensions::new(component_set, &period.consumed);
1182
1183            trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
1184
1185            if let Some(dt) = dimensions.duration_charging.volume {
1186                let acc = totals.duration_charging.get_or_insert_default();
1187                *acc = acc.saturating_add(dt);
1188            }
1189
1190            if let Some(kwh) = dimensions.energy.volume {
1191                let acc = totals.energy.get_or_insert_default();
1192                *acc = acc.saturating_add(kwh);
1193            }
1194
1195            if let Some(dt) = dimensions.duration_parking.volume {
1196                let acc = totals.duration_parking.get_or_insert_default();
1197                *acc = acc.saturating_add(dt);
1198            }
1199
1200            trace!(period_index = index, ?totals, "Update totals");
1201
1202            PeriodReport::new(period, dimensions)
1203        })
1204        .collect::<Vec<_>>();
1205
1206    PeriodTotals {
1207        periods,
1208        step_size,
1209        totals,
1210    }
1211}
1212
1213/// The total costs for each dimension.
1214#[derive(Debug, Default)]
1215pub(crate) struct TotalCosts {
1216    /// The [`Price`] for all energy used during a session.
1217    pub energy: Option<Price>,
1218
1219    /// The [`Price`] for all flat rates applied during a session.
1220    pub fixed: Option<Price>,
1221
1222    /// The [`Price`] for all charging time used during a session.
1223    pub duration_charging: Option<Price>,
1224
1225    /// The [`Price`] for all parking time used during a session.
1226    pub duration_parking: Option<Price>,
1227}
1228
1229impl TotalCosts {
1230    /// Summate each dimension total into a single total.
1231    ///
1232    /// Return `None` if there are no cost dimensions otherwise return `Some`.
1233    pub(crate) fn total(&self) -> Option<Price> {
1234        let Self {
1235            energy,
1236            fixed,
1237            duration_charging,
1238            duration_parking,
1239        } = self;
1240        debug!(
1241            energy = %DisplayOption(*energy),
1242            fixed = %DisplayOption(*fixed),
1243            duration_charging = %DisplayOption(*duration_charging),
1244            duration_parking = %DisplayOption(*duration_parking),
1245            "Calculating total costs."
1246        );
1247        [energy, fixed, duration_charging, duration_parking]
1248            .into_iter()
1249            .fold(None, |accum: Option<Price>, next| match (accum, next) {
1250                (None, None) => None,
1251                _ => Some(
1252                    accum
1253                        .unwrap_or_default()
1254                        .saturating_add(next.unwrap_or_default()),
1255                ),
1256            })
1257    }
1258}
1259
1260/// Accumulate total costs per dimension across all periods.
1261fn total_costs(periods: &[PeriodReport], tariff: &Tariff) -> TotalCosts {
1262    let mut total_costs = TotalCosts::default();
1263
1264    debug!(
1265        tariff_id = tariff.id(),
1266        period_count = periods.len(),
1267        "Accumulating dimension costs for each period"
1268    );
1269    for (index, period) in periods.iter().enumerate() {
1270        let dimensions = &period.dimensions;
1271
1272        trace!(period_index = index, "Processing period");
1273
1274        let energy_cost = dimensions.energy.cost();
1275        let fixed_cost = dimensions.flat.cost();
1276        let duration_charging_cost = dimensions.duration_charging.cost();
1277        let duration_parking_cost = dimensions.duration_parking.cost();
1278
1279        trace!(?total_costs.energy, ?energy_cost, "Energy cost");
1280        trace!(?total_costs.duration_charging, ?duration_charging_cost, "Charging cost");
1281        trace!(?total_costs.duration_parking, ?duration_parking_cost, "Parking cost");
1282        trace!(?total_costs.fixed, ?fixed_cost, "Fixed cost");
1283
1284        total_costs.energy = match (total_costs.energy, energy_cost) {
1285            (None, None) => None,
1286            (total, period) => Some(
1287                total
1288                    .unwrap_or_default()
1289                    .saturating_add(period.unwrap_or_default()),
1290            ),
1291        };
1292
1293        total_costs.duration_charging =
1294            match (total_costs.duration_charging, duration_charging_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.duration_parking = match (total_costs.duration_parking, duration_parking_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        total_costs.fixed = match (total_costs.fixed, fixed_cost) {
1313            (None, None) => None,
1314            (total, period) => Some(
1315                total
1316                    .unwrap_or_default()
1317                    .saturating_add(period.unwrap_or_default()),
1318            ),
1319        };
1320
1321        trace!(period_index = index, ?total_costs, "Update totals");
1322    }
1323
1324    total_costs
1325}
1326
1327fn generate_report(
1328    cdr_totals: &v221::cdr::Totals,
1329    timezone: Tz,
1330    tariff_reports: Vec<TariffReport>,
1331    price_periods_report: PeriodsReport,
1332    tariff_used: TariffOrigin,
1333) -> Report {
1334    let PeriodsReport {
1335        billable,
1336        periods,
1337        totals,
1338        total_costs,
1339    } = price_periods_report;
1340    trace!("Update billed totals {billable:#?}");
1341
1342    let total_cost = total_costs.total();
1343
1344    debug!(total_cost = %DisplayOption(total_cost.as_ref()));
1345
1346    let total_time = {
1347        debug!(
1348            period_start = %DisplayOption(periods.first().map(|p| p.start_date_time)),
1349            period_end = %DisplayOption(periods.last().map(|p| p.end_date_time)),
1350            "Calculating `total_time`"
1351        );
1352
1353        periods
1354            .first()
1355            .zip(periods.last())
1356            .map(|(first, last)| {
1357                last.end_date_time
1358                    .signed_duration_since(first.start_date_time)
1359            })
1360            .unwrap_or_default()
1361    };
1362    debug!(total_time = %Hms(total_time));
1363
1364    let report = Report {
1365        periods,
1366        tariff_used,
1367        timezone: timezone.to_string(),
1368        billed_parking_time: billable.parking_time,
1369        billed_energy: billable.energy.round_to_ocpi_scale(),
1370        billed_charging_time: billable.charging_time,
1371        tariff_reports,
1372        total_charging_time: totals.duration_charging,
1373        total_cost: Total {
1374            cdr: cdr_totals.cost.round_to_ocpi_scale(),
1375            calculated: total_cost.round_to_ocpi_scale(),
1376        },
1377        total_time_cost: Total {
1378            cdr: cdr_totals.time_cost.round_to_ocpi_scale(),
1379            calculated: total_costs.duration_charging.round_to_ocpi_scale(),
1380        },
1381        total_time: Total {
1382            cdr: cdr_totals.time,
1383            calculated: total_time,
1384        },
1385        total_parking_cost: Total {
1386            cdr: cdr_totals.parking_cost.round_to_ocpi_scale(),
1387            calculated: total_costs.duration_parking.round_to_ocpi_scale(),
1388        },
1389        total_parking_time: Total {
1390            cdr: cdr_totals.parking_time,
1391            calculated: totals.duration_parking,
1392        },
1393        total_energy_cost: Total {
1394            cdr: cdr_totals.energy_cost.round_to_ocpi_scale(),
1395            calculated: total_costs.energy.round_to_ocpi_scale(),
1396        },
1397        total_energy: Total {
1398            cdr: cdr_totals.energy.round_to_ocpi_scale(),
1399            calculated: totals.energy.round_to_ocpi_scale(),
1400        },
1401        total_fixed_cost: Total {
1402            cdr: cdr_totals.fixed_cost.round_to_ocpi_scale(),
1403            calculated: total_costs.fixed.round_to_ocpi_scale(),
1404        },
1405        total_reservation_cost: Total {
1406            cdr: cdr_totals.reservation_cost.round_to_ocpi_scale(),
1407            calculated: None,
1408        },
1409    };
1410
1411    trace!("{report:#?}");
1412
1413    report
1414}
1415
1416#[derive(Debug)]
1417struct StepSize {
1418    charging_time: Option<(usize, Component)>,
1419    parking_time: Option<(usize, Component)>,
1420    energy: Option<(usize, Component)>,
1421}
1422
1423/// Return the duration as a `Decimal` amount of seconds.
1424fn delta_as_seconds_dec(delta: TimeDelta) -> Decimal {
1425    Decimal::from(delta.num_milliseconds())
1426        .checked_div(Decimal::from(duration::MILLIS_IN_SEC))
1427        .expect("Can't overflow; See test `as_seconds_dec_should_not_overflow`")
1428}
1429
1430/// Create a `HoursDecimal` from a `Decimal` amount of seconds.
1431fn delta_from_seconds_dec(seconds: Decimal) -> VerdictDeferred<TimeDelta> {
1432    let millis = seconds.saturating_mul(Decimal::from(duration::MILLIS_IN_SEC));
1433    let Ok(millis) = i64::try_from(millis) else {
1434        return Err(warning::ErrorSetDeferred::with_warn(
1435            duration::Warning::Overflow.into(),
1436        ));
1437    };
1438    let Some(delta) = TimeDelta::try_milliseconds(millis) else {
1439        return Err(warning::ErrorSetDeferred::with_warn(
1440            duration::Warning::Overflow.into(),
1441        ));
1442    };
1443    Ok(delta.into_caveat_deferred(warning::SetDeferred::new()))
1444}
1445
1446impl StepSize {
1447    fn new() -> Self {
1448        Self {
1449            charging_time: None,
1450            parking_time: None,
1451            energy: None,
1452        }
1453    }
1454
1455    fn update(&mut self, index: usize, components: &ComponentSet, period: &PeriodNormalized) {
1456        if period.consumed.energy.is_some() {
1457            if let Some(energy) = components.energy.clone() {
1458                self.energy = Some((index, energy));
1459            }
1460        }
1461
1462        if period.consumed.duration_charging.is_some() {
1463            if let Some(time) = components.duration_charging.clone() {
1464                self.charging_time = Some((index, time));
1465            }
1466        }
1467
1468        if period.consumed.duration_parking.is_some() {
1469            if let Some(parking) = components.duration_parking.clone() {
1470                self.parking_time = Some((index, parking));
1471            }
1472        }
1473    }
1474
1475    fn duration_step_size(
1476        total_volume: TimeDelta,
1477        period_billed_volume: &mut TimeDelta,
1478        step_size: u64,
1479    ) -> VerdictDeferred<TimeDelta> {
1480        if step_size == 0 {
1481            return Ok(total_volume.into_caveat_deferred(warning::SetDeferred::new()));
1482        }
1483
1484        let total_seconds = delta_as_seconds_dec(total_volume);
1485        let step_size = Decimal::from(step_size);
1486
1487        let Some(x) = total_seconds.checked_div(step_size) else {
1488            return Err(warning::ErrorSetDeferred::with_warn(
1489                duration::Warning::Overflow.into(),
1490            ));
1491        };
1492        let total_billed_volume = delta_from_seconds_dec(x.ceil().saturating_mul(step_size))?;
1493
1494        let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1495        *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1496
1497        Ok(total_billed_volume)
1498    }
1499
1500    fn apply_time(
1501        &self,
1502        periods: &mut [PeriodReport],
1503        total: TimeDelta,
1504    ) -> VerdictDeferred<TimeDelta> {
1505        let (Some((time_index, price)), None) = (&self.charging_time, &self.parking_time) else {
1506            return Ok(total.into_caveat_deferred(warning::SetDeferred::new()));
1507        };
1508
1509        let Some(period) = periods.get_mut(*time_index) else {
1510            error!(time_index, "Invalid period index");
1511            return Err(warning::ErrorSetDeferred::with_warn(Warning::InternalError));
1512        };
1513        let Some(volume) = period.dimensions.duration_charging.billed_volume.as_mut() else {
1514            return Err(warning::ErrorSetDeferred::with_warn(
1515                Warning::DimensionShouldHaveVolume {
1516                    dimension_name: "time",
1517                },
1518            ));
1519        };
1520
1521        Self::duration_step_size(total, volume, price.step_size)
1522    }
1523
1524    fn apply_parking_time(
1525        &self,
1526        periods: &mut [PeriodReport],
1527        total: TimeDelta,
1528    ) -> VerdictDeferred<TimeDelta> {
1529        let warnings = warning::SetDeferred::new();
1530        let Some((parking_index, price)) = &self.parking_time else {
1531            return Ok(total.into_caveat_deferred(warnings));
1532        };
1533
1534        let Some(period) = periods.get_mut(*parking_index) else {
1535            error!(parking_index, "Invalid period index");
1536            return warnings.bail(Warning::InternalError);
1537        };
1538        let Some(volume) = period.dimensions.duration_parking.billed_volume.as_mut() else {
1539            return warnings.bail(Warning::DimensionShouldHaveVolume {
1540                dimension_name: "parking_time",
1541            });
1542        };
1543
1544        Self::duration_step_size(total, volume, price.step_size)
1545    }
1546
1547    fn apply_energy(
1548        &self,
1549        periods: &mut [PeriodReport],
1550        total_volume: Kwh,
1551    ) -> VerdictDeferred<Kwh> {
1552        let warnings = warning::SetDeferred::new();
1553        let Some((energy_index, price)) = &self.energy else {
1554            return Ok(total_volume.into_caveat_deferred(warnings));
1555        };
1556
1557        if price.step_size == 0 {
1558            return Ok(total_volume.into_caveat_deferred(warnings));
1559        }
1560
1561        let Some(period) = periods.get_mut(*energy_index) else {
1562            error!(energy_index, "Invalid period index");
1563            return warnings.bail(Warning::InternalError);
1564        };
1565        let step_size = Decimal::from(price.step_size);
1566
1567        let Some(period_billed_volume) = period.dimensions.energy.billed_volume.as_mut() else {
1568            return warnings.bail(Warning::DimensionShouldHaveVolume {
1569                dimension_name: "energy",
1570            });
1571        };
1572
1573        let Some(watt_hours) = total_volume.watt_hours().checked_div(step_size) else {
1574            return warnings.bail(duration::Warning::Overflow.into());
1575        };
1576
1577        let total_billed_volume = Kwh::from_watt_hours(watt_hours.ceil().saturating_mul(step_size));
1578        let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1579        *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1580
1581        Ok(total_billed_volume.into_caveat_deferred(warnings))
1582    }
1583}
1584
1585fn parse_cdr<'buf>(cdr: &crate::cdr::Versioned<'buf>) -> Verdict<v221::cdr::WithTariffs<'buf>> {
1586    match cdr.version() {
1587        Version::V211 => {
1588            let cdr = v211::cdr::WithTariffs::from_json(cdr.as_element())?;
1589            Ok(cdr.map(v221::cdr::WithTariffs::from))
1590        }
1591        Version::V221 => v221::cdr::WithTariffs::from_json(cdr.as_element()),
1592    }
1593}