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