ocpi_tariffs/
price.rs

1mod restriction;
2mod session;
3mod tariff;
4mod v211;
5mod v221;
6
7use std::{borrow::Cow, fmt, ops::Range};
8
9use chrono_tz::Tz;
10use serde::Serialize;
11use tracing::{debug, instrument, trace};
12
13pub(crate) use tariff::Tariff;
14pub use v221::tariff::CompatibilityVat;
15
16use crate::{
17    de::obj_from_json_str, duration, warning, DateTime, HoursDecimal, Kwh, Number, ParseError,
18    Price, TariffId, UnexpectedFields, Version,
19};
20
21/// Structure containing the charge session priced according to the specified tariff.
22/// The fields prefixed `total` correspond to CDR fields with the same name.
23#[derive(Debug, Serialize)]
24pub struct Report {
25    /// A set of warnings generated while trying to price the CDR.
26    pub warnings: Vec<WarningKind>,
27
28    /// A set of unexpected fields encountered while parsing the CDR.
29    pub unexpected_fields: UnexpectedFields,
30
31    /// Charge session details per period.
32    pub periods: Vec<Period>,
33
34    /// Index of the tariff that was found to be active.
35    pub tariff_index: usize,
36
37    /// Id of the tariff that was found to be active.
38    pub tariff_id: TariffId,
39
40    /// A list of the tariff Ids found in the CDR or supplied to the [`cdr::price`](crate::cdr::price) function.
41    ///
42    /// Each tariff may have a set of unexpected fields encountered while parsing the tariff.
43    pub tariff_reports: Vec<(TariffId, UnexpectedFields)>,
44
45    /// Time-zone that was either specified or detected.
46    pub timezone: String,
47
48    /// The total energy after applying step-size.
49    pub billed_energy: Kwh,
50
51    /// The total parking time after applying step-size
52    pub billed_parking_time: HoursDecimal,
53
54    /// Total duration of the charging session (excluding not charging), in hours.
55    ///
56    /// This is a total that has no direct source field in the `CDR` as it is calculated in the
57    /// [`cdr::price`](crate::cdr::price) function.
58    pub total_charging_time: HoursDecimal,
59
60    /// The total charging time after applying step-size.
61    pub billed_charging_time: HoursDecimal,
62
63    /// Total sum of all the costs of this transaction in the specified currency.
64    pub total_cost: Total<Price, Option<Price>>,
65
66    /// 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.
67    pub total_fixed_cost: Total<Option<Price>>,
68
69    /// Total duration of the charging session (including the duration of charging and not charging), in hours.
70    pub total_time: Total<HoursDecimal>,
71
72    /// Total sum of all the cost related to duration of charging during this transaction, in the specified currency.
73    pub total_time_cost: Total<Option<Price>>,
74
75    /// Total energy charged, in kWh.
76    pub total_energy: Total<Kwh>,
77
78    /// Total sum of all the cost of all the energy used, in the specified currency.
79    pub total_energy_cost: Total<Option<Price>>,
80
81    /// Total duration of the charging session where the EV was not charging (no energy was transferred between EVSE and EV), in hours.
82    pub total_parking_time: Total<Option<HoursDecimal>, HoursDecimal>,
83
84    /// Total sum of all the cost related to parking of this transaction, including fixed price components, in the specified currency.
85    pub total_parking_cost: Total<Option<Price>>,
86
87    /// Total sum of all the cost related to a reservation of a Charge Point, including fixed price components, in the specified currency.
88    pub total_reservation_cost: Total<Option<Price>>,
89}
90
91#[derive(Debug, Serialize)]
92pub enum WarningKind {
93    /// The `start_date_time` of at least one of the `charging_periods` is outside of the
94    /// CDR's `start_date_time`-`end_date_time` range.
95    PeriodsOutsideStartEndDateTime {
96        cdr_range: Range<DateTime>,
97        period_range: PeriodRange,
98    },
99}
100
101impl fmt::Display for WarningKind {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        match self {
104            Self::PeriodsOutsideStartEndDateTime {
105                cdr_range,
106                period_range,
107            } => {
108                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)
109            }
110        }
111    }
112}
113
114impl warning::Kind for WarningKind {
115    fn id(&self) -> Cow<'static, str> {
116        match self {
117            WarningKind::PeriodsOutsideStartEndDateTime { .. } => {
118                "periods_outside_start_end_date_time".into()
119            }
120        }
121    }
122}
123
124/// A report for a single charging period that occurred during a session.
125///
126/// A charging period is a moment/event that has relevance for the total costs of a CDR.
127/// During a charging session, different parameters change all the time, like the amount of energy used,
128/// 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.
129#[derive(Debug, Serialize)]
130pub struct Period {
131    /// The start time of this period.
132    pub start_date_time: DateTime,
133
134    /// The end time of this period.
135    pub end_date_time: DateTime,
136
137    /// A structure that contains results per dimension.
138    pub dimensions: Dimensions,
139}
140
141impl Period {
142    pub fn new(period: &session::ChargePeriod, dimensions: Dimensions) -> Self {
143        Self {
144            start_date_time: period.start_instant.date_time,
145            end_date_time: period.end_instant.date_time,
146            dimensions,
147        }
148    }
149
150    /// The total cost of all dimensions in this period.
151    pub fn cost(&self) -> Option<Price> {
152        [
153            self.dimensions.time.cost(),
154            self.dimensions.parking_time.cost(),
155            self.dimensions.flat.cost(),
156            self.dimensions.energy.cost(),
157        ]
158        .into_iter()
159        .fold(None, |accum, next| {
160            if accum.is_none() && next.is_none() {
161                None
162            } else {
163                Some(
164                    accum
165                        .unwrap_or_default()
166                        .saturating_add(next.unwrap_or_default()),
167                )
168            }
169        })
170    }
171}
172
173/// A structure containing a report for each dimension.
174#[derive(Debug, Serialize)]
175pub struct Dimensions {
176    /// The flat dimension.
177    pub flat: Dimension<()>,
178
179    /// The energy dimension.
180    pub energy: Dimension<Kwh>,
181
182    /// The time dimension.
183    pub time: Dimension<HoursDecimal>,
184
185    /// The parking time dimension.
186    pub parking_time: Dimension<HoursDecimal>,
187}
188
189impl Dimensions {
190    pub fn new(components: &tariff::PriceComponents, data: &session::PeriodData) -> Self {
191        Self {
192            parking_time: Dimension::new(components.parking, data.parking_duration.map(Into::into)),
193            time: Dimension::new(components.time, data.charging_duration.map(Into::into)),
194            energy: Dimension::new(components.energy, data.energy),
195            flat: Dimension::new(components.flat, Some(())),
196        }
197    }
198}
199
200#[derive(Debug, Serialize)]
201/// A report for a single dimension during a single period.
202pub struct Dimension<V> {
203    /// The price component that was active during this period for this dimension.
204    /// It could be that no price component was active during this period for this dimension in
205    /// which case `price` is `None`.
206    pub price: Option<tariff::PriceComponent>,
207
208    /// The volume of this dimension during this period, as received in the provided charge detail record.
209    /// It could be that no volume was provided during this period for this dimension in which case
210    /// the `volume` is `None`.
211    pub volume: Option<V>,
212
213    /// This field contains the optional value of `volume` after a potential step size was applied.
214    /// Step size is applied over the total volume during the whole session of a dimension. But the
215    /// resulting additional volume should be billed according to the price component in this
216    /// period.
217    ///
218    /// If no step-size was applied for this period, the volume is exactly equal to the `volume`
219    /// field.
220    pub billed_volume: Option<V>,
221}
222
223impl<V> Dimension<V>
224where
225    V: Copy,
226{
227    fn new(price_component: Option<tariff::PriceComponent>, volume: Option<V>) -> Self {
228        Self {
229            price: price_component,
230            volume,
231            billed_volume: volume,
232        }
233    }
234}
235
236impl<V: tariff::Dimension> Dimension<V> {
237    /// The total cost of this dimension during a period.
238    pub fn cost(&self) -> Option<Price> {
239        if let (Some(volume), Some(price)) = (self.billed_volume, self.price) {
240            let excl_vat = volume.cost(price.price);
241
242            let incl_vat = match price.vat {
243                CompatibilityVat::Vat(Some(vat)) => Some(excl_vat.apply_vat(vat)),
244                CompatibilityVat::Vat(None) => Some(excl_vat),
245                CompatibilityVat::Unknown => None,
246            };
247
248            Some(Price { excl_vat, incl_vat })
249        } else {
250            None
251        }
252    }
253}
254
255/// A related source and calculated pair of total amounts.
256///
257/// This is used to express the source and calculated amounts for the total fields of a `CDR`.
258///
259/// - `total_cost`
260/// - `total_fixed_cost`
261/// - `total_energy`
262/// - `total_energy_cost`
263/// - `total_time`
264/// - `total_time_cost`
265/// - `total_parking_time`
266/// - `total_parking_cost`
267/// - `total_reservation_cost`
268#[derive(Debug, Serialize)]
269pub struct Total<TCdr, TCalc = TCdr> {
270    /// The source value from the `CDR`.
271    pub cdr: TCdr,
272
273    /// The value calculated by the [`cdr::price`](crate::cdr::price) function.
274    pub calculated: TCalc,
275}
276
277/// Possible errors when pricing a charge session.
278#[derive(Debug)]
279pub enum Error {
280    /// An error occurred while deserializing a `CDR` or tariff.
281    Deserialize(ParseError),
282
283    /// The given dimension should have a volume
284    DimensionShouldHaveVolume {
285        dimension_name: &'static str,
286    },
287
288    /// A numeric overflow occurred while creating a duration.
289    DurationOverflow,
290
291    /// An internal programming error.
292    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
293
294    /// No valid tariff has been found in the list of provided tariffs.
295    /// The tariff list can be sourced from either the tariffs contained in the CDR or from a list
296    /// provided by the caller.
297    ///
298    /// A valid tariff must have a start date-time before the start of the session and a end
299    /// date-time after the start of the session.
300    ///
301    /// If the CDR does not contain any tariffs consider providing a them using [`TariffSource`]
302    /// when calling [`cdr::price`](crate::cdr::price).
303    NoValidTariff,
304
305    // An error occurred while trying to process a CDR's tariff.
306    Tariff(tariff::Error),
307}
308
309impl From<InvalidPeriodIndex> for Error {
310    fn from(err: InvalidPeriodIndex) -> Self {
311        Self::Internal(err.into())
312    }
313}
314
315#[derive(Debug)]
316struct InvalidPeriodIndex(&'static str);
317
318impl std::error::Error for InvalidPeriodIndex {}
319
320impl fmt::Display for InvalidPeriodIndex {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        write!(f, "Invalid index for period `{}`", self.0)
323    }
324}
325#[derive(Debug, Serialize)]
326pub enum PeriodRange {
327    /// There are many periods in the CDR and so the range if from the `start_date_time` of the first to
328    /// the `start_date_time` of the last.
329    Many(Range<DateTime>),
330
331    /// There is one period in the CDR and so one `start_date_time`.
332    Single(DateTime),
333}
334
335impl fmt::Display for PeriodRange {
336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337        match self {
338            PeriodRange::Many(Range { start, end }) => write!(f, "{start}-{end}"),
339            PeriodRange::Single(date_time) => write!(f, "{date_time}"),
340        }
341    }
342}
343
344impl From<ParseError> for Error {
345    fn from(err: ParseError) -> Self {
346        Error::Deserialize(err)
347    }
348}
349
350impl From<tariff::Error> for Error {
351    fn from(err: tariff::Error) -> Self {
352        Error::Tariff(err)
353    }
354}
355
356impl From<duration::Error> for Error {
357    fn from(err: duration::Error) -> Self {
358        match err {
359            duration::Error::DurationOverflow => Self::DurationOverflow,
360        }
361    }
362}
363
364impl std::error::Error for Error {
365    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
366        match self {
367            Error::Internal(err) => Some(&**err),
368            Error::Tariff(err) => Some(err),
369            _ => None,
370        }
371    }
372}
373
374impl fmt::Display for Error {
375    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
376        match self {
377            Self::Deserialize(err) => {
378                write!(f, "{err}")
379            }
380            Self::DimensionShouldHaveVolume { dimension_name } => {
381                write!(f, "Dimension `{dimension_name}` should have volume")
382            }
383            Self::DurationOverflow => {
384                f.write_str("A numeric overflow occurred while creating a duration")
385            }
386            Self::Internal(err) => {
387                write!(f, "Internal: {err}")
388            }
389            Self::NoValidTariff => {
390                f.write_str("No valid tariff has been found in the list of provided tariffs")
391            }
392            Self::Tariff(err) => {
393                write!(f, "{err}")
394            }
395        }
396    }
397}
398
399#[derive(Debug)]
400enum InternalError {
401    InvalidPeriodIndex {
402        index: usize,
403        field_name: &'static str,
404    },
405}
406
407impl std::error::Error for InternalError {}
408
409impl From<InternalError> for Error {
410    fn from(err: InternalError) -> Self {
411        Error::Internal(Box::new(err))
412    }
413}
414
415impl fmt::Display for InternalError {
416    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
417        match self {
418            InternalError::InvalidPeriodIndex { field_name, index } => {
419                write!(
420                    f,
421                    "Invalid period index for `{field_name}`; index: `{index}`"
422                )
423            }
424        }
425    }
426}
427
428/// Where should the tariffs come from when pricing a `CDR`.
429///
430/// Used with [`cdr::price`](crate::cdr::price).
431#[derive(Debug)]
432pub enum TariffSource {
433    /// Use the tariffs from the `CDR`.
434    UseCdr,
435
436    /// Ignore the tariffs from the `CDR` and use these instead
437    Override(Vec<String>),
438}
439
440#[instrument(skip_all)]
441pub(crate) fn price_cdr(
442    cdr_json: &str,
443    tariff_source: TariffSource,
444    timezone: Tz,
445    version: Version,
446) -> Result<Report, Error> {
447    let cdr_deser = cdr_from_str(cdr_json, version)?;
448    let DeserCdr {
449        cdr,
450        unexpected_fields,
451    } = cdr_deser;
452
453    match tariff_source {
454        TariffSource::UseCdr => {
455            debug!("Using tariffs from CDR");
456            let tariffs = cdr
457                .tariffs
458                .iter()
459                .map(|json| tariff_from_str(json.get(), version))
460                .collect::<Result<Vec<_>, _>>()?;
461            let report =
462                price_cdr_with_tariffs(&cdr, &unexpected_fields, tariffs, timezone, version)?;
463            Ok(report)
464        }
465        TariffSource::Override(tariffs) => {
466            debug!("Using override tariffs");
467            let tariffs = tariffs
468                .iter()
469                .map(|json| tariff_from_str(json, version))
470                .collect::<Result<Vec<_>, _>>()?;
471            let report =
472                price_cdr_with_tariffs(&cdr, &unexpected_fields, tariffs, timezone, version)?;
473            Ok(report)
474        }
475    }
476}
477
478/// Price a single charge-session using a single tariff.
479///
480/// Returns a report containing the totals, subtotals and a breakdown of the calculation.
481fn price_cdr_with_tariffs<'a>(
482    cdr: &v221::Cdr<'a>,
483    unexpected_fields: &UnexpectedFields,
484    tariffs: Vec<DeserTariff<'a>>,
485    timezone: Tz,
486    version: Version,
487) -> Result<Report, Error> {
488    debug!(?timezone, ?version, "Pricing CDR");
489
490    let warnings = validate_cdr(cdr);
491
492    let tariff_reports = process_tariffs(tariffs)?;
493    debug!(tariffs = ?tariff_reports.iter().map(|report| report.tariff.id()).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
494
495    let tariff = find_first_active_tariff(&tariff_reports, cdr.start_date_time)
496        .ok_or(Error::NoValidTariff)?;
497
498    let (tariff_index, tariff) = tariff;
499
500    debug!(
501        id = tariff.id(),
502        index = tariff_index,
503        "Found active tariff"
504    );
505
506    debug!(%timezone, "Found timezone");
507
508    debug!("Extracting charge periods");
509    let cs_periods = session::extract_periods(cdr, timezone)?;
510
511    debug!(count = cs_periods.len(), "Found CDR periods");
512
513    trace!("# CDR period list:");
514    for period in &cs_periods {
515        trace!("{period:#?}");
516    }
517
518    let mut periods = Vec::new();
519    let mut step_size = StepSize::new();
520    let mut total_energy = Kwh::zero();
521    let mut total_charging_time = HoursDecimal::zero();
522    let mut total_parking_time = HoursDecimal::zero();
523
524    let mut has_flat_fee = false;
525
526    debug!(
527        tariff_id = tariff.id(),
528        period_count = periods.len(),
529        "Accumulating `total_charging_time`, `total_energy` and `total_parking_time`"
530    );
531
532    for (index, period) in cs_periods.iter().enumerate() {
533        let mut components = tariff.active_components(period);
534        trace!(
535            index,
536            "Creating charge period with Dimension\n{period:#?}\n{components:#?}"
537        );
538
539        if components.flat.is_some() {
540            if has_flat_fee {
541                components.flat = None;
542            } else {
543                has_flat_fee = true;
544            }
545        }
546
547        step_size.update(index, &components, period);
548
549        trace!(period_index = index, "Step size updated\n{step_size:#?}");
550
551        let dimensions = Dimensions::new(&components, &period.period_data);
552
553        trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
554
555        total_charging_time = total_charging_time
556            .saturating_add(dimensions.time.volume.unwrap_or_else(HoursDecimal::zero));
557
558        total_energy =
559            total_energy.saturating_add(dimensions.energy.volume.unwrap_or_else(Kwh::zero));
560
561        total_parking_time = total_parking_time.saturating_add(
562            dimensions
563                .parking_time
564                .volume
565                .unwrap_or_else(HoursDecimal::zero),
566        );
567
568        trace!(period_index = index, "Update totals");
569        trace!("total_charging_time: {total_charging_time:#?}");
570        trace!("total_energy: {total_energy:#?}");
571        trace!("total_parking_time: {total_parking_time:#?}");
572
573        periods.push(Period::new(period, dimensions));
574    }
575
576    let billed_charging_time = step_size.apply_time(&mut periods, total_charging_time)?;
577    let billed_energy = step_size.apply_energy(&mut periods, total_energy)?;
578    let billed_parking_time = step_size.apply_parking_time(&mut periods, total_parking_time)?;
579
580    trace!("Update billed totals");
581    trace!("billed_charging_time: {billed_charging_time:#?}");
582    trace!("billed_energy: {billed_energy:#?}");
583    trace!("billed_parking_time: {billed_parking_time:#?}");
584
585    let mut total_energy_cost: Option<Price> = None;
586    let mut total_fixed_cost: Option<Price> = None;
587    let mut total_parking_cost: Option<Price> = None;
588    let mut total_time_cost: Option<Price> = None;
589
590    debug!(
591        tariff_id = tariff.id(),
592        period_count = periods.len(),
593        "Accumulating `total_energy_cost`, `total_fixed_cost`, `total_parking_cost` and `total_time_cost`"
594    );
595    for (index, period) in periods.iter().enumerate() {
596        let dimensions = &period.dimensions;
597
598        trace!(period_index = index, "Processing period");
599        total_energy_cost = match (total_energy_cost, dimensions.energy.cost()) {
600            (None, None) => None,
601            (total, period) => Some(
602                total
603                    .unwrap_or_default()
604                    .saturating_add(period.unwrap_or_default()),
605            ),
606        };
607
608        total_time_cost = match (total_time_cost, dimensions.time.cost()) {
609            (None, None) => None,
610            (total, period) => Some(
611                total
612                    .unwrap_or_default()
613                    .saturating_add(period.unwrap_or_default()),
614            ),
615        };
616
617        total_parking_cost = match (total_parking_cost, dimensions.parking_time.cost()) {
618            (None, None) => None,
619            (total, period) => Some(
620                total
621                    .unwrap_or_default()
622                    .saturating_add(period.unwrap_or_default()),
623            ),
624        };
625
626        total_fixed_cost = match (total_fixed_cost, dimensions.flat.cost()) {
627            (None, None) => None,
628            (total, period) => Some(
629                total
630                    .unwrap_or_default()
631                    .saturating_add(period.unwrap_or_default()),
632            ),
633        };
634
635        trace!(period_index = index, "Update totals");
636        trace!("total_energy_cost = {total_energy_cost:?}");
637        trace!("total_fixed_cost = {total_fixed_cost:?}");
638        trace!("total_parking_cost = {total_parking_cost:?}");
639        trace!("total_time_cost = {total_time_cost:?}");
640    }
641
642    trace!("Calculating `total_cost` by accumulating `total_energy_cost`, `total_fixed_cost`, `total_parking_cost` and `total_time_cost`");
643    trace!("total_energy_cost = {total_energy_cost:?}");
644    trace!("total_fixed_cost = {total_fixed_cost:?}");
645    trace!("total_parking_cost = {total_parking_cost:?}");
646    trace!("total_time_cost = {total_time_cost:?}");
647    debug!(
648        ?total_energy_cost,
649        ?total_fixed_cost,
650        ?total_parking_cost,
651        ?total_time_cost,
652        "Calculating `total_cost`"
653    );
654
655    let total_cost = [
656        total_energy_cost,
657        total_fixed_cost,
658        total_parking_cost,
659        total_time_cost,
660    ]
661    .into_iter()
662    .fold(None, |accum: Option<Price>, next| match (accum, next) {
663        (None, None) => None,
664        _ => Some(
665            accum
666                .unwrap_or_default()
667                .saturating_add(next.unwrap_or_default()),
668        ),
669    });
670
671    debug!(?total_cost);
672
673    let total_time = {
674        debug!(
675            period_start = ?periods.first().map(|p| p.start_date_time),
676            period_end = ?periods.last().map(|p| p.end_date_time),
677            "Calculating `total_time`"
678        );
679        if let Some((first, last)) = periods.first().zip(periods.last()) {
680            let time_delta = last
681                .end_date_time
682                .signed_duration_since(*first.start_date_time);
683
684            time_delta.into()
685        } else {
686            HoursDecimal::zero()
687        }
688    };
689    debug!(%total_time);
690
691    let report = Report {
692        periods,
693        tariff_index,
694        tariff_id: tariff.id().to_string(),
695        timezone: timezone.to_string(),
696        billed_parking_time,
697        billed_energy,
698        billed_charging_time,
699        unexpected_fields: unexpected_fields.clone(),
700        tariff_reports: tariff_reports
701            .into_iter()
702            .map(
703                |TariffReport {
704                     tariff,
705                     unexpected_fields,
706                 }| (tariff.id().to_string(), unexpected_fields),
707            )
708            .collect(),
709        total_charging_time,
710        total_cost: Total {
711            cdr: cdr.total_cost,
712            calculated: total_cost,
713        },
714        total_time_cost: Total {
715            cdr: cdr.total_time_cost,
716            calculated: total_time_cost,
717        },
718        total_time: Total {
719            cdr: cdr.total_time,
720            calculated: total_time,
721        },
722        total_parking_cost: Total {
723            cdr: cdr.total_parking_cost,
724            calculated: total_parking_cost,
725        },
726        total_parking_time: Total {
727            cdr: cdr.total_parking_time,
728            calculated: total_parking_time,
729        },
730        total_energy_cost: Total {
731            cdr: cdr.total_energy_cost,
732            calculated: total_energy_cost,
733        },
734        total_energy: Total {
735            cdr: cdr.total_energy,
736            calculated: total_energy,
737        },
738        total_fixed_cost: Total {
739            cdr: cdr.total_fixed_cost,
740            calculated: total_fixed_cost,
741        },
742        total_reservation_cost: Total {
743            cdr: cdr.total_reservation_cost,
744            calculated: None,
745        },
746        warnings,
747    };
748
749    trace!("{report:#?}");
750
751    Ok(report)
752}
753
754/// Return warnings if the CDR isn't internally consistent.
755fn validate_cdr(cdr: &v221::Cdr<'_>) -> Vec<WarningKind> {
756    let mut warnings = vec![];
757    let cdr_range = cdr.start_date_time..cdr.end_date_time;
758    let periods: Vec<_> = cdr.charging_periods.iter().collect();
759
760    if let Ok([period]) = TryInto::<[_; 1]>::try_into(periods.as_ref()) {
761        if !cdr_range.contains(&period.start_date_time) {
762            warnings.push(WarningKind::PeriodsOutsideStartEndDateTime {
763                cdr_range,
764                period_range: PeriodRange::Single(period.start_date_time),
765            });
766        }
767    } else if let Ok([period_a, period_b]) = TryInto::<[_; 2]>::try_into(periods.as_ref()) {
768        let period_range = period_a.start_date_time..period_b.start_date_time;
769
770        if !(cdr_range.contains(&period_range.start) && cdr_range.contains(&period_range.end)) {
771            warnings.push(WarningKind::PeriodsOutsideStartEndDateTime {
772                cdr_range,
773                period_range: PeriodRange::Many(period_range),
774            });
775        }
776    }
777
778    warnings
779}
780
781/// A report returned from the `process_tariffs` fn.
782struct TariffReport<'a> {
783    /// The normalized tariff.
784    tariff: Tariff<'a>,
785
786    /// Set of unexpected fields encountered while parsing the tariff.
787    unexpected_fields: UnexpectedFields,
788}
789
790/// Convert a list of Tariffs into a two lists; a list of standardized Tariffs and a list of
791/// `UnexpectedFields` identified by tariff Id.
792fn process_tariffs(deser_tariffs: Vec<DeserTariff<'_>>) -> Result<Vec<TariffReport<'_>>, Error> {
793    let mut tariff_reports = vec![];
794
795    for tariff in deser_tariffs {
796        let DeserTariff {
797            tariff,
798            unexpected_fields,
799        } = tariff;
800
801        let tariff = Tariff::new(&tariff)?;
802
803        tariff_reports.push(TariffReport {
804            tariff,
805            unexpected_fields,
806        });
807    }
808
809    Ok(tariff_reports)
810}
811
812/// Find the first active tariff and return it and it's index.
813fn find_first_active_tariff<'a>(
814    tariffs: &'a [TariffReport<'a>],
815    start_date_time: DateTime,
816) -> Option<(usize, &'a Tariff<'a>)> {
817    let tariffs: Vec<_> = tariffs.iter().map(|report| &report.tariff).collect();
818
819    tariffs
820        .into_iter()
821        .enumerate()
822        .find(|(_, t)| t.is_active(start_date_time))
823}
824
825#[derive(Debug)]
826struct StepSize {
827    time: Option<(usize, tariff::PriceComponent)>,
828    parking_time: Option<(usize, tariff::PriceComponent)>,
829    energy: Option<(usize, tariff::PriceComponent)>,
830}
831
832impl StepSize {
833    fn new() -> Self {
834        Self {
835            time: None,
836            parking_time: None,
837            energy: None,
838        }
839    }
840
841    fn update(
842        &mut self,
843        index: usize,
844        components: &tariff::PriceComponents,
845        period: &session::ChargePeriod,
846    ) {
847        if period.period_data.energy.is_some() {
848            if let Some(energy) = components.energy {
849                self.energy = Some((index, energy));
850            }
851        }
852
853        if period.period_data.charging_duration.is_some() {
854            if let Some(time) = components.time {
855                self.time = Some((index, time));
856            }
857        }
858
859        if period.period_data.parking_duration.is_some() {
860            if let Some(parking) = components.parking {
861                self.parking_time = Some((index, parking));
862            }
863        }
864    }
865
866    fn duration_step_size(
867        total_volume: HoursDecimal,
868        period_billed_volume: &mut HoursDecimal,
869        step_size: u64,
870    ) -> Result<HoursDecimal, Error> {
871        if step_size == 0 {
872            return Ok(total_volume);
873        }
874
875        let total_seconds = total_volume.as_num_seconds_number();
876        let step_size = Number::from(step_size);
877
878        let total_billed_volume = HoursDecimal::from_seconds_number(
879            total_seconds
880                .checked_div(step_size)
881                .ok_or(Error::DurationOverflow)?
882                .ceil()
883                .saturating_mul(step_size),
884        )?;
885
886        let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
887        *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
888
889        Ok(total_billed_volume)
890    }
891
892    fn apply_time(
893        &self,
894        periods: &mut [Period],
895        total: HoursDecimal,
896    ) -> Result<HoursDecimal, Error> {
897        let (Some((time_index, price)), None) = (&self.time, &self.parking_time) else {
898            return Ok(total);
899        };
900
901        let Some(period) = periods.get_mut(*time_index) else {
902            return Err(InternalError::InvalidPeriodIndex {
903                index: *time_index,
904                field_name: "apply_time",
905            }
906            .into());
907        };
908        let volume = period.dimensions.time.billed_volume.as_mut().ok_or(
909            Error::DimensionShouldHaveVolume {
910                dimension_name: "time",
911            },
912        )?;
913
914        Self::duration_step_size(total, volume, price.step_size)
915    }
916
917    fn apply_parking_time(
918        &self,
919        periods: &mut [Period],
920        total: HoursDecimal,
921    ) -> Result<HoursDecimal, Error> {
922        let Some((parking_index, price)) = &self.parking_time else {
923            return Ok(total);
924        };
925
926        let Some(period) = periods.get_mut(*parking_index) else {
927            return Err(InternalError::InvalidPeriodIndex {
928                index: *parking_index,
929                field_name: "apply_parking_time",
930            }
931            .into());
932        };
933        let volume = period
934            .dimensions
935            .parking_time
936            .billed_volume
937            .as_mut()
938            .ok_or(Error::DimensionShouldHaveVolume {
939                dimension_name: "parking_time",
940            })?;
941
942        Self::duration_step_size(total, volume, price.step_size)
943    }
944
945    fn apply_energy(&self, periods: &mut [Period], total_volume: Kwh) -> Result<Kwh, Error> {
946        let Some((energy_index, price)) = &self.energy else {
947            return Ok(total_volume);
948        };
949
950        if price.step_size == 0 {
951            return Ok(total_volume);
952        }
953
954        let Some(period) = periods.get_mut(*energy_index) else {
955            return Err(InternalError::InvalidPeriodIndex {
956                index: *energy_index,
957                field_name: "apply_energy",
958            }
959            .into());
960        };
961        let step_size = Number::from(price.step_size);
962
963        let period_billed_volume = period.dimensions.energy.billed_volume.as_mut().ok_or(
964            Error::DimensionShouldHaveVolume {
965                dimension_name: "energy",
966            },
967        )?;
968
969        let total_billed_volume = Kwh::from_watt_hours(
970            total_volume
971                .watt_hours()
972                .checked_div(step_size)
973                .ok_or(Error::DurationOverflow)?
974                .ceil()
975                .saturating_mul(step_size),
976        );
977
978        let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
979        *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
980
981        Ok(total_billed_volume)
982    }
983}
984
985/// The result of deserializing a `CDR`.
986#[derive(Debug)]
987struct DeserCdr<'a> {
988    cdr: v221::Cdr<'a>,
989    unexpected_fields: UnexpectedFields,
990}
991
992fn cdr_from_str<'a>(json: &'a str, version: Version) -> Result<DeserCdr<'a>, ParseError> {
993    match version {
994        Version::V221 => {
995            let (cdr, unexpected_fields) =
996                obj_from_json_str::<v221::Cdr<'a>>(json).map_err(ParseError::from_cdr_serde_err)?;
997            Ok(DeserCdr {
998                cdr,
999                unexpected_fields,
1000            })
1001        }
1002        Version::V211 => {
1003            let (cdr, unexpected_fields) =
1004                obj_from_json_str::<v211::Cdr<'_>>(json).map_err(ParseError::from_cdr_serde_err)?;
1005            Ok(DeserCdr {
1006                cdr: cdr.into(),
1007                unexpected_fields,
1008            })
1009        }
1010    }
1011}
1012
1013/// The result of deserializing a `Tariff`.
1014#[derive(Debug)]
1015struct DeserTariff<'a> {
1016    tariff: v221::Tariff<'a>,
1017
1018    /// Set of unexpected fields encountered while parsing the tariff.
1019    unexpected_fields: UnexpectedFields,
1020}
1021
1022fn tariff_from_str<'a>(json: &'a str, version: Version) -> Result<DeserTariff<'a>, ParseError> {
1023    match version {
1024        Version::V221 => {
1025            let (tariff, unexpected_fields) = obj_from_json_str::<v221::Tariff<'a>>(json)
1026                .map_err(ParseError::from_tariff_serde_err)?;
1027            Ok(DeserTariff {
1028                tariff,
1029                unexpected_fields,
1030            })
1031        }
1032        Version::V211 => {
1033            let (tariff, unexpected_fields) = obj_from_json_str::<v211::Tariff<'a>>(json)
1034                .map_err(ParseError::from_tariff_serde_err)?;
1035            Ok(DeserTariff {
1036                tariff: tariff.into(),
1037                unexpected_fields,
1038            })
1039        }
1040    }
1041}
1042
1043#[cfg(test)]
1044pub mod test {
1045    #![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
1046    #![allow(clippy::panic, reason = "tests are allowed panic")]
1047
1048    use std::collections::BTreeMap;
1049
1050    use tracing::debug;
1051
1052    use crate::{
1053        price::Total, test::Expectation, timezone, warning::Kind, HoursDecimal, Kwh, Price,
1054    };
1055
1056    use super::{Error, Report};
1057
1058    // Decimal precision used when comparing the outcomes of the calculation with the CDR.
1059    const PRECISION: u32 = 2;
1060
1061    #[test]
1062    const fn error_should_be_send_and_sync() {
1063        const fn f<T: Send + Sync>() {}
1064
1065        f::<Error>();
1066    }
1067
1068    /// Parse the expect JSON for pricing a CDR.
1069    #[track_caller]
1070    pub fn parse_expect_json(
1071        expect_json: Option<&str>,
1072    ) -> (
1073        Option<timezone::test::FindOrInferExpect>,
1074        Option<PriceExpect>,
1075    ) {
1076        let expect = expect_json
1077            .map(|json| serde_json::from_str(json).expect("Unable to parse expect JSON"));
1078        expect
1079            .map(|v| {
1080                let Expect {
1081                    timezone_find,
1082                    cdr_price,
1083                } = v;
1084                (timezone_find, cdr_price)
1085            })
1086            .unwrap_or_default()
1087    }
1088
1089    #[derive(serde::Deserialize)]
1090    pub struct Expect {
1091        /// Expectations for the result of calling `timezone::find_or_infer`.
1092        pub timezone_find: Option<timezone::test::FindOrInferExpect>,
1093
1094        pub cdr_price: Option<PriceExpect>,
1095    }
1096
1097    pub(crate) fn assert_price_report(report: Report, cdr_price_expect: Option<PriceExpect>) {
1098        let Report {
1099            warnings,
1100            unexpected_fields,
1101            tariff_reports,
1102            periods: _,
1103            tariff_index,
1104            tariff_id,
1105            timezone: _,
1106            billed_energy: _,
1107            billed_parking_time: _,
1108            billed_charging_time: _,
1109            total_charging_time: _,
1110            total_cost,
1111            total_fixed_cost,
1112            total_time,
1113            total_time_cost,
1114            total_energy,
1115            total_energy_cost,
1116            total_parking_time,
1117            total_parking_cost,
1118            total_reservation_cost,
1119        } = report;
1120
1121        // This destructure isn't pretty but it's at least simple to maintain.
1122        // The alternative is getting involved with references of references when processing each borrowed field.
1123        let (
1124            warnings_expect,
1125            unexpected_fields_expect,
1126            tariff_index_expect,
1127            tariff_id_expect,
1128            tariff_reports_expect,
1129            total_cost_expectation,
1130            total_fixed_cost_expectation,
1131            total_time_expectation,
1132            total_time_cost_expectation,
1133            total_energy_expectation,
1134            total_energy_cost_expectation,
1135            total_parking_time_expectation,
1136            total_parking_cost_expectation,
1137            total_reservation_cost_expectation,
1138        ) = cdr_price_expect
1139            .map(|exp| {
1140                let PriceExpect {
1141                    warnings,
1142                    unexpected_fields,
1143                    tariff_index,
1144                    tariff_id,
1145                    tariff_reports,
1146                    total_cost,
1147                    total_fixed_cost,
1148                    total_time,
1149                    total_time_cost,
1150                    total_energy,
1151                    total_energy_cost,
1152                    total_parking_time,
1153                    total_parking_cost,
1154                    total_reservation_cost,
1155                } = exp;
1156
1157                (
1158                    warnings,
1159                    unexpected_fields,
1160                    tariff_index,
1161                    tariff_id,
1162                    tariff_reports,
1163                    total_cost,
1164                    total_fixed_cost,
1165                    total_time,
1166                    total_time_cost,
1167                    total_energy,
1168                    total_energy_cost,
1169                    total_parking_time,
1170                    total_parking_cost,
1171                    total_reservation_cost,
1172                )
1173            })
1174            .unwrap_or((
1175                Expectation::Absent,
1176                Expectation::Absent,
1177                Expectation::Absent,
1178                Expectation::Absent,
1179                Expectation::Absent,
1180                Expectation::Absent,
1181                Expectation::Absent,
1182                Expectation::Absent,
1183                Expectation::Absent,
1184                Expectation::Absent,
1185                Expectation::Absent,
1186                Expectation::Absent,
1187                Expectation::Absent,
1188                Expectation::Absent,
1189            ));
1190
1191        if let Expectation::Present(expectation) = warnings_expect {
1192            let warnings_expect = expectation.expect_value();
1193
1194            debug!("{warnings_expect:?}");
1195
1196            for warning in warnings {
1197                assert!(
1198                    warnings_expect.contains(&warning.id().to_string()),
1199                    "The CDR has a warning that's not expected"
1200                );
1201            }
1202        } else {
1203            assert!(warnings.is_empty(), "The CDR has warnings; {warnings:?}",);
1204        }
1205
1206        if let Expectation::Present(expectation) = unexpected_fields_expect {
1207            let unexpected_fields_expect = expectation.expect_value();
1208
1209            for field in unexpected_fields {
1210                assert!(
1211                    unexpected_fields_expect.contains(&field),
1212                    "The CDR has an unexpected field that's not expected: `{field}`"
1213                );
1214            }
1215        } else {
1216            assert!(
1217                unexpected_fields.is_empty(),
1218                "The CDR has unexpected fields; {unexpected_fields:?}",
1219            );
1220        }
1221
1222        if let Expectation::Present(expectation) = tariff_reports_expect {
1223            let tariff_reports_expect: BTreeMap<_, _> = expectation
1224                .expect_value()
1225                .into_iter()
1226                .map(
1227                    |TariffReport {
1228                         id,
1229                         unexpected_fields,
1230                     }| (id, unexpected_fields),
1231                )
1232                .collect();
1233
1234            for (tariff_id, mut unexpected_fields) in tariff_reports {
1235                let Some(unexpected_fields_expect) = tariff_reports_expect.get(&*tariff_id) else {
1236                    panic!("A tariff with {tariff_id} is not expected");
1237                };
1238
1239                debug!("{:?}", unexpected_fields_expect);
1240
1241                unexpected_fields.retain(|field| {
1242                    let present = unexpected_fields_expect.contains(field);
1243                    assert!(present, "The tariff with id: `{tariff_id}` has an unexpected field that is not expected: `{field}`");
1244                    !present
1245                });
1246
1247                assert!(
1248                    unexpected_fields.is_empty(),
1249                    "The tariff with id `{tariff_id}` has unexpected fields; {unexpected_fields:?}",
1250                );
1251            }
1252        } else {
1253            for (id, unexpected_fields) in tariff_reports {
1254                assert!(
1255                    unexpected_fields.is_empty(),
1256                    "The tariff with id `{id}` has unexpected fields; {unexpected_fields:?}",
1257                );
1258            }
1259        }
1260
1261        if let Expectation::Present(expectation) = tariff_id_expect {
1262            assert_eq!(tariff_id, expectation.expect_value());
1263        }
1264
1265        if let Expectation::Present(expectation) = tariff_index_expect {
1266            assert_eq!(tariff_index, expectation.expect_value());
1267        }
1268
1269        total_cost_expectation.expect_price("total_cost", &total_cost);
1270        total_fixed_cost_expectation.expect_opt_price("total_fixed_cost", &total_fixed_cost);
1271        total_time_expectation.expect_duration("total_time", &total_time);
1272        total_time_cost_expectation.expect_opt_price("total_time_cost", &total_time_cost);
1273        total_energy_expectation.expect_kwh("total_energy", &total_energy);
1274        total_energy_cost_expectation.expect_opt_price("total_energy_cost", &total_energy_cost);
1275        total_parking_time_expectation
1276            .expect_opt_duration("total_parking_time", &total_parking_time);
1277        total_parking_cost_expectation.expect_opt_price("total_parking_cost", &total_parking_cost);
1278        total_reservation_cost_expectation
1279            .expect_opt_price("total_reservation_cost", &total_reservation_cost);
1280    }
1281
1282    /// Expectations for the result of calling `cdr::price`.
1283    #[derive(serde::Deserialize)]
1284    pub struct PriceExpect {
1285        #[serde(default)]
1286        warnings: Expectation<Vec<String>>,
1287
1288        #[serde(default)]
1289        unexpected_fields: Expectation<Vec<String>>,
1290
1291        /// Index of the tariff that was found to be active.
1292        #[serde(default)]
1293        tariff_index: Expectation<usize>,
1294
1295        /// Id of the tariff that was found to be active.
1296        #[serde(default)]
1297        tariff_id: Expectation<String>,
1298
1299        /// A list of the tariff Ids found in the CDR or supplied to the [`cdr::price`](crate::cdr::price) function.
1300        ///
1301        /// Each tariff may have a set of unexpected fields encountered while parsing the tariff.
1302        #[serde(default)]
1303        tariff_reports: Expectation<Vec<TariffReport>>,
1304
1305        /// Total sum of all the costs of this transaction in the specified currency.
1306        #[serde(default)]
1307        total_cost: Expectation<Price>,
1308
1309        /// 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.
1310        #[serde(default)]
1311        total_fixed_cost: Expectation<Price>,
1312
1313        /// Total duration of the charging session (including the duration of charging and not charging), in hours.
1314        #[serde(default)]
1315        total_time: Expectation<HoursDecimal>,
1316
1317        /// Total sum of all the cost related to duration of charging during this transaction, in the specified currency.
1318        #[serde(default)]
1319        total_time_cost: Expectation<Price>,
1320
1321        /// Total energy charged, in kWh.
1322        #[serde(default)]
1323        total_energy: Expectation<Kwh>,
1324
1325        /// Total sum of all the cost of all the energy used, in the specified currency.
1326        #[serde(default)]
1327        total_energy_cost: Expectation<Price>,
1328
1329        /// Total duration of the charging session where the EV was not charging (no energy was transferred between EVSE and EV), in hours.
1330        #[serde(default)]
1331        total_parking_time: Expectation<HoursDecimal>,
1332
1333        /// Total sum of all the cost related to parking of this transaction, including fixed price components, in the specified currency.
1334        #[serde(default)]
1335        total_parking_cost: Expectation<Price>,
1336
1337        /// Total sum of all the cost related to a reservation of a Charge Point, including fixed price components, in the specified currency.
1338        #[serde(default)]
1339        total_reservation_cost: Expectation<Price>,
1340    }
1341
1342    #[derive(Debug, serde::Deserialize)]
1343    pub struct TariffReport {
1344        id: String,
1345
1346        unexpected_fields: Vec<String>,
1347    }
1348
1349    impl Expectation<Price> {
1350        #[track_caller]
1351        fn expect_opt_price(self, field_name: &str, total: &Total<Option<Price>>) {
1352            if let Expectation::Present(expect_value) = self {
1353                assert_eq!(
1354                    expect_value.into_option(),
1355                    total.calculated.map(|v| v.rescale().round_dp(PRECISION)),
1356                    "Comparing `{field_name}` field with expectation"
1357                );
1358            } else {
1359                match (total.cdr, total.calculated) {
1360                    (None, None) => (),
1361                    (None, Some(calculated)) => {
1362                        assert!(calculated.is_zero(), "The CDR field `{field_name}` doesn't have a value but a value was calculated; calculated: {calculated:?}");
1363                    }
1364                    (Some(cdr), None) => {
1365                        assert!(
1366                            cdr.is_zero(),
1367                            "The CDR field `{field_name}` has a value but the calculated value is none; cdr: {cdr:?}"
1368                        );
1369                    }
1370                    (Some(cdr), Some(calculated)) => {
1371                        assert_eq!(
1372                            cdr.round_dp(PRECISION),
1373                            calculated.rescale().round_dp(PRECISION),
1374                            "Comparing `{field_name}` field with CDR"
1375                        );
1376                    }
1377                }
1378            }
1379        }
1380
1381        #[track_caller]
1382        fn expect_price(self, field_name: &str, total: &Total<Price, Option<Price>>) {
1383            if let Expectation::Present(expect_value) = self {
1384                assert_eq!(
1385                    expect_value.into_option(),
1386                    total.calculated.map(|v| v.rescale().round_dp(PRECISION)),
1387                    "Comparing `{field_name}` field with expectation"
1388                );
1389            } else if let Some(calculated) = total.calculated {
1390                assert_eq!(
1391                    total.cdr.round_dp(PRECISION),
1392                    calculated.rescale().round_dp(PRECISION),
1393                    "Comparing `{field_name}` field with CDR"
1394                );
1395            } else {
1396                assert!(
1397                    total.cdr.is_zero(),
1398                    "The CDR field `{field_name}` has a value but the calculated value is none; cdr: {:?}",
1399                    total.cdr
1400                );
1401            }
1402        }
1403    }
1404
1405    impl Expectation<HoursDecimal> {
1406        #[track_caller]
1407        fn expect_duration(self, field_name: &str, total: &Total<HoursDecimal>) {
1408            if let Expectation::Present(expect_value) = self {
1409                assert_eq!(
1410                    expect_value.expect_value().as_num_hours_number(),
1411                    total.calculated.as_num_hours_number().round_dp(PRECISION),
1412                    "Comparing `{field_name}` field with expectation"
1413                );
1414            } else {
1415                assert_eq!(
1416                    total.cdr.as_num_hours_number().round_dp(PRECISION),
1417                    total.calculated.as_num_hours_number().round_dp(PRECISION),
1418                    "Comparing `{field_name}` field with CDR"
1419                );
1420            }
1421        }
1422
1423        #[track_caller]
1424        fn expect_opt_duration(
1425            self,
1426            field_name: &str,
1427            total: &Total<Option<HoursDecimal>, HoursDecimal>,
1428        ) {
1429            if let Expectation::Present(expect_value) = self {
1430                assert_eq!(
1431                    expect_value.expect_value().as_num_hours_number(),
1432                    total.calculated.as_num_hours_number().round_dp(PRECISION),
1433                    "Comparing `{field_name}` field with expectation"
1434                );
1435            } else {
1436                assert_eq!(
1437                    total
1438                        .cdr
1439                        .unwrap_or_default()
1440                        .as_num_hours_number()
1441                        .round_dp(PRECISION),
1442                    total.calculated.as_num_hours_number().round_dp(PRECISION),
1443                    "Comparing `{field_name}` field with CDR"
1444                );
1445            }
1446        }
1447    }
1448
1449    impl Expectation<Kwh> {
1450        #[track_caller]
1451        fn expect_kwh(self, field_name: &str, total: &Total<Kwh>) {
1452            if let Expectation::Present(expect_value) = self {
1453                assert_eq!(
1454                    expect_value.expect_value().round_dp(PRECISION),
1455                    total.calculated.rescale().round_dp(PRECISION),
1456                    "Comparing `{field_name}` field with expectation"
1457                );
1458            } else {
1459                assert_eq!(
1460                    total.cdr.round_dp(PRECISION),
1461                    total.calculated.rescale().round_dp(PRECISION),
1462                    "Comparing `{field_name}` field with CDR"
1463                );
1464            }
1465        }
1466    }
1467}
1468
1469#[cfg(test)]
1470mod test_validate_cdr {
1471    use assert_matches::assert_matches;
1472
1473    use crate::{
1474        de::obj_from_json_str,
1475        price::{self, v221, WarningKind},
1476        test::{self, datetime_from_str},
1477    };
1478
1479    use super::validate_cdr;
1480
1481    #[test]
1482    fn should_pass_validation() {
1483        test::setup();
1484        let json = cdr_json("2022-01-13T16:00:00Z", "2022-01-13T19:12:00Z");
1485        let (cdr, _) = obj_from_json_str::<v221::Cdr<'_>>(&json).unwrap();
1486
1487        let warnings = validate_cdr(&cdr);
1488        assert!(warnings.is_empty());
1489    }
1490
1491    #[test]
1492    fn should_fail_validation_start_end_range_doesnt_overlap_with_periods() {
1493        test::setup();
1494
1495        let json = cdr_json("2022-02-13T16:00:00Z", "2022-02-13T19:12:00Z");
1496        let (cdr, _) = obj_from_json_str::<v221::Cdr<'_>>(&json).unwrap();
1497
1498        let warnings = validate_cdr(&cdr);
1499        let [warning] = warnings.try_into().unwrap();
1500        let (cdr_range, period_range) = assert_matches!(warning, WarningKind::PeriodsOutsideStartEndDateTime { cdr_range, period_range } => (cdr_range, period_range));
1501        {
1502            assert_eq!(cdr_range.start, datetime_from_str("2022-02-13T16:00:00Z"));
1503            assert_eq!(cdr_range.end, datetime_from_str("2022-02-13T19:12:00Z"));
1504        }
1505        {
1506            let period_range =
1507                assert_matches!(period_range, price::PeriodRange::Many(range) => range);
1508
1509            assert_eq!(
1510                period_range.start,
1511                datetime_from_str("2022-01-13T16:00:00Z")
1512            );
1513            assert_eq!(period_range.end, datetime_from_str("2022-01-13T18:30:00Z"));
1514        }
1515    }
1516
1517    fn cdr_json(start_date_time: &str, end_date_time: &str) -> String {
1518        let value = serde_json::json!({
1519            "start_date_time": start_date_time,
1520            "end_date_time": end_date_time,
1521            "currency": "EUR",
1522            "tariffs": [],
1523            "cdr_location": {
1524                "country": "NLD"
1525            },
1526            "charging_periods": [
1527                {
1528                    "start_date_time": "2022-01-13T16:00:00Z",
1529                    "dimensions": [
1530                        {
1531                            "type": "TIME",
1532                            "volume": 2.5
1533                        }
1534                    ]
1535                },
1536                {
1537                    "start_date_time": "2022-01-13T18:30:00Z",
1538                    "dimensions": [
1539                        {
1540                            "type": "PARKING_TIME",
1541                            "volume": 0.7
1542                        }
1543                    ]
1544                }
1545            ],
1546            "total_cost": {
1547                "excl_vat": 11.25,
1548                "incl_vat": 12.75
1549            },
1550            "total_time_cost": {
1551                "excl_vat": 7.5,
1552                "incl_vat": 8.25
1553            },
1554            "total_parking_time": 0.7,
1555            "total_parking_cost": {
1556                "excl_vat": 3.75,
1557                "incl_vat": 4.5
1558            },
1559            "total_time": 3.2,
1560            "total_energy": 0,
1561            "last_updated": "2022-01-13T00:00:00Z"
1562        });
1563
1564        value.to_string()
1565    }
1566}