ocpi_tariffs/
price.rs

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