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
3mod tariff;
4mod v211;
5mod v221;
6
7use std::{borrow::Cow, collections::BTreeMap, fmt, ops::Range};
8
9use chrono::{DateTime, Datelike, TimeDelta, Utc};
10use chrono_tz::Tz;
11use rust_decimal::Decimal;
12use tracing::{debug, error, instrument, trace};
13
14use crate::{
15    country, currency, datetime,
16    duration::{self, Hms},
17    from_warning_all, into_caveat_all,
18    json::{self, FromJson as _},
19    money,
20    number::{self, FromDecimal, TruncateDecimal},
21    string,
22    warning::{
23        self, GatherWarningKinds as _, GatherWarnings, IntoCaveat, IntoCaveatDeferred as _,
24        WithElement as _,
25    },
26    weekday, Ampere, Caveat, Cost, DisplayOption, Kw, Kwh, Money, ParseError, Price,
27    SaturatingAdd as _, SaturatingSub as _, VatApplicable, VerdictExt as _, Version,
28    Versioned as _,
29};
30
31use tariff::Tariff;
32
33type Verdict<T> = crate::Verdict<T, WarningKind>;
34type VerdictDeferred<T> = warning::VerdictDeferred<T, WarningKind>;
35
36into_caveat_all!(PeriodNormalized, PeriodsReport, Report);
37
38/// A normalized/expanded form of a charging period to make the pricing calculation simpler.
39///
40/// The simplicity comes through avoiding having to look up the next period to figure out the end
41/// of the current period.
42#[derive(Debug)]
43struct PeriodNormalized {
44    /// The set of quantities consumed across the duration of the `Period`.
45    consumed: Consumed,
46
47    /// A snapshot of the values of various quantities at the start of the charge period.
48    start_snapshot: TotalsSnapshot,
49
50    /// A snapshot of the values of various quantities at the end of the charge period.
51    end_snapshot: TotalsSnapshot,
52}
53
54/// The set of quantities consumed across the duration of the `Period`.
55#[derive(Clone, Debug)]
56#[cfg_attr(test, derive(Default))]
57pub(crate) struct Consumed {
58    /// The peak current during this period.
59    pub current_max: Option<Ampere>,
60
61    /// The lowest current during this period.
62    pub current_min: Option<Ampere>,
63
64    /// The charging time consumed in this period.
65    pub duration_charging: Option<TimeDelta>,
66
67    /// The parking time consumed in this period.
68    pub duration_parking: Option<TimeDelta>,
69
70    /// The energy consumed in this period.
71    pub energy: Option<Kwh>,
72
73    /// The maximum power reached during this period.
74    pub power_max: Option<Kw>,
75
76    /// The minimum power reached during this period.
77    pub power_min: Option<Kw>,
78}
79
80/// A snapshot of the values of various quantities at the start and end of the charge period.
81#[derive(Clone, Debug)]
82struct TotalsSnapshot {
83    /// The `DateTime` this snapshot of total quantities was taken.
84    date_time: DateTime<Utc>,
85
86    /// The total energy consumed during a charging period.
87    energy: Kwh,
88
89    /// The local timezone.
90    local_timezone: Tz,
91
92    /// The total charging duration during a charging period.
93    duration_charging: TimeDelta,
94
95    /// The total period duration during a charging period.
96    duration_total: TimeDelta,
97}
98
99impl TotalsSnapshot {
100    /// Create a snapshot where all quantities are zero.
101    fn zero(date_time: DateTime<Utc>, local_timezone: Tz) -> Self {
102        Self {
103            date_time,
104            energy: Kwh::zero(),
105            local_timezone,
106            duration_charging: TimeDelta::zero(),
107            duration_total: TimeDelta::zero(),
108        }
109    }
110
111    /// Create a new snapshot based on the current snapshot with consumed quantities added.
112    fn next(&self, consumed: &Consumed, date_time: DateTime<Utc>) -> Self {
113        let duration = date_time.signed_duration_since(self.date_time);
114
115        let mut next = Self {
116            date_time,
117            energy: self.energy,
118            local_timezone: self.local_timezone,
119            duration_charging: self.duration_charging,
120            duration_total: self
121                .duration_total
122                .checked_add(&duration)
123                .unwrap_or(TimeDelta::MAX),
124        };
125
126        if let Some(duration) = consumed.duration_charging {
127            next.duration_charging = next
128                .duration_charging
129                .checked_add(&duration)
130                .unwrap_or(TimeDelta::MAX);
131        }
132
133        if let Some(energy) = consumed.energy {
134            next.energy = next.energy.saturating_add(energy);
135        }
136
137        next
138    }
139
140    /// Return the local time of this snapshot.
141    fn local_time(&self) -> chrono::NaiveTime {
142        self.date_time.with_timezone(&self.local_timezone).time()
143    }
144
145    /// Return the local date of this snapshot.
146    fn local_date(&self) -> chrono::NaiveDate {
147        self.date_time
148            .with_timezone(&self.local_timezone)
149            .date_naive()
150    }
151
152    /// Return the local `Weekday` of this snapshot.
153    fn local_weekday(&self) -> chrono::Weekday {
154        self.date_time.with_timezone(&self.local_timezone).weekday()
155    }
156}
157
158/// Structure containing the charge session priced according to the specified tariff.
159/// The fields prefixed `total` correspond to CDR fields with the same name.
160#[derive(Debug)]
161pub struct Report {
162    /// Charge session details per period.
163    pub periods: Vec<PeriodReport>,
164
165    /// The index of the tariff that was used for pricing.
166    pub tariff_used: TariffOrigin,
167
168    /// A list of reports for each tariff found in the CDR or supplied to the [`cdr::price`](crate::cdr::price) function.
169    ///
170    /// The order of the `tariff::Report`s are the same as the order in which they are given.
171    pub tariff_reports: Vec<TariffReport>,
172
173    /// Time-zone that was either specified or detected.
174    pub timezone: String,
175
176    /* Billed Quantities */
177    /// The total charging time after applying step-size.
178    pub billed_charging_time: Option<TimeDelta>,
179
180    /// The total energy after applying step-size.
181    pub billed_energy: Option<Kwh>,
182
183    /// The total parking time after applying step-size
184    pub billed_parking_time: Option<TimeDelta>,
185
186    /* Totals */
187    /// Total duration of the charging session (excluding not charging), in hours.
188    ///
189    /// This is a total that has no direct source field in the `CDR` as it is calculated in the
190    /// [`cdr::price`](crate::cdr::price) function.
191    pub total_charging_time: Option<TimeDelta>,
192
193    /// Total energy charged, in kWh.
194    pub total_energy: Total<Kwh, Option<Kwh>>,
195
196    /// Total duration of the charging session where the EV was not charging (no energy was transferred between EVSE and EV).
197    pub total_parking_time: Total<Option<TimeDelta>>,
198
199    /// Total duration of the charging session (including the duration of charging and not charging).
200    pub total_time: Total<TimeDelta>,
201
202    /* Costs */
203    /// Total sum of all the costs of this transaction in the specified currency.
204    pub total_cost: Total<Price, Option<Price>>,
205
206    /// Total sum of all the cost of all the energy used, in the specified currency.
207    pub total_energy_cost: Total<Option<Price>>,
208
209    /// 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.
210    pub total_fixed_cost: Total<Option<Price>>,
211
212    /// Total sum of all the cost related to parking of this transaction, including fixed price components, in the specified currency.
213    pub total_parking_cost: Total<Option<Price>>,
214
215    /// Total sum of all the cost related to a reservation of a Charge Point, including fixed price components, in the specified currency.
216    pub total_reservation_cost: Total<Option<Price>>,
217
218    /// Total sum of all the cost related to duration of charging during this transaction, in the specified currency.
219    pub total_time_cost: Total<Option<Price>>,
220}
221
222/// The warnings that happen when pricing a CDR.
223#[derive(Debug)]
224pub enum WarningKind {
225    Country(country::WarningKind),
226    Currency(currency::WarningKind),
227    DateTime(datetime::WarningKind),
228    Decode(json::decode::WarningKind),
229    Duration(duration::WarningKind),
230
231    /// The `$.country` field should be an alpha-2 country code.
232    ///
233    /// The alpha-3 code can be converted into an alpha-3 but the caller should be warned.
234    CountryShouldBeAlpha2,
235
236    /// The given dimension should have a volume
237    DimensionShouldHaveVolume {
238        dimension_name: &'static str,
239    },
240
241    /// A field in the tariff doesn't have the expected type.
242    FieldInvalidType {
243        /// The type that the given field should have according to the schema.
244        expected_type: json::ValueKind,
245    },
246
247    /// A field in the tariff doesn't have the expected value.
248    FieldInvalidValue {
249        /// The value encountered.
250        value: String,
251
252        /// A message about what values are expected for this field.
253        message: Cow<'static, str>,
254    },
255
256    /// The given field is required.
257    FieldRequired {
258        field_name: Cow<'static, str>,
259    },
260
261    /// An internal error occurred.
262    ///
263    /// The details are logged using debug level.
264    InternalError,
265
266    Money(money::WarningKind),
267
268    /// The CDR has no charging periods.
269    NoPeriods,
270
271    /// No valid tariff has been found in the list of provided tariffs.
272    /// The tariff list can be sourced from either the tariffs contained in the CDR or from a list
273    /// provided by the caller.
274    ///
275    /// A valid tariff must have a start date-time before the start of the session and an end
276    /// date-time after the start of the session.
277    ///
278    /// If the CDR does not contain any tariffs consider providing a them using [`TariffSource`]
279    /// when calling [`cdr::price`](crate::cdr::price).
280    NoValidTariff,
281
282    Number(number::WarningKind),
283
284    /// An error occurred while deserializing a `CDR` or tariff.
285    Parse(ParseError),
286
287    /// The `start_date_time` of at least one of the `charging_periods` is outside of the
288    /// CDR's `start_date_time`-`end_date_time` range.
289    PeriodsOutsideStartEndDateTime {
290        cdr_range: Range<DateTime<Utc>>,
291        period_range: PeriodRange,
292    },
293
294    String(string::WarningKind),
295
296    /// Converting the `tariff::Versioned` into a structured `tariff::v221::Tariff` caused an
297    /// unrecoverable error.
298    Tariff(crate::tariff::WarningKind),
299
300    Weekday(weekday::WarningKind),
301}
302
303impl WarningKind {
304    /// Create a new `WarningKind::FieldInvalidValue` where the field is built from the given `json::Element`.
305    fn field_invalid_value(
306        value: impl Into<String>,
307        message: impl Into<Cow<'static, str>>,
308    ) -> Self {
309        WarningKind::FieldInvalidValue {
310            value: value.into(),
311            message: message.into(),
312        }
313    }
314}
315
316impl fmt::Display for WarningKind {
317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318        match self {
319            Self::Country(warning_kind) => write!(f, "{warning_kind}"),
320            Self::CountryShouldBeAlpha2 => {
321                f.write_str("The `$.country` field should be an alpha-2 country code.")
322            }
323            Self::Currency(warning_kind) => write!(f, "{warning_kind}"),
324            Self::DateTime(warning_kind) => write!(f, "{warning_kind}"),
325            Self::Decode(warning_kind) => write!(f, "{warning_kind}"),
326            Self::DimensionShouldHaveVolume { dimension_name } => {
327                write!(f, "Dimension `{dimension_name}` should have volume")
328            }
329            Self::Duration(warning_kind) => write!(f, "{warning_kind}"),
330            Self::FieldInvalidType { expected_type } => {
331                write!(f, "Field has invalid type. Expected type `{expected_type}`")
332            }
333            Self::FieldInvalidValue { value, message } => {
334                write!(f, "Field has invalid value `{value}`: {message}")
335            }
336            Self::FieldRequired { field_name } => {
337                write!(f, "Field is required: `{field_name}`")
338            }
339            Self::InternalError => f.write_str("Internal error"),
340            Self::Money(warning_kind) => write!(f, "{warning_kind}"),
341            Self::NoPeriods => f.write_str("The CDR has no charging periods"),
342            Self::NoValidTariff => {
343                f.write_str("No valid tariff has been found in the list of provided tariffs")
344            }
345            Self::Number(warning_kind) => write!(f, "{warning_kind}"),
346            Self::Parse(err) => {
347                write!(f, "{err}")
348            }
349            Self::PeriodsOutsideStartEndDateTime {
350                cdr_range: Range { start, end },
351                period_range,
352            } => {
353                write!(
354                    f,
355                    "The CDR's charging period time range is not contained within the `start_date_time` \
356                    and `end_date_time`; cdr_range: {start}-{end}, period_range: {period_range}",
357                )
358            }
359            Self::String(warning_kind) => write!(f, "{warning_kind}"),
360            Self::Tariff(warnings) => {
361                write!(f, "Tariff warnings: {warnings:?}")
362            }
363            Self::Weekday(warning_kind) => write!(f, "{warning_kind}"),
364        }
365    }
366}
367
368impl warning::Kind for WarningKind {
369    fn id(&self) -> Cow<'static, str> {
370        match self {
371            Self::Country(kind) => kind.id(),
372            Self::CountryShouldBeAlpha2 => "country_should_be_alpha_2".into(),
373            Self::Currency(kind) => kind.id(),
374            Self::DateTime(kind) => kind.id(),
375            Self::Decode(kind) => kind.id(),
376            Self::DimensionShouldHaveVolume { dimension_name } => {
377                format!("dimension_should_have_volume({dimension_name})").into()
378            }
379            Self::Duration(kind) => kind.id(),
380            Self::FieldInvalidType { .. } => "field_invalid_type".into(),
381            Self::FieldInvalidValue { .. } => "field_invalid_value".into(),
382            Self::FieldRequired { field_name } => format!("field_required({field_name})").into(),
383            Self::InternalError => "internal_error".into(),
384            Self::Money(kind) => kind.id(),
385            Self::NoPeriods => "no_periods".into(),
386            Self::NoValidTariff => "no_valid_tariff".into(),
387            Self::Number(kind) => kind.id(),
388            Self::Parse(ParseError { object: _, kind }) => {
389                format!("parse_error.{}", kind.id()).into()
390            }
391            Self::PeriodsOutsideStartEndDateTime { .. } => {
392                "periods_outside_start_end_date_time".into()
393            }
394            Self::String(kind) => kind.id(),
395            Self::Tariff(kind) => kind.id(),
396            Self::Weekday(kind) => kind.id(),
397        }
398    }
399}
400
401from_warning_all!(
402    country::WarningKind => WarningKind::Country,
403    currency::WarningKind => WarningKind::Currency,
404    datetime::WarningKind => WarningKind::DateTime,
405    duration::WarningKind => WarningKind::Duration,
406    json::decode::WarningKind => WarningKind::Decode,
407    money::WarningKind => WarningKind::Money,
408    number::WarningKind => WarningKind::Number,
409    string::WarningKind => WarningKind::String,
410    crate::tariff::WarningKind => WarningKind::Tariff,
411    weekday::WarningKind => WarningKind::Weekday
412);
413
414/// A report of parsing and using the referenced tariff to price a CDR.
415#[derive(Debug)]
416pub struct TariffReport {
417    /// The id of the tariff.
418    pub origin: TariffOrigin,
419
420    /// Warnings from parsing a tariff.
421    ///
422    /// Each entry in the map is an element path and a list of associated warnings.
423    pub warnings: BTreeMap<String, Vec<crate::tariff::WarningKind>>,
424}
425
426/// The origin data for a tariff.
427#[derive(Clone, Debug)]
428pub struct TariffOrigin {
429    /// The index of the tariff in the CDR JSON or in the list of override tariffs.
430    pub index: usize,
431
432    /// The value of the `id` field in the tariff JSON.
433    pub id: String,
434}
435
436/// A CDR charge period in a normalized form ready for pricing.
437#[derive(Debug)]
438pub(crate) struct Period {
439    /// The start time of this period.
440    pub start_date_time: DateTime<Utc>,
441
442    /// The quantities consumed during this period.
443    pub consumed: Consumed,
444}
445
446/// A structure containing a report for each dimension.
447#[derive(Debug)]
448pub struct Dimensions {
449    /// Energy consumed.
450    pub energy: Dimension<Kwh>,
451
452    /// Flat fee without unit for `step_size`.
453    pub flat: Dimension<()>,
454
455    /// Duration of time charging.
456    pub duration_charging: Dimension<TimeDelta>,
457
458    /// Duration of time not charging.
459    pub duration_parking: Dimension<TimeDelta>,
460}
461
462impl Dimensions {
463    fn new(components: ComponentSet, consumed: &Consumed) -> Self {
464        let ComponentSet {
465            energy: energy_price,
466            flat: flat_price,
467            duration_charging: duration_charging_price,
468            duration_parking: duration_parking_price,
469        } = components;
470
471        let Consumed {
472            duration_charging,
473            duration_parking,
474            energy,
475            current_max: _,
476            current_min: _,
477            power_max: _,
478            power_min: _,
479        } = consumed;
480
481        Self {
482            energy: Dimension {
483                price: energy_price,
484                volume: *energy,
485                billed_volume: *energy,
486            },
487            flat: Dimension {
488                price: flat_price,
489                volume: Some(()),
490                billed_volume: Some(()),
491            },
492            duration_charging: Dimension {
493                price: duration_charging_price,
494                volume: *duration_charging,
495                billed_volume: *duration_charging,
496            },
497            duration_parking: Dimension {
498                price: duration_parking_price,
499                volume: *duration_parking,
500                billed_volume: *duration_parking,
501            },
502        }
503    }
504}
505
506#[derive(Debug)]
507/// A report for a single dimension during a single period.
508pub struct Dimension<V> {
509    /// The price component that was active during this period for this dimension.
510    /// It could be that no price component was active during this period for this dimension in
511    /// which case `price` is `None`.
512    pub price: Option<Component>,
513
514    /// The volume of this dimension during this period, as received in the provided charge detail record.
515    /// It could be that no volume was provided during this period for this dimension in which case
516    /// the `volume` is `None`.
517    pub volume: Option<V>,
518
519    /// This field contains the optional value of `volume` after a potential step size was applied.
520    /// Step size is applied over the total volume during the whole session of a dimension. But the
521    /// resulting additional volume should be billed according to the price component in this
522    /// period.
523    ///
524    /// If no step-size was applied for this period, the volume is exactly equal to the `volume`
525    /// field.
526    pub billed_volume: Option<V>,
527}
528
529impl<V: Cost> Dimension<V> {
530    /// The total cost of this dimension during a period.
531    pub fn cost(&self) -> Option<Price> {
532        let (Some(volume), Some(price_component)) = (&self.billed_volume, &self.price) else {
533            return None;
534        };
535
536        let excl_vat = volume.cost(Money::from_decimal(price_component.price));
537
538        let incl_vat = match price_component.vat {
539            VatApplicable::Applicable(vat) => Some(excl_vat.apply_vat(vat)),
540            VatApplicable::Inapplicable => Some(excl_vat),
541            VatApplicable::Unknown => None,
542        };
543
544        Some(Price { excl_vat, incl_vat })
545    }
546}
547
548/// A set of price `Component`s, one for each dimension.
549///
550/// See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#142-pricecomponent-class>
551/// See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#145-tariffdimensiontype-enum>
552#[derive(Debug)]
553pub struct ComponentSet {
554    /// Energy consumed.
555    pub energy: Option<Component>,
556
557    /// Flat fee without unit for `step_size`.
558    pub flat: Option<Component>,
559
560    /// Duration of time charging.
561    pub duration_charging: Option<Component>,
562
563    /// Duration of time not charging.
564    pub duration_parking: Option<Component>,
565}
566
567impl ComponentSet {
568    /// Returns true if all components are `Some`.
569    fn has_all_components(&self) -> bool {
570        let Self {
571            energy,
572            flat,
573            duration_charging,
574            duration_parking,
575        } = self;
576
577        flat.is_some()
578            && energy.is_some()
579            && duration_parking.is_some()
580            && duration_charging.is_some()
581    }
582}
583
584/// A Price Component describes how a certain amount of a certain dimension being consumed
585/// translates into an amount of money owed.
586///
587/// See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#142-pricecomponent-class>
588#[derive(Clone, Debug)]
589pub struct Component {
590    /// The index of the tariff this `Component` lives in.
591    pub tariff_element_index: usize,
592
593    /// Price per unit (excl. VAT) for this dimension.
594    pub price: Decimal,
595
596    /// Applicable VAT percentage for this tariff dimension. If omitted, no VAT is applicable.
597    /// Not providing a VAT is different from 0% VAT, which would be a value of 0.0 here.
598    pub vat: VatApplicable,
599
600    /// Minimum amount to be billed. That is, the dimension will be billed in this `step_size` blocks.
601    /// Consumed amounts are rounded up to the smallest multiple of `step_size` that is greater than
602    /// the consumed amount.
603    ///
604    /// For example: if type is TIME and `step_size` has a value of 300, then time will be billed in
605    /// blocks of 5 minutes. If 6 minutes were consumed, 10 minutes (2 blocks of `step_size`) will
606    /// be billed.
607    pub step_size: u64,
608}
609
610impl Component {
611    fn new(component: &crate::tariff::v221::PriceComponent, tariff_element_index: usize) -> Self {
612        let crate::tariff::v221::PriceComponent {
613            price,
614            vat,
615            step_size,
616            dimension_type: _,
617        } = component;
618
619        Self {
620            tariff_element_index,
621            price: *price,
622            vat: *vat,
623            step_size: *step_size,
624        }
625    }
626}
627
628/// A related source and calculated pair of total amounts.
629///
630/// This is used to express the source and calculated amounts for the total fields of a `CDR`.
631///
632/// - `total_cost`
633/// - `total_fixed_cost`
634/// - `total_energy`
635/// - `total_energy_cost`
636/// - `total_time`
637/// - `total_time_cost`
638/// - `total_parking_time`
639/// - `total_parking_cost`
640/// - `total_reservation_cost`
641#[derive(Debug)]
642pub struct Total<TCdr, TCalc = TCdr> {
643    /// The source value from the `CDR`.
644    pub cdr: TCdr,
645
646    /// The value calculated by the [`cdr::price`](crate::cdr::price) function.
647    pub calculated: TCalc,
648}
649
650/// The range of time the CDR periods span.
651#[derive(Debug)]
652pub enum PeriodRange {
653    /// There are many periods in the CDR and so the range is from the `start_date_time` of the first to
654    /// the `start_date_time` of the last.
655    Many(Range<DateTime<Utc>>),
656
657    /// There is one period in the CDR and so one `start_date_time`.
658    Single(DateTime<Utc>),
659}
660
661impl fmt::Display for PeriodRange {
662    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
663        match self {
664            PeriodRange::Many(Range { start, end }) => write!(f, "{start}-{end}"),
665            PeriodRange::Single(date_time) => write!(f, "{date_time}"),
666        }
667    }
668}
669
670/// Where should the tariffs come from when pricing a `CDR`.
671///
672/// Used with [`cdr::price`](crate::cdr::price).
673#[derive(Debug)]
674pub enum TariffSource<'buf> {
675    /// Use the tariffs from the `CDR`.
676    UseCdr,
677
678    /// Ignore the tariffs from the `CDR` and use these instead
679    Override(Vec<crate::tariff::Versioned<'buf>>),
680}
681
682impl<'buf> TariffSource<'buf> {
683    /// Convenience method to provide a single override tariff.
684    pub fn single(tariff: crate::tariff::Versioned<'buf>) -> Self {
685        Self::Override(vec![tariff])
686    }
687}
688
689#[instrument(skip_all)]
690pub(super) fn cdr(
691    cdr_elem: &crate::cdr::Versioned<'_>,
692    tariff_source: TariffSource<'_>,
693    timezone: Tz,
694) -> Verdict<Report> {
695    let cdr = parse_cdr(cdr_elem)?;
696
697    match tariff_source {
698        TariffSource::UseCdr => {
699            let (v221::cdr::WithTariffs { cdr, tariffs }, warnings) = cdr.into_parts();
700            debug!("Using tariffs from CDR");
701            let tariffs = tariffs
702                .iter()
703                .map(|elem| {
704                    let tariff = crate::tariff::v211::Tariff::from_json(elem);
705                    tariff.map_caveat(crate::tariff::v221::Tariff::from)
706                })
707                .collect::<Result<Vec<_>, _>>()?;
708
709            let cdr = cdr.into_caveat(warnings);
710
711            Ok(price_v221_cdr_with_tariffs(
712                cdr_elem, cdr, tariffs, timezone,
713            )?)
714        }
715        TariffSource::Override(tariffs) => {
716            let cdr = cdr.map(v221::cdr::WithTariffs::discard_tariffs);
717
718            debug!("Using override tariffs");
719            let tariffs = tariffs
720                .iter()
721                .map(tariff::parse)
722                .collect::<Result<Vec<_>, _>>()?;
723
724            Ok(price_v221_cdr_with_tariffs(
725                cdr_elem, cdr, tariffs, timezone,
726            )?)
727        }
728    }
729}
730
731/// Price a single charge-session using a tariff selected from a list.
732///
733/// Returns a report containing the totals, subtotals, and a breakdown of the calculation.
734/// Price a single charge-session using a single tariff.
735///
736/// Returns a report containing the totals, subtotals, and a breakdown of the calculation.
737fn price_v221_cdr_with_tariffs(
738    cdr_elem: &crate::cdr::Versioned<'_>,
739    cdr: Caveat<v221::Cdr, WarningKind>,
740    tariffs: Vec<Caveat<crate::tariff::v221::Tariff<'_>, crate::tariff::WarningKind>>,
741    timezone: Tz,
742) -> Verdict<Report> {
743    debug!(?timezone, version = ?cdr_elem.version(), "Pricing CDR");
744    let (cdr, mut warnings) = cdr.into_parts();
745
746    // Convert each versioned tariff JSON to a structured tariff.
747    //
748    // This generates a list of `TariffReport`s that are returned to the caller in the `Report`.
749    // One of the structured tariffs is selected for use in the `price_periods` function.
750    let (tariff_reports, tariffs): (Vec<_>, Vec<_>) = tariffs
751        .into_iter()
752        .enumerate()
753        .map(|(index, tariff)| {
754            let (tariff, warnings) = tariff.into_parts();
755            (
756                TariffReport {
757                    origin: TariffOrigin {
758                        index,
759                        id: tariff.id.to_string(),
760                    },
761                    warnings: warnings
762                        .into_group_by_elem(cdr_elem.as_element())
763                        .into_kind_map(),
764                },
765                tariff,
766            )
767        })
768        .unzip();
769
770    debug!(tariffs = ?tariffs.iter().map(|t| t.id).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
771
772    let tariffs_normalized = tariff::normalize_all(&tariffs);
773    let Some((tariff_index, tariff)) =
774        tariff::find_first_active(tariffs_normalized, cdr.start_date_time)
775    else {
776        return warnings.bail(WarningKind::NoValidTariff, cdr_elem.as_element());
777    };
778
779    debug!(tariff_index, id = ?tariff.id(), "Found active tariff");
780    debug!(%timezone, "Found timezone");
781
782    let cs_periods = v221::cdr::normalize_periods(&cdr, timezone)
783        .with_element(cdr_elem.as_element())?
784        .gather_warnings_into(&mut warnings);
785    let price_cdr_report = price_periods(&cs_periods, &tariff)
786        .with_element(cdr_elem.as_element())?
787        .gather_warnings_into(&mut warnings);
788
789    let report = generate_report(
790        &cdr,
791        timezone,
792        tariff_reports,
793        price_cdr_report,
794        TariffOrigin {
795            index: tariff_index,
796            id: tariff.id().to_string(),
797        },
798    );
799
800    Ok(report.into_caveat(warnings))
801}
802
803/// Price a list of normalized [`Period`]s using a [`Versioned`](crate::tariff::Versioned) tariff.
804pub(crate) fn periods(
805    end_date_time: DateTime<Utc>,
806    timezone: Tz,
807    tariff_elem: &crate::tariff::v221::Tariff<'_>,
808    periods: &mut [Period],
809) -> VerdictDeferred<PeriodsReport> {
810    // Make sure the periods are sorted by time as the start date of one period determines the end
811    // date of the previous period.
812    periods.sort_by_key(|p| p.start_date_time);
813    let mut out_periods = Vec::<PeriodNormalized>::new();
814
815    for (index, period) in periods.iter().enumerate() {
816        trace!(index, "processing\n{period:#?}");
817
818        let next_index = index + 1;
819
820        let end_date_time = if let Some(next_period) = periods.get(next_index) {
821            next_period.start_date_time
822        } else {
823            end_date_time
824        };
825
826        let next = if let Some(last) = out_periods.last() {
827            let start_snapshot = last.end_snapshot.clone();
828            let end_snapshot = start_snapshot.next(&period.consumed, end_date_time);
829
830            let period = PeriodNormalized {
831                consumed: period.consumed.clone(),
832                start_snapshot,
833                end_snapshot,
834            };
835            trace!("Adding new period based on the last added\n{period:#?}\n{last:#?}");
836            period
837        } else {
838            let start_snapshot = TotalsSnapshot::zero(period.start_date_time, timezone);
839            let end_snapshot = start_snapshot.next(&period.consumed, end_date_time);
840
841            let period = PeriodNormalized {
842                consumed: period.consumed.clone(),
843                start_snapshot,
844                end_snapshot,
845            };
846            trace!("Adding new period\n{period:#?}");
847            period
848        };
849
850        out_periods.push(next);
851    }
852
853    let tariff = Tariff::from_v221(tariff_elem);
854    price_periods(&out_periods, &tariff)
855}
856
857/// Price the given set of CDR periods using a normalized `Tariff`.
858fn price_periods(periods: &[PeriodNormalized], tariff: &Tariff) -> VerdictDeferred<PeriodsReport> {
859    debug!(count = periods.len(), "Pricing CDR periods");
860
861    if tracing::enabled!(tracing::Level::TRACE) {
862        trace!("# CDR period list:");
863        for period in periods {
864            trace!("{period:#?}");
865        }
866    }
867
868    let period_totals = period_totals(periods, tariff);
869    let (billed, warnings) = period_totals.calculate_billed()?.into_parts();
870    let (billable, periods, totals) = billed;
871    let total_costs = total_costs(&periods, tariff);
872    let report = PeriodsReport {
873        billable,
874        periods,
875        totals,
876        total_costs,
877    };
878
879    Ok(report.into_caveat_deferred(warnings))
880}
881
882/// The internal report generated from the [`periods`] fn.
883pub(crate) struct PeriodsReport {
884    /// The billable dimensions calculated by applying the step-size to each dimension.
885    pub billable: Billable,
886
887    /// A list of reports for each charging period that occurred during a session.
888    pub periods: Vec<PeriodReport>,
889
890    /// The totals for each dimension.
891    pub totals: Totals,
892
893    /// The total costs for each dimension.
894    pub total_costs: TotalCosts,
895}
896
897/// A report for a single charging period that occurred during a session.
898///
899/// A charging period is a period of time that has relevance for the total costs of a CDR.
900/// During a charging session, different parameters change all the time, like the amount of energy used,
901/// 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.
902#[derive(Debug)]
903pub struct PeriodReport {
904    /// The start time of this period.
905    pub start_date_time: DateTime<Utc>,
906
907    /// The end time of this period.
908    pub end_date_time: DateTime<Utc>,
909
910    /// A structure that contains results per dimension.
911    pub dimensions: Dimensions,
912}
913
914impl PeriodReport {
915    fn new(period: &PeriodNormalized, dimensions: Dimensions) -> Self {
916        Self {
917            start_date_time: period.start_snapshot.date_time,
918            end_date_time: period.end_snapshot.date_time,
919            dimensions,
920        }
921    }
922
923    /// The total cost of all dimensions in this period.
924    pub fn cost(&self) -> Option<Price> {
925        [
926            self.dimensions.duration_charging.cost(),
927            self.dimensions.duration_parking.cost(),
928            self.dimensions.flat.cost(),
929            self.dimensions.energy.cost(),
930        ]
931        .into_iter()
932        .fold(None, |accum, next| {
933            if accum.is_none() && next.is_none() {
934                None
935            } else {
936                Some(
937                    accum
938                        .unwrap_or_default()
939                        .saturating_add(next.unwrap_or_default()),
940                )
941            }
942        })
943    }
944}
945
946/// The result of normalizing the CDR charging periods.
947#[derive(Debug)]
948struct PeriodTotals {
949    /// The list of normalized periods.
950    periods: Vec<PeriodReport>,
951
952    /// The computed step size.
953    step_size: StepSize,
954
955    /// The totals for each dimension.
956    totals: Totals,
957}
958
959/// The totals for each dimension.
960#[derive(Debug, Default)]
961pub(crate) struct Totals {
962    /// The total energy used during a session.
963    pub energy: Option<Kwh>,
964
965    /// The total charging time used during a session.
966    pub duration_charging: Option<TimeDelta>,
967
968    /// The total parking time used during a session.
969    pub duration_parking: Option<TimeDelta>,
970}
971
972impl PeriodTotals {
973    /// Calculate the billed dimensions by applying the step-size to each dimension.
974    ///
975    /// Applying the step size can mutate the dimension values contained in the `Period`.
976    fn calculate_billed(self) -> VerdictDeferred<(Billable, Vec<PeriodReport>, Totals)> {
977        let mut warnings = warning::SetDeferred::new();
978        let Self {
979            mut periods,
980            step_size,
981            totals,
982        } = self;
983        let charging_time = totals
984            .duration_charging
985            .map(|dt| step_size.apply_time(&mut periods, dt))
986            .transpose()?
987            .gather_warning_kinds_into(&mut warnings);
988        let energy = totals
989            .energy
990            .map(|kwh| step_size.apply_energy(&mut periods, kwh))
991            .transpose()?
992            .gather_warning_kinds_into(&mut warnings);
993        let parking_time = totals
994            .duration_parking
995            .map(|dt| step_size.apply_parking_time(&mut periods, dt))
996            .transpose()?
997            .gather_warning_kinds_into(&mut warnings);
998        let billed = Billable {
999            charging_time,
1000            energy,
1001            parking_time,
1002        };
1003        Ok((billed, periods, totals).into_caveat_deferred(warnings))
1004    }
1005}
1006
1007/// The billable dimensions calculated by applying the step-size to each dimension.
1008#[derive(Debug)]
1009pub(crate) struct Billable {
1010    /// The billable charging time.
1011    charging_time: Option<TimeDelta>,
1012
1013    /// The billable energy use.
1014    energy: Option<Kwh>,
1015
1016    /// The billable parking time.
1017    parking_time: Option<TimeDelta>,
1018}
1019
1020/// Map the `session::ChargePeriod`s to a normalized `Period` and calculate the step size and
1021/// totals for each dimension.
1022fn period_totals(periods: &[PeriodNormalized], tariff: &Tariff) -> PeriodTotals {
1023    let mut has_flat_fee = false;
1024    let mut step_size = StepSize::new();
1025    let mut totals = Totals::default();
1026
1027    debug!(
1028        tariff_id = tariff.id(),
1029        period_count = periods.len(),
1030        "Accumulating dimension totals for each period"
1031    );
1032
1033    let periods = periods
1034        .iter()
1035        .enumerate()
1036        .map(|(index, period)| {
1037            let mut component_set = tariff.active_components(period);
1038            trace!(
1039                index,
1040                "Creating charge period with Dimension\n{period:#?}\n{component_set:#?}"
1041            );
1042
1043            if component_set.flat.is_some() {
1044                if has_flat_fee {
1045                    component_set.flat = None;
1046                } else {
1047                    has_flat_fee = true;
1048                }
1049            }
1050
1051            step_size.update(index, &component_set, period);
1052
1053            trace!(period_index = index, "Step size updated\n{step_size:#?}");
1054
1055            let dimensions = Dimensions::new(component_set, &period.consumed);
1056
1057            trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
1058
1059            if let Some(dt) = dimensions.duration_charging.volume {
1060                let acc = totals.duration_charging.get_or_insert_default();
1061                *acc = acc.saturating_add(dt);
1062            }
1063
1064            if let Some(kwh) = dimensions.energy.volume {
1065                let acc = totals.energy.get_or_insert_default();
1066                *acc = acc.saturating_add(kwh);
1067            }
1068
1069            if let Some(dt) = dimensions.duration_parking.volume {
1070                let acc = totals.duration_parking.get_or_insert_default();
1071                *acc = acc.saturating_add(dt);
1072            }
1073
1074            trace!(period_index = index, ?totals, "Update totals");
1075
1076            PeriodReport::new(period, dimensions)
1077        })
1078        .collect::<Vec<_>>();
1079
1080    PeriodTotals {
1081        periods,
1082        step_size,
1083        totals,
1084    }
1085}
1086
1087/// The total costs for each dimension.
1088#[derive(Debug, Default)]
1089pub(crate) struct TotalCosts {
1090    /// The [`Price`] for all energy used during a session.
1091    pub energy: Option<Price>,
1092
1093    /// The [`Price`] for all flat rates applied during a session.
1094    pub fixed: Option<Price>,
1095
1096    /// The [`Price`] for all charging time used during a session.
1097    pub duration_charging: Option<Price>,
1098
1099    /// The [`Price`] for all parking time used during a session.
1100    pub duration_parking: Option<Price>,
1101}
1102
1103impl TotalCosts {
1104    /// Summate each dimension total into a single total.
1105    ///
1106    /// Return `None` if there are no cost dimensions otherwise return `Some`.
1107    pub(crate) fn total(&self) -> Option<Price> {
1108        let Self {
1109            energy,
1110            fixed,
1111            duration_charging,
1112            duration_parking,
1113        } = self;
1114        debug!(
1115            energy = %DisplayOption(*energy),
1116            fixed = %DisplayOption(*fixed),
1117            duration_charging = %DisplayOption(*duration_charging),
1118            duration_parking = %DisplayOption(*duration_parking),
1119            "Calculating total costs."
1120        );
1121        [energy, fixed, duration_charging, duration_parking]
1122            .into_iter()
1123            .fold(None, |accum: Option<Price>, next| match (accum, next) {
1124                (None, None) => None,
1125                _ => Some(
1126                    accum
1127                        .unwrap_or_default()
1128                        .saturating_add(next.unwrap_or_default()),
1129                ),
1130            })
1131    }
1132}
1133
1134/// Accumulate total costs per dimension across all periods.
1135fn total_costs(periods: &[PeriodReport], tariff: &Tariff) -> TotalCosts {
1136    let mut total_costs = TotalCosts::default();
1137
1138    debug!(
1139        tariff_id = tariff.id(),
1140        period_count = periods.len(),
1141        "Accumulating dimension costs for each period"
1142    );
1143    for (index, period) in periods.iter().enumerate() {
1144        let dimensions = &period.dimensions;
1145
1146        trace!(period_index = index, "Processing period");
1147
1148        let energy_cost = dimensions.energy.cost();
1149        let fixed_cost = dimensions.flat.cost();
1150        let duration_charging_cost = dimensions.duration_charging.cost();
1151        let duration_parking_cost = dimensions.duration_parking.cost();
1152
1153        trace!(?total_costs.energy, ?energy_cost, "Energy cost");
1154        trace!(?total_costs.duration_charging, ?duration_charging_cost, "Charging cost");
1155        trace!(?total_costs.duration_parking, ?duration_parking_cost, "Parking cost");
1156        trace!(?total_costs.fixed, ?fixed_cost, "Fixed cost");
1157
1158        total_costs.energy = match (total_costs.energy, energy_cost) {
1159            (None, None) => None,
1160            (total, period) => Some(
1161                total
1162                    .unwrap_or_default()
1163                    .saturating_add(period.unwrap_or_default()),
1164            ),
1165        };
1166
1167        total_costs.duration_charging =
1168            match (total_costs.duration_charging, duration_charging_cost) {
1169                (None, None) => None,
1170                (total, period) => Some(
1171                    total
1172                        .unwrap_or_default()
1173                        .saturating_add(period.unwrap_or_default()),
1174                ),
1175            };
1176
1177        total_costs.duration_parking = match (total_costs.duration_parking, duration_parking_cost) {
1178            (None, None) => None,
1179            (total, period) => Some(
1180                total
1181                    .unwrap_or_default()
1182                    .saturating_add(period.unwrap_or_default()),
1183            ),
1184        };
1185
1186        total_costs.fixed = match (total_costs.fixed, fixed_cost) {
1187            (None, None) => None,
1188            (total, period) => Some(
1189                total
1190                    .unwrap_or_default()
1191                    .saturating_add(period.unwrap_or_default()),
1192            ),
1193        };
1194
1195        trace!(period_index = index, ?total_costs, "Update totals");
1196    }
1197
1198    total_costs
1199}
1200
1201fn generate_report(
1202    cdr: &v221::Cdr,
1203    timezone: Tz,
1204    tariff_reports: Vec<TariffReport>,
1205    price_periods_report: PeriodsReport,
1206    tariff_used: TariffOrigin,
1207) -> Report {
1208    let PeriodsReport {
1209        billable,
1210        periods,
1211        totals,
1212        total_costs,
1213    } = price_periods_report;
1214    trace!("Update billed totals {billable:#?}");
1215
1216    let total_cost = total_costs.total();
1217
1218    debug!(total_cost = %DisplayOption(total_cost.as_ref()));
1219
1220    let total_time = {
1221        debug!(
1222            period_start = %DisplayOption(periods.first().map(|p| p.start_date_time)),
1223            period_end = %DisplayOption(periods.last().map(|p| p.end_date_time)),
1224            "Calculating `total_time`"
1225        );
1226
1227        periods
1228            .first()
1229            .zip(periods.last())
1230            .map(|(first, last)| {
1231                last.end_date_time
1232                    .signed_duration_since(first.start_date_time)
1233            })
1234            .unwrap_or_default()
1235    };
1236    debug!(total_time = %Hms(total_time));
1237
1238    let report = Report {
1239        periods,
1240        tariff_used,
1241        timezone: timezone.to_string(),
1242        billed_parking_time: billable.parking_time,
1243        billed_energy: billable.energy.truncate_to_ocpi_scale(),
1244        billed_charging_time: billable.charging_time,
1245        tariff_reports,
1246        total_charging_time: totals.duration_charging,
1247        total_cost: Total {
1248            cdr: cdr.total_cost.truncate_to_ocpi_scale(),
1249            calculated: total_cost.truncate_to_ocpi_scale(),
1250        },
1251        total_time_cost: Total {
1252            cdr: cdr.total_time_cost.truncate_to_ocpi_scale(),
1253            calculated: total_costs.duration_charging.truncate_to_ocpi_scale(),
1254        },
1255        total_time: Total {
1256            cdr: cdr.total_time,
1257            calculated: total_time,
1258        },
1259        total_parking_cost: Total {
1260            cdr: cdr.total_parking_cost.truncate_to_ocpi_scale(),
1261            calculated: total_costs.duration_parking.truncate_to_ocpi_scale(),
1262        },
1263        total_parking_time: Total {
1264            cdr: cdr.total_parking_time,
1265            calculated: totals.duration_parking,
1266        },
1267        total_energy_cost: Total {
1268            cdr: cdr.total_energy_cost.truncate_to_ocpi_scale(),
1269            calculated: total_costs.energy.truncate_to_ocpi_scale(),
1270        },
1271        total_energy: Total {
1272            cdr: cdr.total_energy.truncate_to_ocpi_scale(),
1273            calculated: totals.energy.truncate_to_ocpi_scale(),
1274        },
1275        total_fixed_cost: Total {
1276            cdr: cdr.total_fixed_cost.truncate_to_ocpi_scale(),
1277            calculated: total_costs.fixed.truncate_to_ocpi_scale(),
1278        },
1279        total_reservation_cost: Total {
1280            cdr: cdr.total_reservation_cost.truncate_to_ocpi_scale(),
1281            calculated: None,
1282        },
1283    };
1284
1285    trace!("{report:#?}");
1286
1287    report
1288}
1289
1290#[derive(Debug)]
1291struct StepSize {
1292    charging_time: Option<(usize, Component)>,
1293    parking_time: Option<(usize, Component)>,
1294    energy: Option<(usize, Component)>,
1295}
1296
1297/// Return the duration as a `Decimal` amount of seconds.
1298fn delta_as_seconds_dec(delta: TimeDelta) -> Decimal {
1299    Decimal::from(delta.num_milliseconds())
1300        .checked_div(Decimal::from(duration::MILLIS_IN_SEC))
1301        .expect("Can't overflow; See test `as_seconds_dec_should_not_overflow`")
1302}
1303
1304/// Create a `HoursDecimal` from a `Decimal` amount of seconds.
1305fn delta_from_seconds_dec(seconds: Decimal) -> VerdictDeferred<TimeDelta> {
1306    let millis = seconds.saturating_mul(Decimal::from(duration::MILLIS_IN_SEC));
1307    let Ok(millis) = i64::try_from(millis) else {
1308        return Err(warning::SetDeferred::with_warn(
1309            duration::WarningKind::Overflow.into(),
1310        ));
1311    };
1312    let Some(delta) = TimeDelta::try_milliseconds(millis) else {
1313        return Err(warning::SetDeferred::with_warn(
1314            duration::WarningKind::Overflow.into(),
1315        ));
1316    };
1317    Ok(delta.into_caveat_deferred(warning::SetDeferred::new()))
1318}
1319
1320impl StepSize {
1321    fn new() -> Self {
1322        Self {
1323            charging_time: None,
1324            parking_time: None,
1325            energy: None,
1326        }
1327    }
1328
1329    fn update(&mut self, index: usize, components: &ComponentSet, period: &PeriodNormalized) {
1330        if period.consumed.energy.is_some() {
1331            if let Some(energy) = components.energy.clone() {
1332                self.energy = Some((index, energy));
1333            }
1334        }
1335
1336        if period.consumed.duration_charging.is_some() {
1337            if let Some(time) = components.duration_charging.clone() {
1338                self.charging_time = Some((index, time));
1339            }
1340        }
1341
1342        if period.consumed.duration_parking.is_some() {
1343            if let Some(parking) = components.duration_parking.clone() {
1344                self.parking_time = Some((index, parking));
1345            }
1346        }
1347    }
1348
1349    fn duration_step_size(
1350        total_volume: TimeDelta,
1351        period_billed_volume: &mut TimeDelta,
1352        step_size: u64,
1353    ) -> VerdictDeferred<TimeDelta> {
1354        if step_size == 0 {
1355            return Ok(total_volume.into_caveat_deferred(warning::SetDeferred::new()));
1356        }
1357
1358        let total_seconds = delta_as_seconds_dec(total_volume);
1359        let step_size = Decimal::from(step_size);
1360
1361        let Some(x) = total_seconds.checked_div(step_size) else {
1362            return Err(warning::SetDeferred::with_warn(
1363                duration::WarningKind::Overflow.into(),
1364            ));
1365        };
1366        let total_billed_volume = delta_from_seconds_dec(x.ceil().saturating_mul(step_size))?;
1367
1368        let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1369        *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1370
1371        Ok(total_billed_volume)
1372    }
1373
1374    fn apply_time(
1375        &self,
1376        periods: &mut [PeriodReport],
1377        total: TimeDelta,
1378    ) -> VerdictDeferred<TimeDelta> {
1379        let (Some((time_index, price)), None) = (&self.charging_time, &self.parking_time) else {
1380            return Ok(total.into_caveat_deferred(warning::SetDeferred::new()));
1381        };
1382
1383        let Some(period) = periods.get_mut(*time_index) else {
1384            error!(time_index, "Invalid period index");
1385            return Err(warning::SetDeferred::with_warn(WarningKind::InternalError));
1386        };
1387        let Some(volume) = period.dimensions.duration_charging.billed_volume.as_mut() else {
1388            return Err(warning::SetDeferred::with_warn(
1389                WarningKind::DimensionShouldHaveVolume {
1390                    dimension_name: "time",
1391                },
1392            ));
1393        };
1394
1395        Self::duration_step_size(total, volume, price.step_size)
1396    }
1397
1398    fn apply_parking_time(
1399        &self,
1400        periods: &mut [PeriodReport],
1401        total: TimeDelta,
1402    ) -> VerdictDeferred<TimeDelta> {
1403        let warnings = warning::SetDeferred::new();
1404        let Some((parking_index, price)) = &self.parking_time else {
1405            return Ok(total.into_caveat_deferred(warnings));
1406        };
1407
1408        let Some(period) = periods.get_mut(*parking_index) else {
1409            error!(parking_index, "Invalid period index");
1410            return warnings.bail(WarningKind::InternalError);
1411        };
1412        let Some(volume) = period.dimensions.duration_parking.billed_volume.as_mut() else {
1413            return warnings.bail(WarningKind::DimensionShouldHaveVolume {
1414                dimension_name: "parking_time",
1415            });
1416        };
1417
1418        Self::duration_step_size(total, volume, price.step_size)
1419    }
1420
1421    fn apply_energy(
1422        &self,
1423        periods: &mut [PeriodReport],
1424        total_volume: Kwh,
1425    ) -> VerdictDeferred<Kwh> {
1426        let warnings = warning::SetDeferred::new();
1427        let Some((energy_index, price)) = &self.energy else {
1428            return Ok(total_volume.into_caveat_deferred(warnings));
1429        };
1430
1431        if price.step_size == 0 {
1432            return Ok(total_volume.into_caveat_deferred(warnings));
1433        }
1434
1435        let Some(period) = periods.get_mut(*energy_index) else {
1436            error!(energy_index, "Invalid period index");
1437            return warnings.bail(WarningKind::InternalError);
1438        };
1439        let step_size = Decimal::from(price.step_size);
1440
1441        let Some(period_billed_volume) = period.dimensions.energy.billed_volume.as_mut() else {
1442            return warnings.bail(WarningKind::DimensionShouldHaveVolume {
1443                dimension_name: "energy",
1444            });
1445        };
1446
1447        let Some(watt_hours) = total_volume.watt_hours().checked_div(step_size) else {
1448            return warnings.bail(duration::WarningKind::Overflow.into());
1449        };
1450
1451        let total_billed_volume = Kwh::from_watt_hours(watt_hours.ceil().saturating_mul(step_size));
1452        let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1453        *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1454
1455        Ok(total_billed_volume.into_caveat_deferred(warnings))
1456    }
1457}
1458
1459fn parse_cdr<'caller: 'buf, 'buf>(
1460    cdr: &'caller crate::cdr::Versioned<'buf>,
1461) -> Verdict<v221::cdr::WithTariffs<'buf>> {
1462    match cdr.version() {
1463        Version::V211 => {
1464            let cdr = v211::cdr::WithTariffs::from_json(cdr.as_element())?;
1465            Ok(cdr.map(v221::cdr::WithTariffs::from))
1466        }
1467        Version::V221 => v221::cdr::WithTariffs::from_json(cdr.as_element()),
1468    }
1469}
1470
1471#[cfg(test)]
1472pub mod test {
1473    #![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
1474    #![allow(clippy::panic, reason = "tests are allowed panic")]
1475
1476    use std::collections::{BTreeMap, BTreeSet};
1477
1478    use chrono::TimeDelta;
1479    use rust_decimal::Decimal;
1480    use serde::Deserialize;
1481    use tracing::debug;
1482
1483    use crate::{
1484        assert_approx_eq, cdr,
1485        duration::ToHoursDecimal,
1486        json, number,
1487        test::{ApproxEq, ExpectFile, Expectation},
1488        timezone,
1489        warning::{self, Kind as _},
1490        Caveat, Kwh, Price,
1491    };
1492
1493    use super::{Report, TariffReport, Total, WarningKind};
1494
1495    // Decimal precision used when comparing the outcomes of the calculation with the CDR.
1496    const PRECISION: u32 = 2;
1497
1498    #[test]
1499    const fn warning_kind_should_be_send_and_sync() {
1500        const fn f<T: Send + Sync>() {}
1501
1502        f::<WarningKind>();
1503    }
1504
1505    pub trait UnwrapReport {
1506        #[track_caller]
1507        fn unwrap_report(self, cdr: &cdr::Versioned<'_>) -> Caveat<Report, WarningKind>;
1508    }
1509
1510    impl UnwrapReport for super::Verdict<Report> {
1511        fn unwrap_report(self, cdr: &cdr::Versioned<'_>) -> Caveat<Report, WarningKind> {
1512            match self {
1513                Ok(v) => v,
1514                Err(warnings) => {
1515                    panic!(
1516                        "parsing tariff failed:\n{:?}",
1517                        warning::SetWriter::new(cdr.as_element(), &warnings)
1518                    )
1519                }
1520            }
1521        }
1522    }
1523
1524    /// A `TimeDelta` wrapper used to serialize and deserialize to/from a `Decimal` representation of hours
1525    #[derive(Debug, Default)]
1526    pub(crate) struct HoursDecimal(Decimal);
1527
1528    impl ToHoursDecimal for HoursDecimal {
1529        fn to_hours_dec(&self) -> Decimal {
1530            self.0
1531        }
1532    }
1533
1534    /// Deserialize bytes into a `Decimal` applying the scale defined in the OCPI spec.
1535    ///
1536    /// Called from the `impl Deserialize` for a `Decimal` newtype.
1537    fn decimal<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
1538    where
1539        D: serde::Deserializer<'de>,
1540    {
1541        use serde::Deserialize;
1542
1543        let mut d = <Decimal as Deserialize>::deserialize(deserializer)?;
1544        d.rescale(number::SCALE);
1545        Ok(d)
1546    }
1547
1548    impl<'de> Deserialize<'de> for HoursDecimal {
1549        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1550        where
1551            D: serde::Deserializer<'de>,
1552        {
1553            decimal(deserializer).map(Self)
1554        }
1555    }
1556
1557    #[derive(serde::Deserialize)]
1558    pub(crate) struct Expect {
1559        /// Expectations for the result of calling `timezone::find_or_infer`.
1560        pub timezone_find: Option<timezone::test::FindOrInferExpect>,
1561
1562        /// Expectations for the result of calling `cdr::parse*`.
1563        pub tariff_parse: Option<ParseExpect>,
1564
1565        /// Expectations for the result of calling `cdr::parse*`.
1566        pub cdr_parse: Option<ParseExpect>,
1567
1568        /// Expectations for the result of calling `cdr::price*`.
1569        pub cdr_price: Option<PriceExpect>,
1570    }
1571
1572    /// The `Expect` is used to parse the JSON but the tests use the individual fields in separate
1573    /// asset functions. Each of those functions needs to know the `expect_file_name`.
1574    #[expect(
1575        clippy::struct_field_names,
1576        reason = "When deconstructed these fields will always be called *_expect. This avoids having to rename them in-place."
1577    )]
1578    pub(crate) struct ExpectFields {
1579        /// Expectations for the result of calling `timezone::find_or_infer`.
1580        pub timezone_find_expect: ExpectFile<timezone::test::FindOrInferExpect>,
1581
1582        /// Expectations for the result of calling `cdr::parse*`.
1583        pub tariff_parse_expect: ExpectFile<ParseExpect>,
1584
1585        /// Expectations for the result of calling `cdr::parse*`.
1586        pub cdr_parse_expect: ExpectFile<ParseExpect>,
1587
1588        /// Expectations for the result of calling `cdr::price*`.
1589        pub cdr_price_expect: ExpectFile<PriceExpect>,
1590    }
1591
1592    impl ExpectFile<Expect> {
1593        /// Split the `ExpectFile<Expect>` into its constituent fields and repackage them as `ExpectFile`s.
1594        pub(crate) fn into_fields(self) -> ExpectFields {
1595            let ExpectFile {
1596                value,
1597                expect_file_name,
1598            } = self;
1599
1600            match value {
1601                Some(expect) => {
1602                    let Expect {
1603                        timezone_find,
1604                        tariff_parse,
1605                        cdr_parse,
1606                        cdr_price,
1607                    } = expect;
1608                    ExpectFields {
1609                        timezone_find_expect: ExpectFile::with_value(
1610                            timezone_find,
1611                            &expect_file_name,
1612                        ),
1613                        tariff_parse_expect: ExpectFile::with_value(
1614                            tariff_parse,
1615                            &expect_file_name,
1616                        ),
1617                        cdr_parse_expect: ExpectFile::with_value(cdr_parse, &expect_file_name),
1618                        cdr_price_expect: ExpectFile::with_value(cdr_price, &expect_file_name),
1619                    }
1620                }
1621                None => ExpectFields {
1622                    timezone_find_expect: ExpectFile::only_file_name(&expect_file_name),
1623                    tariff_parse_expect: ExpectFile::only_file_name(&expect_file_name),
1624                    cdr_parse_expect: ExpectFile::only_file_name(&expect_file_name),
1625                    cdr_price_expect: ExpectFile::only_file_name(&expect_file_name),
1626                },
1627            }
1628        }
1629    }
1630
1631    pub(crate) fn assert_parse_report(
1632        unexpected_fields: json::UnexpectedFields<'_>,
1633        expect: ExpectFile<ParseExpect>,
1634    ) {
1635        let ExpectFile {
1636            value,
1637            expect_file_name,
1638        } = expect;
1639        let unexpected_fields_expect = value
1640            .map(|exp| exp.unexpected_fields)
1641            .unwrap_or(Expectation::Absent);
1642
1643        if let Expectation::Present(expectation) = unexpected_fields_expect {
1644            let unexpected_fields_expect = expectation.expect_value();
1645
1646            for field in unexpected_fields {
1647                assert!(
1648                    unexpected_fields_expect.contains(&field.to_string()),
1649                    "The CDR has an unexpected field that's not expected in `{expect_file_name}`: `{field}`"
1650                );
1651            }
1652        } else {
1653            assert!(
1654                unexpected_fields.is_empty(),
1655                "The CDR has unexpected fields but the expect file doesn't `{expect_file_name}`; {unexpected_fields:#}",
1656            );
1657        }
1658    }
1659
1660    pub(crate) fn assert_price_report(
1661        cdr_elem: &cdr::Versioned<'_>,
1662        report: Caveat<Report, WarningKind>,
1663        expect: ExpectFile<PriceExpect>,
1664    ) {
1665        let (report, warnings) = report.into_parts();
1666        let Report {
1667            mut tariff_reports,
1668            periods: _,
1669            tariff_used,
1670            timezone: _,
1671            billed_energy: _,
1672            billed_parking_time: _,
1673            billed_charging_time: _,
1674            total_charging_time: _,
1675            total_cost,
1676            total_fixed_cost,
1677            total_time,
1678            total_time_cost,
1679            total_energy,
1680            total_energy_cost,
1681            total_parking_time,
1682            total_parking_cost,
1683            total_reservation_cost,
1684        } = report;
1685
1686        let ExpectFile {
1687            value: expect,
1688            expect_file_name,
1689        } = expect;
1690
1691        // This destructure isn't pretty but it's at least simple to maintain.
1692        // The alternative is getting involved with references of references when processing each borrowed field.
1693        let (
1694            warnings_expect,
1695            tariff_index_expect,
1696            tariff_id_expect,
1697            tariff_reports_expect,
1698            total_cost_expectation,
1699            total_fixed_cost_expectation,
1700            total_time_expectation,
1701            total_time_cost_expectation,
1702            total_energy_expectation,
1703            total_energy_cost_expectation,
1704            total_parking_time_expectation,
1705            total_parking_cost_expectation,
1706            total_reservation_cost_expectation,
1707        ) = expect
1708            .map(|exp| {
1709                let PriceExpect {
1710                    warnings,
1711                    tariff_index,
1712                    tariff_id,
1713                    tariff_reports,
1714                    total_cost,
1715                    total_fixed_cost,
1716                    total_time,
1717                    total_time_cost,
1718                    total_energy,
1719                    total_energy_cost,
1720                    total_parking_time,
1721                    total_parking_cost,
1722                    total_reservation_cost,
1723                } = exp;
1724
1725                (
1726                    warnings,
1727                    tariff_index,
1728                    tariff_id,
1729                    tariff_reports,
1730                    total_cost,
1731                    total_fixed_cost,
1732                    total_time,
1733                    total_time_cost,
1734                    total_energy,
1735                    total_energy_cost,
1736                    total_parking_time,
1737                    total_parking_cost,
1738                    total_reservation_cost,
1739                )
1740            })
1741            .unwrap_or((
1742                Expectation::Absent,
1743                Expectation::Absent,
1744                Expectation::Absent,
1745                Expectation::Absent,
1746                Expectation::Absent,
1747                Expectation::Absent,
1748                Expectation::Absent,
1749                Expectation::Absent,
1750                Expectation::Absent,
1751                Expectation::Absent,
1752                Expectation::Absent,
1753                Expectation::Absent,
1754                Expectation::Absent,
1755            ));
1756
1757        if let Expectation::Present(expectation) = warnings_expect {
1758            let warnings_expect = expectation.expect_value();
1759
1760            debug!("{warnings_expect:?}");
1761
1762            for warning::Group { element, warnings } in
1763                warnings.group_by_elem(cdr_elem.as_element())
1764            {
1765                let elem_path = element.path().to_string();
1766                let Some(warnings_expect) = warnings_expect.get(&*elem_path) else {
1767                    let warning_ids = warnings
1768                        .iter()
1769                        .map(|k| format!("  \"{}\",", k.id()))
1770                        .collect::<Vec<_>>()
1771                        .join("\n");
1772
1773                    panic!("No warnings expected `{expect_file_name}` for `Element` at `{elem_path}` but {} warnings were reported:\n[\n{}\n]", warnings.len(), warning_ids);
1774                };
1775
1776                let warnings_expect = warnings_expect
1777                    .iter()
1778                    .map(|s| &**s)
1779                    .collect::<BTreeSet<_>>();
1780
1781                for warning_kind in warnings {
1782                    let id = warning_kind.id();
1783                    assert!(
1784                        warnings_expect.contains(&*id),
1785                        "Unexpected warning `{id}` for `Element` at `{elem_path}`"
1786                    );
1787                }
1788            }
1789        } else {
1790            assert!(
1791                warnings.is_empty(),
1792                "The expectation file at `{expect_file_name}` did not expect warnings, but the CDR has warnings;\n{:?}",
1793                warnings.group_by_elem(cdr_elem.as_element()).into_id_map()
1794            );
1795        }
1796
1797        if let Expectation::Present(expectation) = tariff_reports_expect {
1798            let tariff_reports_expect: BTreeMap<_, _> = expectation
1799                .expect_value()
1800                .into_iter()
1801                .map(|TariffReportExpect { id, warnings }| (id, warnings))
1802                .collect();
1803
1804            for report in &mut tariff_reports {
1805                let TariffReport { origin, warnings } = report;
1806                let id = &origin.id;
1807                let Some(warnings_expect) = tariff_reports_expect.get(id) else {
1808                    panic!("A tariff with {id} is not expected `{expect_file_name}`");
1809                };
1810
1811                debug!("{warnings_expect:?}");
1812
1813                for (elem_path, warnings) in warnings {
1814                    let Some(warnings_expect) = warnings_expect.get(elem_path) else {
1815                        let warning_ids = warnings
1816                            .iter()
1817                            .map(|k| format!("  \"{}\",", k.id()))
1818                            .collect::<Vec<_>>()
1819                            .join("\n");
1820
1821                        panic!("No warnings expected for `Element` at `{elem_path}` but {} warnings were reported:\n[\n{}\n]", warnings.len(), warning_ids);
1822                    };
1823
1824                    let warnings_expect = warnings_expect
1825                        .iter()
1826                        .map(|s| &**s)
1827                        .collect::<BTreeSet<_>>();
1828
1829                    for warning_kind in warnings {
1830                        let id = warning_kind.id();
1831                        assert!(
1832                            warnings_expect.contains(&*id),
1833                            "Unexpected warning `{id}` for `Element` at `{elem_path}`"
1834                        );
1835                    }
1836                }
1837            }
1838        } else {
1839            for report in &tariff_reports {
1840                let TariffReport { origin, warnings } = report;
1841
1842                let id = &origin.id;
1843
1844                assert!(
1845                    warnings.is_empty(),
1846                    "The tariff with id `{id}` has warnings.\n {warnings:?}"
1847                );
1848            }
1849        }
1850
1851        if let Expectation::Present(expectation) = tariff_id_expect {
1852            assert_eq!(tariff_used.id, expectation.expect_value());
1853        }
1854
1855        if let Expectation::Present(expectation) = tariff_index_expect {
1856            assert_eq!(tariff_used.index, expectation.expect_value());
1857        }
1858
1859        total_cost_expectation.expect_price("total_cost", &total_cost);
1860        total_fixed_cost_expectation.expect_opt_price("total_fixed_cost", &total_fixed_cost);
1861        total_time_expectation.expect_duration("total_time", &total_time);
1862        total_time_cost_expectation.expect_opt_price("total_time_cost", &total_time_cost);
1863        total_energy_expectation.expect_opt_kwh("total_energy", &total_energy);
1864        total_energy_cost_expectation.expect_opt_price("total_energy_cost", &total_energy_cost);
1865        total_parking_time_expectation
1866            .expect_opt_duration("total_parking_time", &total_parking_time);
1867        total_parking_cost_expectation.expect_opt_price("total_parking_cost", &total_parking_cost);
1868        total_reservation_cost_expectation
1869            .expect_opt_price("total_reservation_cost", &total_reservation_cost);
1870    }
1871
1872    /// Expectations for the result of calling `cdr::parse*`.
1873    #[derive(serde::Deserialize)]
1874    pub struct ParseExpect {
1875        #[serde(default)]
1876        unexpected_fields: Expectation<Vec<String>>,
1877    }
1878
1879    /// Expectations for the result of calling `cdr::price`.
1880    #[derive(serde::Deserialize)]
1881    pub struct PriceExpect {
1882        /// Expected Warnings from parsing a CDR.
1883        ///
1884        /// Each entry in the map is an element path and a list of associated warnings.
1885        #[serde(default)]
1886        warnings: Expectation<BTreeMap<String, Vec<String>>>,
1887
1888        /// Index of the tariff that was found to be active.
1889        #[serde(default)]
1890        tariff_index: Expectation<usize>,
1891
1892        /// Id of the tariff that was found to be active.
1893        #[serde(default)]
1894        tariff_id: Expectation<String>,
1895
1896        /// A list of the tariff IDs found in the CDR or supplied to the [`cdr::price`](crate::cdr::price) function.
1897        ///
1898        /// Each tariff may have a set of unexpected fields encountered while parsing the tariff.
1899        #[serde(default)]
1900        tariff_reports: Expectation<Vec<TariffReportExpect>>,
1901
1902        /// Total sum of all the costs of this transaction in the specified currency.
1903        #[serde(default)]
1904        total_cost: Expectation<Price>,
1905
1906        /// 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.
1907        #[serde(default)]
1908        total_fixed_cost: Expectation<Price>,
1909
1910        /// Total duration of the charging session (including the duration of charging and not charging), in hours.
1911        #[serde(default)]
1912        total_time: Expectation<HoursDecimal>,
1913
1914        /// Total sum of all the cost related to duration of charging during this transaction, in the specified currency.
1915        #[serde(default)]
1916        total_time_cost: Expectation<Price>,
1917
1918        /// Total energy charged, in kWh.
1919        #[serde(default)]
1920        total_energy: Expectation<Kwh>,
1921
1922        /// Total sum of all the cost of all the energy used, in the specified currency.
1923        #[serde(default)]
1924        total_energy_cost: Expectation<Price>,
1925
1926        /// Total duration of the charging session where the EV was not charging (no energy was transferred between EVSE and EV), in hours.
1927        #[serde(default)]
1928        total_parking_time: Expectation<HoursDecimal>,
1929
1930        /// Total sum of all the cost related to parking of this transaction, including fixed price components, in the specified currency.
1931        #[serde(default)]
1932        total_parking_cost: Expectation<Price>,
1933
1934        /// Total sum of all the cost related to a reservation of a Charge Point, including fixed price components, in the specified currency.
1935        #[serde(default)]
1936        total_reservation_cost: Expectation<Price>,
1937    }
1938
1939    #[derive(Debug, Deserialize)]
1940    struct TariffReportExpect {
1941        /// The id of the tariff.
1942        id: String,
1943
1944        /// Expected Warnings from parsing a tariff.
1945        ///
1946        /// Each entry in the map is an element path and a list of associated warnings.
1947        #[serde(default)]
1948        warnings: BTreeMap<String, Vec<String>>,
1949    }
1950
1951    impl Expectation<Price> {
1952        #[track_caller]
1953        fn expect_opt_price(self, field_name: &str, total: &Total<Option<Price>>) {
1954            if let Expectation::Present(expect_value) = self {
1955                match (expect_value.into_option(), total.calculated) {
1956                    (Some(a), Some(b)) => assert!(
1957                        a.approx_eq(&b),
1958                        "Expected `{a}` but `{b}` was calculated for `{field_name}`"
1959                    ),
1960                    (Some(a), None) => {
1961                        panic!("Expected `{a}`, but no price was calculated for `{field_name}`")
1962                    }
1963                    (None, Some(b)) => {
1964                        panic!("Expected no value, but `{b}` was calculated for `{field_name}`")
1965                    }
1966                    (None, None) => (),
1967                }
1968            } else {
1969                match (total.cdr, total.calculated) {
1970                    (None, None) => (),
1971                    (None, Some(calculated)) => {
1972                        assert!(calculated.is_zero(), "The CDR field `{field_name}` doesn't have a value but a value was calculated; calculated: {calculated}");
1973                    }
1974                    (Some(cdr), None) => {
1975                        assert!(
1976                            cdr.is_zero(),
1977                            "The CDR field `{field_name}` has a value but the calculated value is none; cdr: {cdr}"
1978                        );
1979                    }
1980                    (Some(cdr), Some(calculated)) => {
1981                        assert!(
1982                            cdr.approx_eq(&calculated),
1983                            "Comparing `{field_name}` field with CDR"
1984                        );
1985                    }
1986                }
1987            }
1988        }
1989
1990        #[track_caller]
1991        fn expect_price(self, field_name: &str, total: &Total<Price, Option<Price>>) {
1992            if let Expectation::Present(expect_value) = self {
1993                match (expect_value.into_option(), total.calculated) {
1994                    (Some(a), Some(b)) => assert!(
1995                        a.approx_eq(&b),
1996                        "Expected `{a}` but `{b}` was calculated for `{field_name}`"
1997                    ),
1998                    (Some(a), None) => {
1999                        panic!("Expected `{a}`, but no price was calculated for `{field_name}`")
2000                    }
2001                    (None, Some(b)) => {
2002                        panic!("Expected no value, but `{b}` was calculated for `{field_name}`")
2003                    }
2004                    (None, None) => (),
2005                }
2006            } else if let Some(calculated) = total.calculated {
2007                assert!(
2008                    total.cdr.approx_eq(&calculated),
2009                    "CDR contains `{}` but `{}` was calculated for `{field_name}`",
2010                    total.cdr,
2011                    calculated
2012                );
2013            } else {
2014                assert!(
2015                    total.cdr.is_zero(),
2016                    "The CDR field `{field_name}` has a value but the calculated value is none; cdr: {:?}",
2017                    total.cdr
2018                );
2019            }
2020        }
2021    }
2022
2023    impl Expectation<HoursDecimal> {
2024        #[track_caller]
2025        fn expect_duration(self, field_name: &str, total: &Total<TimeDelta>) {
2026            if let Expectation::Present(expect_value) = self {
2027                assert_approx_eq!(
2028                    expect_value.expect_value().to_hours_dec(),
2029                    total.calculated.to_hours_dec(),
2030                    "Comparing `{field_name}` field with expectation"
2031                );
2032            } else {
2033                assert_approx_eq!(
2034                    total.cdr.to_hours_dec(),
2035                    total.calculated.to_hours_dec(),
2036                    "Comparing `{field_name}` field with CDR"
2037                );
2038            }
2039        }
2040
2041        #[track_caller]
2042        fn expect_opt_duration(
2043            self,
2044            field_name: &str,
2045            total: &Total<Option<TimeDelta>, Option<TimeDelta>>,
2046        ) {
2047            if let Expectation::Present(expect_value) = self {
2048                assert_approx_eq!(
2049                    expect_value
2050                        .into_option()
2051                        .unwrap_or_default()
2052                        .to_hours_dec(),
2053                    &total
2054                        .calculated
2055                        .as_ref()
2056                        .map(ToHoursDecimal::to_hours_dec)
2057                        .unwrap_or_default(),
2058                    "Comparing `{field_name}` field with expectation"
2059                );
2060            } else {
2061                assert_approx_eq!(
2062                    total.cdr.unwrap_or_default().to_hours_dec(),
2063                    total.calculated.unwrap_or_default().to_hours_dec(),
2064                    "Comparing `{field_name}` field with CDR"
2065                );
2066            }
2067        }
2068    }
2069
2070    impl Expectation<Kwh> {
2071        #[track_caller]
2072        fn expect_opt_kwh(self, field_name: &str, total: &Total<Kwh, Option<Kwh>>) {
2073            if let Expectation::Present(expect_value) = self {
2074                assert_eq!(
2075                    expect_value
2076                        .into_option()
2077                        .map(|kwh| kwh.round_dp(PRECISION)),
2078                    total
2079                        .calculated
2080                        .map(|kwh| kwh.rescale().round_dp(PRECISION)),
2081                    "Comparing `{field_name}` field with expectation"
2082                );
2083            } else {
2084                assert_eq!(
2085                    total.cdr.round_dp(PRECISION),
2086                    total
2087                        .calculated
2088                        .map(|kwh| kwh.rescale().round_dp(PRECISION))
2089                        .unwrap_or_default(),
2090                    "Comparing `{field_name}` field with CDR"
2091                );
2092            }
2093        }
2094    }
2095}
2096
2097#[cfg(test)]
2098mod test_periods {
2099    #![allow(clippy::as_conversions, reason = "tests are allowed to panic")]
2100    #![allow(clippy::panic, reason = "tests are allowed panic")]
2101
2102    use assert_matches::assert_matches;
2103    use chrono::Utc;
2104    use chrono_tz::Tz;
2105    use rust_decimal::Decimal;
2106    use rust_decimal_macros::dec;
2107
2108    use crate::{
2109        assert_approx_eq, cdr,
2110        price::{self, test::UnwrapReport},
2111        tariff, test, Kwh, Version,
2112    };
2113
2114    use super::{Consumed, Period, TariffSource};
2115
2116    #[test]
2117    fn should_price_periods_from_time_and_parking_time_cdr_and_tariff() {
2118        const VERSION: Version = Version::V211;
2119        const CDR_JSON: &str = include_str!(
2120            "../test_data/v211/real_world/time_and_parking_time_separate_tariff/cdr.json"
2121        );
2122        const TARIFF_JSON: &str = include_str!(
2123            "../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json"
2124        );
2125        // Every period has a 15 minute duration.
2126        const PERIOD_DURATION: chrono::TimeDelta = chrono::TimeDelta::minutes(15);
2127
2128        /// Create `TIME` period for each energy value provided.
2129        ///
2130        /// Each `TIME` period is the same duration.
2131        /// But has a different `start_date_time`.
2132        fn charging(start_date_time: &str, energy: Vec<Decimal>) -> Vec<Period> {
2133            let start: chrono::DateTime<Utc> = start_date_time.parse().unwrap();
2134
2135            energy
2136                .into_iter()
2137                .enumerate()
2138                .map(|(i, kwh)| {
2139                    let i = i32::try_from(i).unwrap();
2140                    let start_date_time = start + (PERIOD_DURATION * i);
2141
2142                    Period {
2143                        start_date_time,
2144                        consumed: Consumed {
2145                            duration_charging: Some(PERIOD_DURATION),
2146                            energy: Some(kwh.into()),
2147                            ..Default::default()
2148                        },
2149                    }
2150                })
2151                .collect()
2152        }
2153
2154        /// Create `period_count` number of `PARKING_TIME` periods.
2155        ///
2156        /// Each `PARKING_TIME` period is the same duration and energy usage (0kWh)
2157        /// but has a different `start_date_time`.
2158        fn parking(start_date_time: &str, period_count: usize) -> Vec<Period> {
2159            // Every parking period has a consumed energy of zero.
2160            let period_energy = Kwh::from(0);
2161            let start: chrono::DateTime<Utc> = start_date_time.parse().unwrap();
2162
2163            let period_count = i32::try_from(period_count).unwrap();
2164            // Add uniform periods except for the last one
2165            let mut periods: Vec<Period> = (0..period_count - 1)
2166                .map(|i| {
2167                    let start_date_time = start + (PERIOD_DURATION * i);
2168
2169                    Period {
2170                        start_date_time,
2171                        consumed: Consumed {
2172                            duration_parking: Some(PERIOD_DURATION),
2173                            energy: Some(period_energy),
2174                            ..Default::default()
2175                        },
2176                    }
2177                })
2178                .collect();
2179
2180            let start_date_time = start + (PERIOD_DURATION * (period_count - 1));
2181
2182            // The last period is a 10 minutes period instead of 15 minutes.
2183            periods.push(Period {
2184                start_date_time,
2185                consumed: Consumed {
2186                    duration_parking: Some(chrono::TimeDelta::seconds(644)),
2187                    energy: Some(period_energy),
2188                    ..Default::default()
2189                },
2190            });
2191
2192            periods
2193        }
2194
2195        test::setup();
2196
2197        let report = cdr::parse_with_version(CDR_JSON, VERSION).unwrap();
2198        let cdr::ParseReport {
2199            cdr,
2200            unexpected_fields,
2201        } = report;
2202
2203        assert!(unexpected_fields.is_empty());
2204        let tariff::ParseReport {
2205            tariff,
2206            unexpected_fields,
2207        } = tariff::parse_with_version(TARIFF_JSON, VERSION).unwrap();
2208        assert!(unexpected_fields.is_empty());
2209
2210        // If you know the version and timezone of a CDR you simply pass them into the `cdr::price` fn.
2211        let report = cdr::price(
2212            &cdr,
2213            TariffSource::Override(vec![tariff.clone()]),
2214            Tz::Europe__Amsterdam,
2215        )
2216        .unwrap_report(&cdr);
2217
2218        let (report, warnings) = report.into_parts();
2219        assert_matches!(warnings.into_kind_vec().as_slice(), []);
2220
2221        let price::Report {
2222            // We are not concerned with warnings in this test
2223            periods,
2224            // We are not concerned with the tariff reports in this test
2225            tariff_used: _,
2226            tariff_reports: _,
2227            timezone: _,
2228            billed_energy,
2229            billed_parking_time,
2230            billed_charging_time,
2231            total_charging_time,
2232            total_energy,
2233            total_parking_time,
2234            // The `total_time` simply the addition of `total_charging_time` and `total_parking_time`.
2235            total_time: _,
2236            total_cost,
2237            total_energy_cost,
2238            total_fixed_cost,
2239            total_parking_cost,
2240            // Reservation costs are not computed during pricing.
2241            total_reservation_cost: _,
2242            total_time_cost,
2243        } = report;
2244
2245        let mut cdr_periods = charging(
2246            "2025-04-09T16:12:54.000Z",
2247            vec![
2248                dec!(2.75),
2249                dec!(2.77),
2250                dec!(1.88),
2251                dec!(2.1),
2252                dec!(2.09),
2253                dec!(2.11),
2254                dec!(2.09),
2255                dec!(2.09),
2256                dec!(2.09),
2257                dec!(2.09),
2258                dec!(2.09),
2259                dec!(2.09),
2260                dec!(2.09),
2261                dec!(2.11),
2262                dec!(2.13),
2263                dec!(2.09),
2264                dec!(2.11),
2265                dec!(2.12),
2266                dec!(2.13),
2267                dec!(2.1),
2268                dec!(2.0),
2269                dec!(0.69),
2270                dec!(0.11),
2271            ],
2272        );
2273        let mut periods_parking = parking("2025-04-09T21:57:55.000Z", 47);
2274
2275        cdr_periods.append(&mut periods_parking);
2276        cdr_periods.sort_by_key(|p| p.start_date_time);
2277
2278        assert_eq!(
2279            cdr_periods.len(),
2280            periods.len(),
2281            "The amount of `price::Report` periods should equal the periods given to the `price::periods` fn"
2282        );
2283        assert_eq!(
2284            periods.len(),
2285            70,
2286            "The `time_and_parking/cdr.json` has 70 `charging_periods`"
2287        );
2288
2289        assert!(periods
2290            .iter()
2291            .map(|p| p.start_date_time)
2292            .collect::<Vec<_>>()
2293            .is_sorted());
2294
2295        let (tariff, warnings) = super::tariff::parse(&tariff).unwrap().into_parts();
2296        assert!(warnings.is_empty());
2297
2298        let periods_report = price::periods(
2299            "2025-04-10T09:38:38.000Z".parse().unwrap(),
2300            chrono_tz::Europe::Amsterdam,
2301            &tariff,
2302            &mut cdr_periods,
2303        )
2304        .unwrap()
2305        .unwrap();
2306
2307        let price::PeriodsReport {
2308            billable,
2309            periods,
2310            totals,
2311            total_costs,
2312        } = periods_report;
2313
2314        assert_eq!(
2315            cdr_periods.len(),
2316            periods.len(),
2317            "The amount of `price::Report` periods should equal the periods given to the `price::periods` fn"
2318        );
2319        assert_eq!(
2320            periods.len(),
2321            70,
2322            "The `time_and_parking/cdr.json` has 70 `charging_periods`"
2323        );
2324
2325        assert_approx_eq!(billable.charging_time, billed_charging_time);
2326        assert_approx_eq!(billable.energy, billed_energy);
2327        assert_approx_eq!(billable.parking_time, billed_parking_time,);
2328
2329        assert_approx_eq!(totals.duration_charging, total_charging_time);
2330        assert_approx_eq!(totals.energy, total_energy.calculated);
2331        assert_approx_eq!(totals.duration_parking, total_parking_time.calculated);
2332
2333        assert_approx_eq!(total_costs.duration_charging, total_time_cost.calculated,);
2334        assert_approx_eq!(total_costs.energy, total_energy_cost.calculated,);
2335        assert_approx_eq!(total_costs.fixed, total_fixed_cost.calculated);
2336        assert_approx_eq!(total_costs.duration_parking, total_parking_cost.calculated);
2337        assert_approx_eq!(total_costs.total(), total_cost.calculated);
2338    }
2339}
2340
2341#[cfg(test)]
2342mod test_validate_cdr {
2343    use assert_matches::assert_matches;
2344
2345    use crate::{
2346        cdr,
2347        json::FromJson,
2348        price::{self, v221, WarningKind},
2349        test::{self, datetime_from_str},
2350    };
2351
2352    #[test]
2353    fn should_pass_parse_validation() {
2354        test::setup();
2355        let json = cdr_json("2022-01-13T16:00:00Z", "2022-01-13T19:12:00Z");
2356        let cdr::ParseReport {
2357            cdr,
2358            unexpected_fields,
2359        } = cdr::parse_with_version(&json, crate::Version::V221).unwrap();
2360        assert!(unexpected_fields.is_empty());
2361        let (_cdr, warnings) = v221::Cdr::from_json(cdr.as_element()).unwrap().into_parts();
2362        assert!(warnings.is_empty());
2363    }
2364
2365    #[test]
2366    fn should_fail_validation_start_end_range_doesnt_overlap_with_periods() {
2367        test::setup();
2368
2369        let json = cdr_json("2022-02-13T16:00:00Z", "2022-02-13T19:12:00Z");
2370        let cdr::ParseReport {
2371            cdr,
2372            unexpected_fields,
2373        } = cdr::parse_with_version(&json, crate::Version::V221).unwrap();
2374        assert!(unexpected_fields.is_empty());
2375        let (_cdr, warnings) = v221::Cdr::from_json(cdr.as_element()).unwrap().into_parts();
2376        let warnings = warnings.into_kind_vec();
2377        let [warning] = warnings.try_into().unwrap();
2378        let (cdr_range, period_range) = assert_matches!(warning, WarningKind::PeriodsOutsideStartEndDateTime { cdr_range, period_range } => (cdr_range, period_range));
2379
2380        {
2381            assert_eq!(cdr_range.start, datetime_from_str("2022-02-13T16:00:00Z"));
2382            assert_eq!(cdr_range.end, datetime_from_str("2022-02-13T19:12:00Z"));
2383        }
2384        {
2385            let period_range =
2386                assert_matches!(period_range, price::PeriodRange::Many(range) => range);
2387
2388            assert_eq!(
2389                period_range.start,
2390                datetime_from_str("2022-01-13T16:00:00Z")
2391            );
2392            assert_eq!(period_range.end, datetime_from_str("2022-01-13T18:30:00Z"));
2393        }
2394    }
2395
2396    fn cdr_json(start_date_time: &str, end_date_time: &str) -> String {
2397        let value = serde_json::json!({
2398            "country_code": "NL",
2399            "party_id": "ENE",
2400            "start_date_time": start_date_time,
2401            "end_date_time": end_date_time,
2402            "currency": "EUR",
2403            "tariffs": [],
2404            "cdr_location": {
2405                "country": "NLD"
2406            },
2407            "charging_periods": [
2408                {
2409                    "start_date_time": "2022-01-13T16:00:00Z",
2410                    "dimensions": [
2411                        {
2412                            "type": "TIME",
2413                            "volume": 2.5
2414                        }
2415                    ]
2416                },
2417                {
2418                    "start_date_time": "2022-01-13T18:30:00Z",
2419                    "dimensions": [
2420                        {
2421                            "type": "PARKING_TIME",
2422                            "volume": 0.7
2423                        }
2424                    ]
2425                }
2426            ],
2427            "total_cost": {
2428                "excl_vat": 11.25,
2429                "incl_vat": 12.75
2430            },
2431            "total_time_cost": {
2432                "excl_vat": 7.5,
2433                "incl_vat": 8.25
2434            },
2435            "total_parking_time": 0.7,
2436            "total_parking_cost": {
2437                "excl_vat": 3.75,
2438                "incl_vat": 4.5
2439            },
2440            "total_time": 3.2,
2441            "total_energy": 0,
2442            "last_updated": "2022-01-13T00:00:00Z"
2443        });
2444
2445        value.to_string()
2446    }
2447}
2448
2449#[cfg(test)]
2450mod test_real_world_v211 {
2451    use std::path::Path;
2452
2453    use crate::{
2454        cdr,
2455        price::{
2456            self,
2457            test::{Expect, ExpectFields, UnwrapReport},
2458        },
2459        tariff, test, timezone, Version,
2460    };
2461
2462    #[test_each::file(
2463        glob = "ocpi-tariffs/test_data/v211/real_world/*/cdr*.json",
2464        name(segments = 2)
2465    )]
2466    fn test_price_cdr(cdr_json: &str, path: &Path) {
2467        const VERSION: Version = Version::V211;
2468
2469        test::setup();
2470
2471        let expect_json = test::read_expect_json(path, "price");
2472        let expect = test::parse_expect_json::<Expect>(expect_json.as_deref());
2473
2474        let ExpectFields {
2475            timezone_find_expect,
2476            tariff_parse_expect,
2477            cdr_parse_expect,
2478            cdr_price_expect,
2479        } = expect.into_fields();
2480
2481        let tariff_json = std::fs::read_to_string(path.parent().unwrap().join("tariff.json")).ok();
2482        let tariff = tariff_json
2483            .as_deref()
2484            .map(|json| tariff::parse_with_version(json, VERSION))
2485            .transpose()
2486            .unwrap();
2487
2488        let tariff = if let Some(parse_report) = tariff {
2489            let tariff::ParseReport {
2490                tariff,
2491                unexpected_fields,
2492            } = parse_report;
2493            price::test::assert_parse_report(unexpected_fields, tariff_parse_expect);
2494            price::TariffSource::Override(vec![tariff])
2495        } else {
2496            assert!(tariff_parse_expect.value.is_none(), "There is no separate tariff to parse so there is no need to define a `tariff_parse` expectation");
2497            price::TariffSource::UseCdr
2498        };
2499
2500        let report = cdr::parse_with_version(cdr_json, VERSION).unwrap();
2501        let cdr::ParseReport {
2502            cdr,
2503            unexpected_fields,
2504        } = report;
2505        price::test::assert_parse_report(unexpected_fields, cdr_parse_expect);
2506
2507        let (timezone_source, warnings) = timezone::find_or_infer(&cdr).into_parts();
2508        let timezone_source = timezone_source.unwrap();
2509
2510        timezone::test::assert_find_or_infer_outcome(
2511            &cdr,
2512            timezone_source,
2513            timezone_find_expect,
2514            &warnings,
2515        );
2516
2517        let report = cdr::price(&cdr, tariff, timezone_source.into_timezone()).unwrap_report(&cdr);
2518        price::test::assert_price_report(&cdr, report, cdr_price_expect);
2519    }
2520}
2521
2522#[cfg(test)]
2523mod test_real_world_v221 {
2524    use std::path::Path;
2525
2526    use crate::{
2527        cdr,
2528        price::{
2529            self,
2530            test::{ExpectFields, UnwrapReport},
2531        },
2532        tariff, test, timezone, Version,
2533    };
2534
2535    #[test_each::file(
2536        glob = "ocpi-tariffs/test_data/v221/real_world/*/cdr*.json",
2537        name(segments = 2)
2538    )]
2539    fn test_price_cdr(cdr_json: &str, path: &Path) {
2540        const VERSION: Version = Version::V221;
2541
2542        test::setup();
2543
2544        let expect_json = test::read_expect_json(path, "price");
2545        let expect = test::parse_expect_json(expect_json.as_deref());
2546        let ExpectFields {
2547            timezone_find_expect,
2548            tariff_parse_expect,
2549            cdr_parse_expect,
2550            cdr_price_expect,
2551        } = expect.into_fields();
2552
2553        let tariff_json = std::fs::read_to_string(path.parent().unwrap().join("tariff.json")).ok();
2554        let tariff = tariff_json
2555            .as_deref()
2556            .map(|json| tariff::parse_with_version(json, VERSION))
2557            .transpose()
2558            .unwrap();
2559        let tariff = tariff
2560            .map(|report| {
2561                let tariff::ParseReport {
2562                    tariff,
2563                    unexpected_fields,
2564                } = report;
2565                price::test::assert_parse_report(unexpected_fields, tariff_parse_expect);
2566                price::TariffSource::Override(vec![tariff])
2567            })
2568            .unwrap_or(price::TariffSource::UseCdr);
2569
2570        let report = cdr::parse_with_version(cdr_json, VERSION).unwrap();
2571        let cdr::ParseReport {
2572            cdr,
2573            unexpected_fields,
2574        } = report;
2575        price::test::assert_parse_report(unexpected_fields, cdr_parse_expect);
2576
2577        let (timezone_source, warnings) = timezone::find_or_infer(&cdr).into_parts();
2578        let timezone_source = timezone_source.unwrap();
2579
2580        timezone::test::assert_find_or_infer_outcome(
2581            &cdr,
2582            timezone_source,
2583            timezone_find_expect,
2584            &warnings,
2585        );
2586
2587        // The v221 tariff location does not contain a timezone field, this timezone should be
2588        // used from the `Location`.
2589        //
2590        // The tariff's time related fields are in UTC and can be converted to local time by using
2591        // the timezone from the `Location` object.
2592        //
2593        // > `start_date_time`:
2594        // >
2595        // > The time when this tariff becomes active, in UTC, time_zone field of the Location can be used to convert to local time.
2596        // >
2597        // > See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>
2598        //
2599        // See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_locations.asciidoc>
2600        let report = cdr::price(&cdr, tariff, timezone_source.into_timezone()).unwrap_report(&cdr);
2601        price::test::assert_price_report(&cdr, report, cdr_price_expect);
2602    }
2603}