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