Skip to main content

ocpi_tariffs/
price.rs

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