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