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