Skip to main content

ocpi_kit/tariffs/
engine.rs

1//! The pricing algorithm.
2//!
3//! # How a Tariff is applied
4//!
5//! > *When the list of Tariff Elements contains more than one Element that has a Price Component
6//! > for a certain dimension, then the first Tariff Element with a Price Component for that
7//! > dimension in the list with matching Tariff Restrictions will be used. Only one Price
8//! > Component per dimension can be active at any point in time.*
9//!
10//! So the lookup is **per dimension, per charging period**: for each period and each dimension it
11//! consumed, walk the Tariff's elements in order and take the first one that both prices that
12//! dimension and whose restrictions match at that moment.
13//!
14//! # `step_size`
15//!
16//! The rules are subtle and the specification spells them out with worked examples, which this
17//! module implements literally:
18//!
19//! > *`step_size` SHALL only be taken into account once per session for the TariffDimensionType
20//! > `ENERGY` and once for `PARKING_TIME` and `TIME` combined.*
21//!
22//! > *The `step_size` uses the total amount of a certain unit used during a session, not only the
23//! > last ChargingPeriod. … If the `step_size` differs for the different TariffElements, the
24//! > `step_size` of the last relevant PriceComponent is used.*
25//!
26//! > *In the cases that `TIME` and `PARKING_TIME` Tariff Elements are both used, `step_size` is
27//! > only taken into account for the total parking duration.*
28//!
29//! In other words: `ENERGY` is rounded once, on the session total, with the step of the last
30//! energy component used, and the extra lands in the last segment. The time dimensions are
31//! rounded once between them: `PARKING_TIME` absorbs it whenever the session has any, and `TIME`
32//! only when it does not — so charging time followed by parking time is billed exactly and only
33//! the parking is rounded up, which is the specification's own worked example.
34//!
35//! # What the engine assumes about its input, and what it does when that fails
36//!
37//! A Charging Period is the unit of pricing: it carries totals, not a curve, so there is no way
38//! to know how much of its energy fell before a tariff switched and how much after. The
39//! specification puts the obligation on the CPO instead:
40//!
41//! > *A CPO SHALL at least start (and add) a ChargingPeriod every moment/event that has relevance
42//! > for the total costs of a CDR. … When an energy changes in price after 17:00. The CPO has to
43//! > start a new Charging Period at 17:00.*
44//!
45//! Every implementation therefore assumes well-formed periods, and prices each one at the rate
46//! that applied when it began. What is unusual here is that **this engine checks**: it re-evaluates
47//! the restrictions at the moment each period ends, and when a different Price Component would
48//! have applied by then it records a
49//! [`PeriodSpansPriceChange`](super::PricingNoteCode::PeriodSpansPriceChange) note naming the
50//! dimension and the moment.
51//!
52//! That turns a silent few cents into a reviewable line. The total beside it is still the best
53//! answer available from the data — nothing is guessed or interpolated — but the reader is told
54//! that the CDR broke a `SHALL`, which is exactly the finding an invoice reconciliation exists to
55//! produce.
56
57use crate::types::{DateTime, LocalDate, LocalTime, Number};
58use crate::v2_3_0::tariffs::{
59    DayOfWeek, PriceComponent, ReservationRestrictionType, Tariff, TariffDimensionType, TariffElement,
60    TariffRestrictions,
61};
62
63use super::PricingError;
64use super::breakdown::{
65    AppliedComponent, CostBreakdown, DimensionCost, PriceLimitApplied, PricedSegment, PricingNote,
66    PricingNoteCode, TaxLine,
67};
68use super::input::{PricedPeriod, PricedSession};
69use super::policy::PricingPolicy;
70
71/// Prices sessions against Tariffs.
72///
73/// ```
74/// use ocpi_kit::tariffs::{PricingEngine, PricedPeriod, PricedSession, TimeZone};
75/// use ocpi_kit::v2_3_0::tariffs::*;
76/// use ocpi_kit::types::{DateTime, Number};
77///
78/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
79/// // "Simple Tariff example € 2 per hour": 2.00/h excl. VAT, 10% VAT, billed per minute.
80/// let tariff = Tariff::builder()
81///     .country_code("DE").party_id("ALL").id("11").currency("EUR")
82///     .elements(vec![TariffElement::builder()
83///         .price_components(vec![PriceComponent {
84///             component_type: TariffDimensionType::Time,
85///             price: "2.00".parse()?,
86///             vat: Some("10.0".parse()?),
87///             step_size: 60,
88///             extensions: Default::default(),
89///         }])
90///         .build()])
91///     .tax_included(TaxIncluded::No)
92///     .last_updated("2015-06-29T20:39:09Z".parse::<DateTime>()?)
93///     .build();
94///
95/// let session = PricedSession::new("2024-01-15T10:00:00Z".parse()?, TimeZone::utc())
96///     .with_period(PricedPeriod {
97///         charging_hours: "2.5".parse()?,
98///         ..PricedPeriod::new("2024-01-15T10:00:00Z".parse()?)
99///     })
100///     .ending("2024-01-15T12:30:00Z".parse()?);
101///
102/// let breakdown = PricingEngine::new().price(&session, &[tariff])?;
103/// assert_eq!(breakdown.total_excl_vat.to_string(), "5.00");
104/// assert_eq!(breakdown.total_incl_vat.to_string(), "5.50");
105/// # Ok(())
106/// # }
107/// ```
108#[derive(Clone, Debug, Default)]
109pub struct PricingEngine {
110    policy: PricingPolicy,
111}
112
113impl PricingEngine {
114    /// An engine with the default [`PricingPolicy`].
115    #[must_use]
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// An engine with a specific policy.
121    #[must_use]
122    pub fn with_policy(policy: PricingPolicy) -> Self {
123        Self { policy }
124    }
125
126    /// The policy in use.
127    #[must_use]
128    pub const fn policy(&self) -> &PricingPolicy {
129        &self.policy
130    }
131
132    /// Prices `session` against `tariffs`.
133    ///
134    /// When several tariffs are given, each charging period uses the one its `tariff_id` names;
135    /// a period with no `tariff_id` uses the first tariff that is valid at that moment and whose
136    /// `type` matches the session's charging preference.
137    ///
138    /// # Errors
139    ///
140    /// Returns [`PricingError`] when no applicable tariff can be found, or when the session's
141    /// time zone cannot be resolved.
142    pub fn price(&self, session: &PricedSession, tariffs: &[Tariff]) -> Result<CostBreakdown, PricingError> {
143        if tariffs.is_empty() {
144            return Err(PricingError::NoTariff);
145        }
146        let mut notes: Vec<PricingNote> = Vec::new();
147
148        // Per dimension, the segments that were priced.
149        let mut segments: Vec<(TariffDimensionType, PricedSegment, u32)> = Vec::new();
150        let mut flat_charged = false;
151
152        // A pricing input can be built by hand as well as read off a CDR, so the order the
153        // rest of this function depends on is checked here rather than assumed.
154        if let Some(at) = session.first_out_of_order() {
155            notes.push(PricingNote::new(
156                PricingNoteCode::PeriodsOutOfOrder,
157                Some(at),
158                "this Charging Period does not start after the one before it; `step_size` and \
159                 every duration-based restriction are evaluated against the order given, which \
160                 is not a timeline this session could have had",
161            ));
162        }
163
164        for (index, period) in session.periods.iter().enumerate() {
165            let tariff = Self::select_tariff(session, period, tariffs)?;
166            let context = RestrictionContext::build(session, index, period)?;
167            let end_context = RestrictionContext::build_at_end(session, index, period)?;
168
169            for (dimension, quantity, reserving) in period_quantities(period) {
170                if quantity.is_zero() {
171                    continue;
172                }
173                // A CDR may carry TIME and RESERVATION_TIME in the same ChargingPeriod, and the
174                // two are priced by different Tariff Elements — one restricted with
175                // `reservation`, one not. Looking both up against a single "this period is a
176                // reservation" flag would bill the charging minutes at the reservation rate, so
177                // each quantity is looked up in the context that describes *it*.
178                let context = context.reserving(reserving);
179                let Some(found) = find_component(tariff, dimension, &context) else {
180                    notes.push(PricingNote::new(
181                        PricingNoteCode::NoPriceComponent,
182                        Some(period.start),
183                        format!(
184                            "no {dimension} Price Component in tariff {} matched{}; \
185                             the specification says there are then no costs for that dimension",
186                            tariff.id,
187                            if reserving { " for the reserved time" } else { "" },
188                        ),
189                    ));
190                    continue;
191                };
192
193                // The period is priced at the rate that applied when it began. If a different
194                // one would apply by the time it ends, the CPO should have split it here.
195                if let Some(end) = end_context.as_ref().map(|c| c.reserving(reserving))
196                    && let Some(later) = find_component(tariff, dimension, &end)
197                    && (later.element_index, later.component_index)
198                        != (found.element_index, found.component_index)
199                {
200                    notes.push(PricingNote::new(
201                        PricingNoteCode::PeriodSpansPriceChange,
202                        Some(period.start),
203                        format!(
204                            "the {dimension} Charging Period starting here outlasts the Price \
205                             Component that prices it: element {} applies at the start and \
206                             element {} by the time the period ends. A CPO SHALL start a new \
207                             Charging Period at a price change, so this one should have been \
208                             split; its {dimension} is billed in full at the earlier rate, \
209                             because nothing in the period says how it divides",
210                            found.element_index, later.element_index,
211                        ),
212                    ));
213                }
214                segments.push((
215                    dimension,
216                    PricedSegment {
217                        start: period.start,
218                        quantity,
219                        price: found.component.price,
220                        vat_percentage: found.component.vat,
221                        cost: Number::ZERO, // filled in after quantisation
222                        applied: found.applied(tariff, &context),
223                    },
224                    found.component.step_size,
225                ));
226            }
227
228            // "FLAT: Flat fee without unit for step_size" — charged once for the session, by the
229            // first element that prices it and whose restrictions match.
230            let context = context.reserving(!period.reservation_hours.is_zero());
231            if !flat_charged && let Some(found) = find_component(tariff, TariffDimensionType::Flat, &context)
232            {
233                flat_charged = true;
234                segments.push((
235                    TariffDimensionType::Flat,
236                    PricedSegment {
237                        start: period.start,
238                        quantity: Number::ONE,
239                        price: found.component.price,
240                        vat_percentage: found.component.vat,
241                        cost: Number::ZERO,
242                        applied: found.applied(tariff, &context),
243                    },
244                    1,
245                ));
246            }
247        }
248
249        let dimensions = self.quantise_and_cost(segments);
250        let tariff = Self::select_tariff_for_limits(session, tariffs)?;
251        Ok(self.finish(dimensions, tariff, notes))
252    }
253
254    /// Applies `step_size` per the rules on this module, then costs every segment.
255    fn quantise_and_cost(
256        &self,
257        segments: Vec<(TariffDimensionType, PricedSegment, u32)>,
258    ) -> Vec<DimensionCost> {
259        use TariffDimensionType::{Energy, Flat, ParkingTime, Time};
260
261        // "In the cases that TIME and PARKING_TIME Tariff Elements are both used, `step_size` is
262        //  only taken into account for the total parking duration."
263        //
264        // Unconditional, so PARKING_TIME absorbs the rounding whenever it appears at all. The
265        // spec's own worked example justifies it sequentially instead — "the charging duration is
266        // not rounded up, as it is followed by another time based period" — and the two readings
267        // agree on every session that charges and then parks, which is every session. They part
268        // only on one that parks and then charges, and there the sentence is what governs.
269        let quantised_time_dimension =
270            if segments.iter().any(|(d, _, _)| *d == TariffDimensionType::ParkingTime) {
271                Some(ParkingTime)
272            } else {
273                segments.iter().find(|(d, _, _)| d.is_time_based()).map(|(d, _, _)| *d)
274            };
275
276        let mut by_dimension: Vec<(TariffDimensionType, Vec<(PricedSegment, u32)>)> = Vec::new();
277        for (dimension, segment, step) in segments {
278            match by_dimension.iter_mut().find(|(d, _)| *d == dimension) {
279                Some((_, list)) => list.push((segment, step)),
280                None => by_dimension.push((dimension, vec![(segment, step)])),
281            }
282        }
283
284        let mut out = Vec::with_capacity(by_dimension.len());
285        for (dimension, mut list) in by_dimension {
286            let measured: Number = list.iter().map(|(s, _)| s.quantity).sum();
287
288            // Only ENERGY and the last time-based dimension are quantised; a FLAT has no unit.
289            let quantise = match dimension {
290                Energy => true,
291                Time | ParkingTime => quantised_time_dimension == Some(dimension),
292                Flat => false,
293            };
294            let billed = if quantise {
295                // "the step_size of the last relevant PriceComponent is used"
296                let step = list.last().map_or(1, |(_, step)| *step);
297                let unit_scale = match dimension {
298                    Energy => 1000, // kWh measured, Wh counted
299                    _ => 3600,      // hours measured, seconds counted
300                };
301                self.policy.quantisation.apply(measured, step, unit_scale)
302            } else {
303                measured
304            };
305
306            // "The extra minutes are then added to the last period with a Price Component with a
307            //  time-based dimension" — the surplus is billed at the last segment's rate.
308            if billed != measured
309                && let Some((last, _)) = list.last_mut()
310            {
311                last.quantity = last.quantity + (billed - measured);
312            }
313
314            let mut cost = Number::ZERO;
315            let mut vat = Number::ZERO;
316            let mut priced_segments = Vec::with_capacity(list.len());
317            for (mut segment, _) in list {
318                segment.cost = self.policy.round_component(segment.quantity * segment.price);
319                segment.quantity = self.policy.round_quantity(segment.quantity);
320                cost = cost + segment.cost;
321                if let Some(percentage) = segment.vat_percentage {
322                    vat = vat + self.policy.round_component(segment.cost * percentage / Number::from(100u32));
323                }
324                priced_segments.push(segment);
325            }
326
327            out.push(DimensionCost {
328                dimension,
329                // Reported, not charged: the costs above came from the exact quantities.
330                measured: self.policy.round_quantity(measured),
331                billed: self.policy.round_quantity(billed),
332                cost: self.policy.round_component(cost),
333                vat: self.policy.round_component(vat),
334                segments: priced_segments,
335            });
336        }
337        out
338    }
339
340    /// Totals the dimensions, groups the VAT and applies `min_price`/`max_price`.
341    ///
342    /// # Why the tax lines move with the total
343    ///
344    /// The specification states the two price limits as independent rules:
345    ///
346    /// > *The total cost of a Charging Session before taxes can never be lower than the value of
347    /// > the min_price's `before_taxes` field. The total cost of a Charging Session after taxes
348    /// > can never be lower than the value of the min_price's `after_taxes` field.*
349    ///
350    /// Applying them literally and stopping there produces an incoherent document. A €0.50
351    /// session with 21% VAT under a `min_price.before_taxes` of €5.00 becomes €5.00 net — but its
352    /// tax lines still describe the €0.50 that was actually metered, so they no longer sum to the
353    /// difference between the two totals. Nobody can file that.
354    ///
355    /// So a clamp moves the taxes with the base, in proportion, and the invariant
356    /// `sum(taxes) == total_incl - total_excl` holds on every breakdown this engine produces. The
357    /// specification says nothing about this because it does not describe a breakdown at all;
358    /// this is the arithmetic that makes one usable, and a
359    /// [`TotalClamped`](super::PricingNoteCode::TotalClamped) note records that it happened.
360    fn finish(
361        &self,
362        dimensions: Vec<DimensionCost>,
363        tariff: &Tariff,
364        mut notes: Vec<PricingNote>,
365    ) -> CostBreakdown {
366        let mut taxes: Vec<TaxLine> = Vec::new();
367        for dimension in &dimensions {
368            for segment in &dimension.segments {
369                let Some(percentage) = segment.vat_percentage else { continue };
370                let amount = self.policy.round_component(segment.cost * percentage / Number::from(100u32));
371                match taxes.iter_mut().find(|t| t.percentage == Some(percentage)) {
372                    Some(line) => {
373                        line.taxable = line.taxable + segment.cost;
374                        line.amount = line.amount + amount;
375                    }
376                    None => {
377                        taxes.push(TaxLine { percentage: Some(percentage), taxable: segment.cost, amount });
378                    }
379                }
380            }
381        }
382        taxes.sort_by_key(|a| a.percentage);
383
384        let raw_excl: Number = dimensions.iter().map(|d| d.cost).sum();
385        let raw_vat: Number = taxes.iter().map(|t| t.amount).sum();
386        let mut total_excl = self.policy.round_currency(raw_excl);
387        let mut total_incl = self.policy.round_currency(raw_excl + raw_vat);
388        let mut limit_applied = None;
389
390        if let Some(min) = tariff.min_price.as_ref() {
391            if total_excl < min.before_taxes {
392                total_excl = self.policy.round_currency(min.before_taxes);
393                limit_applied = Some(PriceLimitApplied::Minimum);
394            }
395            if let Some(after) = min.after_taxes
396                && total_incl < after
397            {
398                total_incl = self.policy.round_currency(after);
399                limit_applied = Some(PriceLimitApplied::Minimum);
400            }
401        }
402        if let Some(max) = tariff.max_price.as_ref() {
403            if total_excl > max.before_taxes {
404                total_excl = self.policy.round_currency(max.before_taxes);
405                limit_applied = Some(PriceLimitApplied::Maximum);
406            }
407            if let Some(after) = max.after_taxes
408                && total_incl > after
409            {
410                total_incl = self.policy.round_currency(after);
411                limit_applied = Some(PriceLimitApplied::Maximum);
412            }
413        }
414
415        let mut base_ratio = Number::ONE;
416        if let Some(applied) = limit_applied {
417            // The pre-tax total moved, so the tax owed on it moved too — unless an explicit
418            // after-tax bound already decided the inclusive total, in which case that wins and
419            // the tax is whatever is left between them.
420            base_ratio = if raw_excl.is_zero() { Number::ONE } else { total_excl / raw_excl };
421            let bounded_after_tax = match applied {
422                PriceLimitApplied::Minimum => tariff.min_price.as_ref().and_then(|p| p.after_taxes),
423                PriceLimitApplied::Maximum => tariff.max_price.as_ref().and_then(|p| p.after_taxes),
424            };
425            if bounded_after_tax.is_none() {
426                // Nothing was metered to scale from: the effective rate is unknowable.
427                let scaled_vat = if raw_excl.is_zero() { Number::ZERO } else { raw_vat * base_ratio };
428                total_incl = self.policy.round_currency(total_excl + scaled_vat);
429            }
430            total_incl = total_incl.max(total_excl);
431            notes.push(PricingNote::new(
432                PricingNoteCode::TotalClamped,
433                None,
434                format!(
435                    "the session metered {raw_excl} before tax, which the tariff's {} price \
436                     limit moved to {total_excl}; the tax lines were moved in proportion so they \
437                     still account for the difference between the two totals",
438                    match applied {
439                        PriceLimitApplied::Minimum => "minimum",
440                        PriceLimitApplied::Maximum => "maximum",
441                    },
442                ),
443            ));
444        }
445
446        // A tariff with a negative `vat` is malformed — `Validate` reports it — but the engine
447        // does not require validated input, and a breakdown where the session costs less with tax
448        // than without is not a document anybody can use.
449        if total_incl < total_excl {
450            notes.push(PricingNote::new(
451                PricingNoteCode::NegativeTax,
452                None,
453                format!(
454                    "the price components of this tariff describe {} of tax, which no tariff can \
455                     mean; the inclusive total is held at the exclusive one. A VAT percentage \
456                     below zero is what causes this, and `Tariff::validate` names the component",
457                    total_incl - total_excl,
458                ),
459            ));
460            total_incl = total_excl;
461        }
462
463        self.present_taxes(&mut taxes, total_incl - total_excl, base_ratio, total_excl, &mut notes);
464
465        CostBreakdown {
466            dimensions,
467            total_excl_vat: total_excl,
468            total_incl_vat: total_incl,
469            taxes,
470            limit_applied,
471            notes,
472        }
473    }
474
475    /// Puts the tax lines into the shape a breakdown publishes them in.
476    ///
477    /// Two things happen here, and both are needed for the document to hold together.
478    ///
479    /// The lines are **rounded to currency precision**, like the totals beside them. They are
480    /// accumulated at the finer `component_decimals`, so without this a 2% VAT on €2,502,360
481    /// prints as `500.4720` next to totals that differ by `500.47`. That is a rounding
482    /// discrepancy of half a cent and an audit finding.
483    ///
484    /// And they are made to sum to **exactly** `owed`, with the last line absorbing the residue,
485    /// rather than each being rounded independently and hoping. `base_ratio` carries any
486    /// `min_price`/`max_price` clamp through to the `taxable` bases, so those keep describing the
487    /// amount the tax was actually charged on.
488    fn present_taxes(
489        &self,
490        taxes: &mut Vec<TaxLine>,
491        owed: Number,
492        base_ratio: Number,
493        taxable_base: Number,
494        notes: &mut Vec<PricingNote>,
495    ) {
496        let current: Number = taxes.iter().map(|t| t.amount).sum();
497        if taxes.is_empty() || current.is_zero() {
498            if owed.is_zero() {
499                for line in taxes.iter_mut() {
500                    line.taxable = self.policy.round_currency(line.taxable * base_ratio);
501                    line.amount = Number::ZERO;
502                }
503                return;
504            }
505            // A price limit's `after_taxes` bound above a session that named no VAT at all: the
506            // amount is a fact, the rate is not knowable, and inventing one would be a lie in a
507            // document somebody files.
508            notes.push(PricingNote::new(
509                PricingNoteCode::UnattributedTax,
510                None,
511                format!(
512                    "{owed} of tax is owed that no price component in this session accounts for; \
513                     it comes from a price limit's `after_taxes` bound, which names an amount but \
514                     not a rate",
515                ),
516            ));
517            taxes.clear();
518            taxes.push(TaxLine { percentage: None, taxable: taxable_base, amount: owed });
519            return;
520        }
521
522        let mut running = Number::ZERO;
523        let last = taxes.len() - 1;
524        for (i, line) in taxes.iter_mut().enumerate() {
525            line.taxable = self.policy.round_currency(line.taxable * base_ratio);
526            if i == last {
527                line.amount = owed - running;
528            } else {
529                line.amount = self.policy.round_currency(line.amount * owed / current);
530                running = running + line.amount;
531            }
532        }
533    }
534
535    /// The tariff that applies to one charging period.
536    fn select_tariff<'a>(
537        session: &PricedSession,
538        period: &PricedPeriod,
539        tariffs: &'a [Tariff],
540    ) -> Result<&'a Tariff, PricingError> {
541        if let Some(id) = period.tariff_id.as_deref() {
542            return tariffs
543                .iter()
544                .find(|t| t.id.eq_ignore_case(id))
545                .ok_or_else(|| PricingError::UnknownTariff(id.to_owned()));
546        }
547        Self::select_by_preference(session, period.start, tariffs)
548    }
549
550    /// The tariff whose `min_price`/`max_price` bound the session as a whole.
551    fn select_tariff_for_limits<'a>(
552        session: &PricedSession,
553        tariffs: &'a [Tariff],
554    ) -> Result<&'a Tariff, PricingError> {
555        Self::select_by_preference(session, session.start, tariffs)
556    }
557
558    fn select_by_preference<'a>(
559        session: &PricedSession,
560        at: DateTime,
561        tariffs: &'a [Tariff],
562    ) -> Result<&'a Tariff, PricingError> {
563        use crate::v2_3_0::sessions::ProfileType;
564        use crate::v2_3_0::tariffs::TariffType;
565        let wanted = if session.ad_hoc_payment {
566            Some(TariffType::AdHocPayment)
567        } else {
568            match session.profile_type {
569                Some(ProfileType::Cheap) => Some(TariffType::ProfileCheap),
570                Some(ProfileType::Fast) => Some(TariffType::ProfileFast),
571                Some(ProfileType::Green) => Some(TariffType::ProfileGreen),
572                Some(ProfileType::Regular) => Some(TariffType::Regular),
573                None => None,
574            }
575        };
576        let active: Vec<&Tariff> = tariffs.iter().filter(|t| t.is_active_at(at)).collect();
577        if active.is_empty() {
578            return Err(PricingError::NoActiveTariff(at));
579        }
580        // Prefer a tariff whose `type` matches the preference; then one with no `type`, which
581        // "is valid for all sessions"; then whatever is left.
582        if let Some(wanted) = wanted
583            && let Some(t) = active.iter().find(|t| t.tariff_type == Some(wanted))
584        {
585            return Ok(t);
586        }
587        if let Some(t) = active.iter().find(|t| t.tariff_type.is_none()) {
588            return Ok(t);
589        }
590        Ok(active[0])
591    }
592}
593
594/// The quantities one period consumed, in the order the dimensions are evaluated.
595///
596/// The third element says whether that quantity is **reserved** time rather than consumed time,
597/// which is what decides between a Tariff Element restricted with `reservation` and one without.
598/// Reserved time is a `TIME` quantity like any other — OCPI has no `RESERVATION` tariff dimension,
599/// only a `reservation` *restriction* — so it shares the dimension, and therefore the `step_size`
600/// budget, with charging time.
601fn period_quantities(period: &PricedPeriod) -> [(TariffDimensionType, Number, bool); 4] {
602    [
603        (TariffDimensionType::Energy, period.energy_kwh, false),
604        (TariffDimensionType::Time, period.charging_hours, false),
605        (TariffDimensionType::Time, period.reservation_hours, true),
606        (TariffDimensionType::ParkingTime, period.parking_hours, false),
607    ]
608}
609
610/// Everything a restriction can be evaluated against, at one moment of one session.
611#[derive(Clone, Copy)]
612struct RestrictionContext {
613    local_time: LocalTime,
614    local_date: LocalDate,
615    weekday: DayOfWeek,
616    energy_so_far: Number,
617    duration_so_far_seconds: i64,
618    current_lower: Option<Number>,
619    current_upper: Option<Number>,
620    power_lower: Option<Number>,
621    power_upper: Option<Number>,
622    is_reservation: bool,
623    reservation_expired: bool,
624}
625
626impl RestrictionContext {
627    fn build(session: &PricedSession, index: usize, period: &PricedPeriod) -> Result<Self, PricingError> {
628        let local = session.time_zone.to_local(period.start)?;
629        Ok(Self {
630            local_time: LocalTime::new(local.hour(), local.minute())
631                .map_err(|e| PricingError::TimeZone(e.to_string()))?,
632            local_date: LocalDate::from_date(local.date()),
633            weekday: DayOfWeek::from_iso_number(local.weekday().number_from_monday())
634                .unwrap_or(DayOfWeek::Monday),
635            energy_so_far: session.energy_before(index),
636            duration_so_far_seconds: session.duration_before(index),
637            current_lower: period.current_for_lower_bound(),
638            current_upper: period.current_for_upper_bound(),
639            power_lower: period.power_for_lower_bound(),
640            power_upper: period.power_for_upper_bound(),
641            is_reservation: false,
642            reservation_expired: session.reservation_expired,
643        })
644    }
645
646    /// The context at the last instant the period covers, or `None` when the period has no known
647    /// end — an open session's final period.
648    ///
649    /// One second before the end rather than at it, because a period is half-open: one that runs
650    /// up to exactly 17:00 does not span the 17:00 boundary, and must not be reported as if it
651    /// did.
652    fn build_at_end(
653        session: &PricedSession,
654        index: usize,
655        period: &PricedPeriod,
656    ) -> Result<Option<Self>, PricingError> {
657        let Some(end) = session.period_end(index) else { return Ok(None) };
658        let Some(last_instant) = DateTime::from_unix_timestamp(end.unix_timestamp() - 1).ok() else {
659            return Ok(None);
660        };
661        if last_instant <= period.start {
662            // A period of a second or less cannot span anything.
663            return Ok(None);
664        }
665        let local = session.time_zone.to_local(last_instant)?;
666        Ok(Some(Self {
667            local_time: LocalTime::new(local.hour(), local.minute())
668                .map_err(|e| PricingError::TimeZone(e.to_string()))?,
669            local_date: LocalDate::from_date(local.date()),
670            weekday: DayOfWeek::from_iso_number(local.weekday().number_from_monday())
671                .unwrap_or(DayOfWeek::Monday),
672            // By the end of the period, everything it consumed has been consumed.
673            energy_so_far: session.energy_before(index) + period.energy_kwh,
674            duration_so_far_seconds: last_instant.unix_timestamp() - session.start.unix_timestamp(),
675            current_lower: period.current_for_lower_bound(),
676            current_upper: period.current_for_upper_bound(),
677            power_lower: period.power_for_lower_bound(),
678            power_upper: period.power_for_upper_bound(),
679            is_reservation: false,
680            reservation_expired: session.reservation_expired,
681        }))
682    }
683
684    /// This context, viewed as pricing reserved time or consumed time.
685    const fn reserving(&self, is_reservation: bool) -> Self {
686        Self { is_reservation, ..*self }
687    }
688
689    fn describe(&self) -> String {
690        format!(
691            "at {} {} local ({}), {} kWh and {}s into the session",
692            self.local_date, self.local_time, self.weekday, self.energy_so_far, self.duration_so_far_seconds
693        )
694    }
695}
696
697/// The Price Component that priced one dimension of one period, with where it came from.
698struct Found<'a> {
699    component: &'a PriceComponent,
700    element_index: usize,
701    component_index: usize,
702}
703
704impl Found<'_> {
705    fn applied(&self, tariff: &Tariff, context: &RestrictionContext) -> AppliedComponent {
706        AppliedComponent {
707            tariff_id: tariff.id.as_str().to_owned(),
708            element_index: self.element_index,
709            component_index: self.component_index,
710            because: context.describe(),
711        }
712    }
713}
714
715/// The first Tariff Element that prices `dimension` and whose restrictions match.
716fn find_component<'a>(
717    tariff: &'a Tariff,
718    dimension: TariffDimensionType,
719    context: &RestrictionContext,
720) -> Option<Found<'a>> {
721    for (element_index, element) in tariff.elements.iter().enumerate() {
722        if !restrictions_match(element, context) {
723            continue;
724        }
725        for (component_index, component) in element.price_components.iter().enumerate() {
726            if component.component_type == dimension {
727                return Some(Found { component, element_index, component_index });
728            }
729        }
730    }
731    None
732}
733
734fn restrictions_match(element: &TariffElement, context: &RestrictionContext) -> bool {
735    let Some(restrictions) = element.restrictions.as_ref() else {
736        // "a Tariff Element without restrictions … will act as fallback"
737        return !context.is_reservation || element_prices_reservation_dimension(element);
738    };
739    matches(restrictions, context)
740}
741
742/// A reservation period may only be priced by an element that is about reservations, or by a
743/// fallback element with `FLAT`/`TIME` components.
744fn element_prices_reservation_dimension(element: &TariffElement) -> bool {
745    element
746        .price_components
747        .iter()
748        .any(|c| matches!(c.component_type, TariffDimensionType::Flat | TariffDimensionType::Time))
749}
750
751/// Whether every restriction that is set matches. *"they are to be treated as a logical AND."*
752fn matches(r: &TariffRestrictions, context: &RestrictionContext) -> bool {
753    // "When this field is present, the TariffElement describes reservation costs."
754    match r.reservation {
755        Some(ReservationRestrictionType::Reservation) => {
756            if !context.is_reservation || context.reservation_expired {
757                return false;
758            }
759        }
760        Some(ReservationRestrictionType::ReservationExpires) => {
761            if !context.is_reservation || !context.reservation_expired {
762                return false;
763            }
764        }
765        None => {
766            if context.is_reservation {
767                // A non-reservation element does not price a reservation period.
768                return false;
769            }
770        }
771    }
772
773    if let (Some(start), Some(end)) = (r.start_time, r.end_time) {
774        if !context.local_time.is_within(start, end) {
775            return false;
776        }
777    } else if let Some(start) = r.start_time {
778        if context.local_time < start {
779            return false;
780        }
781    } else if let Some(end) = r.end_time
782        && context.local_time >= end
783    {
784        return false;
785    }
786
787    // "start_date … valid from this day (inclusive)"; "end_date … valid until this day (exclusive)"
788    if r.start_date.is_some_and(|d| context.local_date < d) {
789        return false;
790    }
791    if r.end_date.is_some_and(|d| context.local_date >= d) {
792        return false;
793    }
794
795    // "min_kwh … valid from this amount of energy (inclusive) being used"
796    if r.min_kwh.is_some_and(|min| context.energy_so_far < min) {
797        return false;
798    }
799    // "max_kwh … valid until this amount of energy (exclusive) being used"
800    if r.max_kwh.is_some_and(|max| context.energy_so_far >= max) {
801        return false;
802    }
803
804    if let Some(min) = r.min_current
805        && context.current_lower.is_none_or(|c| c < min)
806    {
807        return false;
808    }
809    if let Some(max) = r.max_current
810        && context.current_upper.is_none_or(|c| c >= max)
811    {
812        return false;
813    }
814    if let Some(min) = r.min_power
815        && context.power_lower.is_none_or(|p| p < min)
816    {
817        return false;
818    }
819    if let Some(max) = r.max_power
820        && context.power_upper.is_none_or(|p| p >= max)
821    {
822        return false;
823    }
824
825    if let Some(min) = r.min_duration
826        && context.duration_so_far_seconds < i64_of(min)
827    {
828        return false;
829    }
830    if let Some(max) = r.max_duration
831        && context.duration_so_far_seconds >= i64_of(max)
832    {
833        return false;
834    }
835
836    if !r.day_of_week.is_empty() && !r.day_of_week.contains(&context.weekday) {
837        return false;
838    }
839
840    true
841}
842
843fn i64_of(value: u64) -> i64 {
844    i64::try_from(value).unwrap_or(i64::MAX)
845}