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