Skip to main content

ocpi_kit/tariffs/
mod.rs

1//! An auditable pricing engine: what a charging session costs, and exactly why.
2//!
3//! OCPI is the only protocol that carries both the tariff and the metering data, which means the
4//! cost of a session is computable from what crosses the wire. Doing that well is worth a lot:
5//! it is how an eMSP checks a CPO's invoice, how a CPO checks its own, and how the Payments
6//! module's financial advice confirmations get reconciled against the CDRs they belong to.
7//!
8//! # What makes this engine different
9//!
10//! **The answer is auditable.** [`CostBreakdown`] does not just say `12.28`; it says which
11//! quantity was billed for each dimension, what `step_size` did to it, which Tariff Element and
12//! which Price Component priced it, and why that element was selected.
13//!
14//! **The arithmetic is exact.** Every value is a [`Number`](crate::types::Number), a decimal.
15//! There is no `f64` anywhere in this module.
16//!
17//! **The undefined parts are parameters.** The specification says nothing about rounding, on
18//! purpose, and OCPI 3.0 removes `step_size` altogether. Both are settings on
19//! [`PricingPolicy`] rather than assumptions baked into the code.
20//!
21//! # Example
22//!
23//! ```
24//! use ocpi_kit::tariffs::{PricedPeriod, PricedSession, PricingEngine, TimeZone};
25//! use ocpi_kit::types::DateTime;
26//! # use ocpi_kit::v2_3_0::tariffs::*;
27//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
28//! # let tariff = Tariff::builder().country_code("DE").party_id("ALL").id("1").currency("EUR")
29//! #   .elements(vec![TariffElement::builder().price_components(vec![
30//! #       PriceComponent { component_type: TariffDimensionType::Energy, price: "0.25".parse()?,
31//! #                        vat: Some("10".parse()?), step_size: 1, extensions: Default::default() }
32//! #   ]).build()])
33//! #   .tax_included(TaxIncluded::No).last_updated("2024-01-01T00:00:00Z".parse::<DateTime>()?).build();
34//! let session = PricedSession::new("2024-01-15T10:00:00Z".parse()?, TimeZone::named("Europe/Berlin")?)
35//!     .with_period(PricedPeriod {
36//!         energy_kwh: "20".parse()?,
37//!         ..PricedPeriod::new("2024-01-15T10:00:00Z".parse()?)
38//!     });
39//!
40//! let breakdown = PricingEngine::new().price(&session, &[tariff])?;
41//! assert_eq!(breakdown.total_excl_vat.to_string(), "5.00");
42//! assert_eq!(breakdown.total_incl_vat.to_string(), "5.50");
43//! # Ok(())
44//! # }
45//! ```
46//!
47//! Spec: 2.3.0 §mod_tariffs_tariffs_module, §mod_cdrs_step_size
48
49mod breakdown;
50mod engine;
51mod input;
52mod policy;
53
54pub use breakdown::{
55    AppliedComponent, CostBreakdown, DimensionCost, PriceLimitApplied, PricedSegment, PricingNote,
56    PricingNoteCode, TaxLine,
57};
58pub use engine::PricingEngine;
59pub use input::{PricedPeriod, PricedSession};
60pub use policy::{PricingPolicy, Quantisation};
61
62use core::fmt;
63
64use crate::types::DateTime;
65
66/// The IANA time zone a Location is in, which the local-time restrictions are expressed in.
67///
68/// > *`start_time`: Start time of day in local time, the time zone is defined in the `time_zone`
69/// > field of the Location.*
70///
71/// A tariff that costs more after 17:00 is wrong by an hour for half the year unless the
72/// conversion goes through the real zone rules, so this resolves the name against the IANA
73/// database rather than assuming a fixed offset.
74#[derive(Clone)]
75pub struct TimeZone {
76    name: String,
77    zone: Option<tz::TimeZoneRef<'static>>,
78}
79
80impl TimeZone {
81    /// Resolves an IANA time zone name, such as `Europe/Oslo`.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`PricingError::TimeZone`] when the name is not in the IANA database.
86    pub fn named(name: &str) -> Result<Self, PricingError> {
87        if name.eq_ignore_ascii_case("UTC") {
88            return Ok(Self::utc());
89        }
90        let zone = tzdb::tz_by_name(name)
91            .ok_or_else(|| PricingError::TimeZone(format!("unknown IANA time zone {name:?}")))?;
92        Ok(Self { name: name.to_owned(), zone: Some(zone) })
93    }
94
95    /// UTC, for tests and for a Location whose zone is not known.
96    #[must_use]
97    pub fn utc() -> Self {
98        Self { name: "UTC".to_owned(), zone: None }
99    }
100
101    /// The IANA name this zone was built from.
102    #[must_use]
103    pub fn name(&self) -> &str {
104        &self.name
105    }
106
107    /// The UTC offset in effect at `instant`, in seconds.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`PricingError::TimeZone`] when the instant is outside the range the zone's rules
112    /// cover.
113    pub fn offset_seconds_at(&self, instant: DateTime) -> Result<i32, PricingError> {
114        let Some(zone) = self.zone else { return Ok(0) };
115        zone.find_local_time_type(instant.unix_timestamp())
116            .map(tz::LocalTimeType::ut_offset)
117            .map_err(|e| PricingError::TimeZone(format!("{}: {e}", self.name)))
118    }
119
120    /// `instant` as a wall-clock time in this zone.
121    ///
122    /// # Errors
123    ///
124    /// Returns [`PricingError::TimeZone`] when the offset cannot be determined.
125    pub fn to_local(&self, instant: DateTime) -> Result<time::OffsetDateTime, PricingError> {
126        let offset = self.offset_seconds_at(instant)?;
127        Ok(instant.as_offset_date_time() + time::Duration::seconds(i64::from(offset)))
128    }
129}
130
131impl fmt::Debug for TimeZone {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        write!(f, "TimeZone({})", self.name)
134    }
135}
136
137impl PartialEq for TimeZone {
138    fn eq(&self, other: &Self) -> bool {
139        self.name.eq_ignore_ascii_case(&other.name)
140    }
141}
142
143/// Why a session could not be priced.
144#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
145#[non_exhaustive]
146pub enum PricingError {
147    /// No tariff was given at all.
148    #[error("no tariff to price against")]
149    NoTariff,
150    /// A charging period named a `tariff_id` that was not among the tariffs given.
151    #[error("charging period refers to tariff {0:?}, which was not provided")]
152    UnknownTariff(String),
153    /// None of the tariffs given was valid at the moment in question.
154    #[error("no tariff is active at {0}")]
155    NoActiveTariff(DateTime),
156    /// The Location's time zone could not be resolved.
157    #[error("time zone: {0}")]
158    TimeZone(String),
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::types::Number;
165    use crate::v2_3_0::tariffs::{
166        PriceComponent, PriceLimit, Tariff, TariffDimensionType, TariffElement, TariffRestrictions,
167        TaxIncluded,
168    };
169
170    fn n(s: &str) -> Number {
171        s.parse().unwrap()
172    }
173    fn dt(s: &str) -> DateTime {
174        s.parse().unwrap()
175    }
176
177    /// Minutes as a fraction of an hour, the unit OCPI measures time in.
178    fn minutes(count: u32) -> Number {
179        Number::from(count) / Number::from(60u32)
180    }
181
182    /// Compares two quantities at the precision a [`CostBreakdown`] reports them to.
183    ///
184    /// Durations in hours are repeating decimals — 35 minutes is 0.58333… — and the breakdown
185    /// reports them rounded, so an exact comparison would be asserting on digits the artefact
186    /// deliberately does not carry. See `PricingPolicy::quantity_decimals`.
187    #[track_caller]
188    fn assert_close(actual: Number, expected: Number) {
189        let dp = PricingPolicy::default().quantity_decimals;
190        assert_eq!(actual.round_dp(dp), expected.round_dp(dp), "expected {expected}, got {actual}");
191    }
192
193    fn component(
194        dimension: TariffDimensionType,
195        price: &str,
196        vat: Option<&str>,
197        step_size: u32,
198    ) -> PriceComponent {
199        PriceComponent {
200            component_type: dimension,
201            price: n(price),
202            vat: vat.map(n),
203            step_size,
204            extensions: crate::types::Extensions::new(),
205        }
206    }
207
208    fn tariff(elements: Vec<TariffElement>) -> Tariff {
209        Tariff::builder()
210            .country_code("DE")
211            .party_id("ALL")
212            .id("1")
213            .currency("EUR")
214            .elements(elements)
215            .tax_included(TaxIncluded::No)
216            .last_updated(dt("2015-06-29T20:39:09Z"))
217            .build()
218    }
219
220    fn element(components: Vec<PriceComponent>) -> TariffElement {
221        TariffElement::builder().price_components(components).build()
222    }
223
224    fn restricted(components: Vec<PriceComponent>, r: TariffRestrictions) -> TariffElement {
225        TariffElement::builder().price_components(components).restrictions(r).build()
226    }
227
228    // ---------------------------------------------------------------------------------------
229    // The specification's own worked examples.
230    // ---------------------------------------------------------------------------------------
231
232    #[test]
233    fn spec_example_simple_025_per_kwh() {
234        // "This tariff will result in costs of € 5.00 (excl. VAT) or € 5.50 (incl. VAT) when
235        //  20 kWh are charged."
236        let t = tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", Some("10.0"), 1)])]);
237        let session =
238            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
239                energy_kwh: n("20"),
240                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
241            });
242        let b = PricingEngine::new().price(&session, &[t]).unwrap();
243        assert_eq!(b.total_excl_vat, n("5.00"));
244        assert_eq!(b.total_incl_vat, n("5.50"));
245    }
246
247    #[test]
248    fn spec_example_energy_plus_start_fee() {
249        // "Start fee € 0.50 excl. VAT with 20% VAT; energy € 0.25/kWh with 10% VAT.
250        //  20 kWh → € 5.50 (excl. VAT) or € 6.10 (incl. VAT)."
251        let t = tariff(vec![element(vec![
252            component(TariffDimensionType::Flat, "0.50", Some("20.0"), 1),
253            component(TariffDimensionType::Energy, "0.25", Some("10.0"), 1),
254        ])]);
255        let session =
256            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
257                energy_kwh: n("20"),
258                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
259            });
260        let b = PricingEngine::new().price(&session, &[t]).unwrap();
261        assert_eq!(b.total_excl_vat, n("5.50"));
262        assert_eq!(b.total_incl_vat, n("6.10"));
263        assert_eq!(b.dimension_total(TariffDimensionType::Flat), n("0.50"));
264    }
265
266    #[test]
267    fn spec_example_flat_fee_is_charged_once_for_the_whole_session() {
268        let t = tariff(vec![element(vec![
269            component(TariffDimensionType::Flat, "0.50", None, 1),
270            component(TariffDimensionType::Energy, "0.25", None, 1),
271        ])]);
272        let session = PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc())
273            .with_period(PricedPeriod {
274                energy_kwh: n("10"),
275                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
276            })
277            .with_period(PricedPeriod {
278                energy_kwh: n("10"),
279                ..PricedPeriod::new(dt("2024-01-15T11:00:00Z"))
280            });
281        let b = PricingEngine::new().price(&session, &[t]).unwrap();
282        assert_eq!(b.dimension_total(TariffDimensionType::Flat), n("0.50"), "not 1.00");
283        assert_eq!(b.total_excl_vat, n("5.50"));
284    }
285
286    #[test]
287    fn spec_example_energy_step_size_rounds_the_session_total_once() {
288        // "Energy costs € 0.20 per kWh before 17:00 and € 0.27 per kWh after 17:00. Both Price
289        //  Components have a step_size of 500 Wh. If a driver charges 4.3 kWh before 17:00 and
290        //  1.1 kWh after 17:00, a total of 5.4 kWh is charged. The step_size rounds this up to
291        //  5.5 kWh total. It does NOT round the energy used after 17:00 to 1.5 kWh."
292        let t = tariff(vec![
293            restricted(
294                vec![component(TariffDimensionType::Energy, "0.20", None, 500)],
295                TariffRestrictions { end_time: Some("17:00".parse().unwrap()), ..Default::default() },
296            ),
297            element(vec![component(TariffDimensionType::Energy, "0.27", None, 500)]),
298        ]);
299        let session = PricedSession::new(dt("2024-01-15T15:00:00Z"), TimeZone::utc())
300            .with_period(PricedPeriod {
301                energy_kwh: n("4.3"),
302                ..PricedPeriod::new(dt("2024-01-15T15:00:00Z"))
303            })
304            .with_period(PricedPeriod {
305                energy_kwh: n("1.1"),
306                ..PricedPeriod::new(dt("2024-01-15T17:30:00Z"))
307            });
308        let b = PricingEngine::new().price(&session, &[t]).unwrap();
309        let energy = b.dimension(TariffDimensionType::Energy).unwrap();
310        assert_eq!(energy.measured, n("5.4"));
311        assert_eq!(energy.billed, n("5.5"), "the session total is rounded, once");
312        // 4.3 @ 0.20 + 1.2 @ 0.27 = 0.86 + 0.324 = 1.184
313        assert_eq!(energy.segments[1].quantity, n("1.2"), "the surplus lands in the last segment");
314        assert_eq!(b.total_excl_vat, n("1.18"));
315    }
316
317    #[test]
318    fn charging_time_in_a_reserved_period_is_not_billed_at_the_reservation_rate() {
319        // A ChargingPeriod may carry TIME and RESERVATION_TIME at once. The two are priced by
320        // different Tariff Elements — one restricted with `reservation`, one not — so treating
321        // the whole period as "a reservation" would bill the charging minutes at the
322        // reservation rate, which is usually the more expensive of the two.
323        let t = tariff(vec![
324            restricted(
325                vec![component(TariffDimensionType::Time, "4.00", None, 1)],
326                TariffRestrictions {
327                    reservation: Some(crate::v2_3_0::tariffs::ReservationRestrictionType::Reservation),
328                    ..Default::default()
329                },
330            ),
331            element(vec![component(TariffDimensionType::Time, "1.00", None, 1)]),
332        ]);
333        let session =
334            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
335                charging_hours: n("1"),
336                reservation_hours: n("0.5"),
337                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
338            });
339
340        let b = PricingEngine::new().price(&session, &[t]).unwrap();
341        // 1 h charging @ 1.00 + 0.5 h reserved @ 4.00 = 3.00, not 1.5 h @ 4.00 = 6.00.
342        assert_eq!(b.total_excl_vat, n("3.00"));
343
344        let time = b.dimension(TariffDimensionType::Time).unwrap();
345        assert_eq!(time.segments.len(), 2, "the two are separate segments in the audit trail");
346        assert_eq!(time.segments[0].price, n("1.00"));
347        assert_eq!(time.segments[1].price, n("4.00"));
348        // They still share the TIME dimension, and so the one step_size budget the spec allows.
349        assert_eq!(time.measured, n("1.5"));
350    }
351
352    /// Every breakdown this engine produces must be internally consistent.
353    ///
354    /// The tax lines are what a party files and what a partner checks; if they do not account for
355    /// the difference between the two totals, the document is not evidence of anything.
356    fn assert_taxes_add_up(b: &CostBreakdown) {
357        let summed: Number = b.taxes.iter().map(|t| t.amount).sum();
358        assert_eq!(
359            summed,
360            b.total_incl_vat - b.total_excl_vat,
361            "tax lines {:?} do not account for {} - {}",
362            b.taxes,
363            b.total_incl_vat,
364            b.total_excl_vat,
365        );
366    }
367
368    #[test]
369    fn a_minimum_price_carries_its_tax_with_it() {
370        // A €0.50 session with 21% VAT under a €5.00 minimum. Charging €5.00 net and remitting no
371        // VAT is not a thing a party may do, and tax lines describing the €0.50 that was actually
372        // metered would not add up to the totals printed beside them.
373        let mut t =
374            tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", Some("21"), 1)])]);
375        t.min_price = Some(PriceLimit {
376            before_taxes: n("5.00"),
377            after_taxes: None,
378            extensions: crate::types::Extensions::new(),
379        });
380        let session =
381            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
382                energy_kwh: n("2"),
383                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
384            });
385
386        let b = PricingEngine::new().price(&session, &[t]).unwrap();
387        assert_eq!(b.total_excl_vat, n("5.00"));
388        assert_eq!(b.total_incl_vat, n("6.05"), "21% of the clamped base, not of the metered one");
389        assert_taxes_add_up(&b);
390        assert_eq!(b.taxes.len(), 1);
391        assert_eq!(b.taxes[0].percentage, Some(n("21")));
392        assert_eq!(b.limit_applied, Some(PriceLimitApplied::Minimum));
393        assert_eq!(b.notes_with(PricingNoteCode::TotalClamped).count(), 1);
394    }
395
396    #[test]
397    fn a_maximum_price_carries_its_tax_with_it_too() {
398        let mut t =
399            tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", Some("20"), 1)])]);
400        t.max_price = Some(PriceLimit {
401            before_taxes: n("10.00"),
402            after_taxes: None,
403            extensions: crate::types::Extensions::new(),
404        });
405        let session =
406            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
407                energy_kwh: n("100"),
408                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
409            });
410
411        let b = PricingEngine::new().price(&session, &[t]).unwrap();
412        assert_eq!(b.total_excl_vat, n("10.00"));
413        assert_eq!(b.total_incl_vat, n("12.00"));
414        assert_taxes_add_up(&b);
415    }
416
417    #[test]
418    fn several_vat_rates_keep_their_proportions_through_a_clamp() {
419        // A start fee at one rate and energy at another: the clamp must not silently pick one.
420        let mut t = tariff(vec![element(vec![
421            component(TariffDimensionType::Flat, "1.00", Some("20"), 0),
422            component(TariffDimensionType::Energy, "0.25", Some("10"), 1),
423        ])]);
424        t.max_price = Some(PriceLimit {
425            before_taxes: n("3.00"),
426            after_taxes: None,
427            extensions: crate::types::Extensions::new(),
428        });
429        let session =
430            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
431                energy_kwh: n("40"),
432                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
433            });
434
435        let b = PricingEngine::new().price(&session, &[t]).unwrap();
436        assert_eq!(b.total_excl_vat, n("3.00"));
437        assert_taxes_add_up(&b);
438        assert_eq!(b.taxes.len(), 2, "both rates survive the clamp: {:?}", b.taxes);
439        assert!(b.taxes.iter().all(|t| t.percentage.is_some()));
440    }
441
442    #[test]
443    fn tax_that_no_rate_explains_is_reported_rather_than_invented() {
444        // A `min_price.after_taxes` above a session whose components named no VAT at all. The
445        // amount is a fact; the rate is not knowable, and making one up would be a lie in a
446        // document somebody files.
447        let mut t = tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", None, 1)])]);
448        t.min_price = Some(PriceLimit {
449            before_taxes: n("5.00"),
450            after_taxes: Some(n("6.00")),
451            extensions: crate::types::Extensions::new(),
452        });
453        let session =
454            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
455                energy_kwh: n("2"),
456                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
457            });
458
459        let b = PricingEngine::new().price(&session, &[t]).unwrap();
460        assert_eq!((b.total_excl_vat, b.total_incl_vat), (n("5.00"), n("6.00")));
461        assert_taxes_add_up(&b);
462        assert_eq!(b.taxes.len(), 1);
463        assert_eq!(b.taxes[0].percentage, None, "the amount is known, the rate is not");
464        assert_eq!(b.notes_with(PricingNoteCode::UnattributedTax).count(), 1);
465    }
466
467    #[test]
468    fn a_charging_period_that_outlasts_its_price_is_reported() {
469        // "A CPO SHALL at least start (and add) a ChargingPeriod every moment/event that has
470        //  relevance for the total costs of a CDR. … When an energy changes in price after 17:00.
471        //  The CPO has to start a new Charging Period at 17:00."
472        //
473        // One period 16:50 -> 17:30 with 10 kWh: the CPO should have split it at 17:00. Nothing
474        // in the period says how the energy divides, so it is billed at the earlier rate — and
475        // the reader is told, because that is the finding a reconciliation exists to produce.
476        let t = tariff(vec![
477            restricted(
478                vec![component(TariffDimensionType::Energy, "0.40", None, 1)],
479                TariffRestrictions { start_time: Some("17:00".parse().unwrap()), ..Default::default() },
480            ),
481            element(vec![component(TariffDimensionType::Energy, "0.20", None, 1)]),
482        ]);
483        let session = PricedSession::new(dt("2024-01-15T16:50:00Z"), TimeZone::utc())
484            .with_period(PricedPeriod {
485                energy_kwh: n("10"),
486                ..PricedPeriod::new(dt("2024-01-15T16:50:00Z"))
487            })
488            .ending(dt("2024-01-15T17:30:00Z"));
489
490        let b = PricingEngine::new().price(&session, &[t]).unwrap();
491        assert_eq!(b.total_excl_vat, n("2.00"), "billed at the rate that applied when it began");
492        assert!(b.needs_review());
493        let spans: Vec<_> = b.notes_with(PricingNoteCode::PeriodSpansPriceChange).collect();
494        assert_eq!(spans.len(), 1, "{:?}", b.notes);
495        assert_eq!(spans[0].at, Some(dt("2024-01-15T16:50:00Z")));
496        assert!(spans[0].message.contains("ENERGY"), "{}", spans[0].message);
497    }
498
499    #[test]
500    fn a_period_that_ends_exactly_on_the_boundary_is_not_reported() {
501        // A period is half-open. One running up to exactly 17:00 does not span 17:00, and a false
502        // positive here would train everybody to ignore the note.
503        let t = tariff(vec![
504            restricted(
505                vec![component(TariffDimensionType::Energy, "0.40", None, 1)],
506                TariffRestrictions { start_time: Some("17:00".parse().unwrap()), ..Default::default() },
507            ),
508            element(vec![component(TariffDimensionType::Energy, "0.20", None, 1)]),
509        ]);
510        let session = PricedSession::new(dt("2024-01-15T16:50:00Z"), TimeZone::utc())
511            .with_period(PricedPeriod { energy_kwh: n("5"), ..PricedPeriod::new(dt("2024-01-15T16:50:00Z")) })
512            .with_period(PricedPeriod { energy_kwh: n("5"), ..PricedPeriod::new(dt("2024-01-15T17:00:00Z")) })
513            .ending(dt("2024-01-15T17:30:00Z"));
514
515        let b = PricingEngine::new().price(&session, &[t]).unwrap();
516        assert_eq!(b.total_excl_vat, n("3.00"), "5 kWh at 0.20 then 5 at 0.40");
517        assert!(!b.needs_review(), "a well-formed CDR needs no review: {:?}", b.notes);
518    }
519
520    #[test]
521    fn periods_given_out_of_order_are_reported() {
522        // Charging periods arrive from a CSMS through a CPO's own aggregation, and a merge that
523        // loses the sort is invisible in every field-by-field check: every period is valid.
524        let t = tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", None, 1)])]);
525        let session = PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc())
526            .with_period(PricedPeriod { energy_kwh: n("1"), ..PricedPeriod::new(dt("2024-01-15T11:00:00Z")) })
527            .with_period(PricedPeriod {
528                energy_kwh: n("1"),
529                ..PricedPeriod::new(dt("2024-01-15T10:30:00Z"))
530            });
531
532        let b = PricingEngine::new().price(&session, &[t]).unwrap();
533        assert_eq!(b.total_excl_vat, n("0.50"), "the quantities are all there, so it still prices");
534        let out_of_order: Vec<_> = b.notes_with(PricingNoteCode::PeriodsOutOfOrder).collect();
535        assert_eq!(out_of_order.len(), 1, "{:?}", b.notes);
536        assert_eq!(out_of_order[0].at, Some(dt("2024-01-15T10:30:00Z")));
537    }
538
539    #[test]
540    fn a_tariff_that_describes_negative_tax_does_not_produce_a_negative_bill() {
541        // A VAT percentage below zero is malformed — `Tariff::validate` says so — but the engine
542        // does not require validated input, and "costs less with tax than without" is not a
543        // document anybody can use.
544        let t = tariff(vec![element(vec![component(TariffDimensionType::Energy, "1.00", Some("-20"), 1)])]);
545        assert!(crate::types::Validate::validate(&t).is_err(), "the tariff is reported as malformed");
546
547        let session =
548            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
549                energy_kwh: n("10"),
550                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
551            });
552        let b = PricingEngine::new().price(&session, &[t]).unwrap();
553
554        assert_eq!(b.total_excl_vat, n("10.00"));
555        assert_eq!(b.total_incl_vat, n("10.00"), "held at the exclusive total, not 8.00");
556        assert_eq!(b.total_vat(), Number::ZERO);
557        assert_eq!(b.notes_with(PricingNoteCode::NegativeTax).count(), 1, "{:?}", b.notes);
558    }
559
560    #[test]
561    fn a_breakdown_survives_being_written_down() {
562        // A cost breakdown is an audit artefact: it gets stored, sent to a partner, and shown to
563        // a driver disputing an invoice. If a quantity in it does not survive a JSON round-trip,
564        // the copy the driver sees is not the one the engine computed.
565        let t = tariff(vec![element(vec![
566            component(TariffDimensionType::Time, "1.00", Some("21"), 600),
567            component(TariffDimensionType::ParkingTime, "2.00", Some("21"), 600),
568            component(TariffDimensionType::Energy, "0.25", Some("21"), 1),
569        ])]);
570        let session = PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc())
571            .with_period(PricedPeriod {
572                charging_hours: minutes(7),
573                energy_kwh: n("1") / n("3"),
574                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
575            })
576            .with_period(PricedPeriod {
577                parking_hours: minutes(16),
578                ..PricedPeriod::new(dt("2024-01-15T10:07:00Z"))
579            });
580        let breakdown = PricingEngine::new().price(&session, &[t]).unwrap();
581
582        for dimension in &breakdown.dimensions {
583            assert!(
584                dimension.measured.json_round_trips(),
585                "{} measured {}",
586                dimension.dimension,
587                dimension.measured
588            );
589            assert!(
590                dimension.billed.json_round_trips(),
591                "{} billed {}",
592                dimension.dimension,
593                dimension.billed
594            );
595            for segment in &dimension.segments {
596                assert!(segment.quantity.json_round_trips(), "quantity {}", segment.quantity);
597                assert!(segment.cost.json_round_trips(), "cost {}", segment.cost);
598            }
599        }
600        let json = serde_json::to_string(&breakdown).unwrap();
601        let back: CostBreakdown = serde_json::from_str(&json).unwrap();
602        assert_eq!(back, breakdown, "the stored copy is the computed one");
603    }
604
605    #[test]
606    fn spec_example_parking_absorbs_the_time_rounding() {
607        // "Time spent charging costs € 1.00 per hour and time spent parking € 2.00 per hour.
608        //  Both have a step_size of 10 minutes. If a driver charges 21 minutes, and keeps his EV
609        //  connected while it is full for another 16 minutes, then the step_size rounds the
610        //  parking duration up to 20 minutes … Note that the charging duration is not rounded up,
611        //  as it is followed by another time based period."
612        let t = tariff(vec![element(vec![
613            component(TariffDimensionType::Time, "1.00", None, 600),
614            component(TariffDimensionType::ParkingTime, "2.00", None, 600),
615        ])]);
616        let session = PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc())
617            .with_period(PricedPeriod {
618                charging_hours: minutes(21),
619                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
620            })
621            .with_period(PricedPeriod {
622                parking_hours: minutes(16),
623                ..PricedPeriod::new(dt("2024-01-15T10:21:00Z"))
624            });
625        let b = PricingEngine::new().price(&session, &[t]).unwrap();
626        let charging = b.dimension(TariffDimensionType::Time).unwrap();
627        let parking = b.dimension(TariffDimensionType::ParkingTime).unwrap();
628        assert_eq!(charging.billed, charging.measured, "charging is not rounded");
629        assert_close(parking.billed, minutes(20));
630        // 21/60 * 1.00 + 20/60 * 2.00 = 0.35 + 0.6667
631        assert_eq!(b.total_excl_vat, n("1.02"));
632    }
633
634    #[test]
635    fn spec_example_time_alone_is_rounded_with_the_last_step_size() {
636        // "An EV driver plugs in at 16:35 and charges for 35 minutes. … the total charging time is
637        //  rounded up from 35 to 45 minutes. … 25 minutes @ 1.20/h = 0.50, 20 minutes @ 2.40/h =
638        //  0.80. Total 1.30."
639        let t = tariff(vec![
640            restricted(
641                vec![component(TariffDimensionType::Time, "1.20", None, 1800)],
642                TariffRestrictions { end_time: Some("17:00".parse().unwrap()), ..Default::default() },
643            ),
644            element(vec![component(TariffDimensionType::Time, "2.40", None, 900)]),
645        ]);
646        let session = PricedSession::new(dt("2024-01-15T16:35:00Z"), TimeZone::utc())
647            .with_period(PricedPeriod {
648                charging_hours: minutes(25),
649                ..PricedPeriod::new(dt("2024-01-15T16:35:00Z"))
650            })
651            .with_period(PricedPeriod {
652                charging_hours: minutes(10),
653                ..PricedPeriod::new(dt("2024-01-15T17:00:00Z"))
654            });
655        let b = PricingEngine::new().price(&session, &[t]).unwrap();
656        let time = b.dimension(TariffDimensionType::Time).unwrap();
657        assert_close(time.measured, minutes(35));
658        assert_close(time.billed, minutes(45));
659        assert_close(time.segments[1].quantity, minutes(20));
660        assert_eq!(b.total_excl_vat, n("1.30"));
661    }
662
663    #[test]
664    fn spec_example_min_price_raises_a_cheap_session() {
665        // "if less than 2 kWh is charged, € 0.50 (excl. VAT) or € 0.55 (incl. VAT) will be billed."
666        let mut t =
667            tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", Some("10.0"), 1)])]);
668        t.min_price = Some(PriceLimit {
669            before_taxes: n("0.50"),
670            after_taxes: Some(n("0.55")),
671            extensions: crate::types::Extensions::new(),
672        });
673        let session =
674            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
675                energy_kwh: n("1"),
676                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
677            });
678        let b = PricingEngine::new().price(&session, &[t.clone()]).unwrap();
679        assert_eq!(b.total_excl_vat, n("0.50"));
680        assert_eq!(b.total_incl_vat, n("0.55"));
681        assert_eq!(b.limit_applied, Some(PriceLimitApplied::Minimum));
682
683        // 20 kWh is above the minimum, so it is billed normally.
684        let big = PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
685            energy_kwh: n("20"),
686            ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
687        });
688        let b = PricingEngine::new().price(&big, &[t]).unwrap();
689        assert_eq!(b.total_excl_vat, n("5.00"));
690        assert_eq!(b.limit_applied, None);
691    }
692
693    #[test]
694    fn spec_example_max_price_caps_an_expensive_session() {
695        // "For a charging session where 50 kWh are charged, this tariff will result in costs of
696        //  € 10.00 (excl. VAT) or € 11.00 (incl. VAT) due to the price limit."
697        let mut t = tariff(vec![element(vec![
698            component(TariffDimensionType::Flat, "0.50", Some("20.0"), 1),
699            component(TariffDimensionType::Energy, "0.25", Some("10.0"), 1),
700        ])]);
701        t.max_price = Some(PriceLimit {
702            before_taxes: n("10.00"),
703            after_taxes: Some(n("11.00")),
704            extensions: crate::types::Extensions::new(),
705        });
706        let session =
707            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
708                energy_kwh: n("50"),
709                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
710            });
711        let b = PricingEngine::new().price(&session, &[t.clone()]).unwrap();
712        assert_eq!(b.total_excl_vat, n("10.00"));
713        assert_eq!(b.total_incl_vat, n("11.00"));
714        assert_eq!(b.limit_applied, Some(PriceLimitApplied::Maximum));
715
716        // "If only 30 kWh were charged, the costs would be € 8.00 (excl. VAT) and € 8.85 (incl.)"
717        let smaller =
718            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
719                energy_kwh: n("30"),
720                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
721            });
722        let b = PricingEngine::new().price(&smaller, &[t]).unwrap();
723        assert_eq!(b.total_excl_vat, n("8.00"));
724        assert_eq!(b.total_incl_vat, n("8.85"));
725    }
726
727    #[test]
728    fn spec_example_max_power_restrictions_switch_the_rate() {
729        // "1 kWh at 6 kW: € 0.20; 40 kWh at 48 kW: € 20.00; 0.5 kWh at 4 kW: € 0.10 → € 20.30"
730        let t = tariff(vec![
731            restricted(
732                vec![component(TariffDimensionType::Energy, "0.20", None, 1)],
733                TariffRestrictions { max_power: Some(n("16")), ..Default::default() },
734            ),
735            restricted(
736                vec![component(TariffDimensionType::Energy, "0.35", None, 1)],
737                TariffRestrictions { max_power: Some(n("32")), ..Default::default() },
738            ),
739            element(vec![component(TariffDimensionType::Energy, "0.50", None, 1)]),
740        ]);
741        let period = |start: &str, kwh: &str, power: &str| PricedPeriod {
742            energy_kwh: n(kwh),
743            max_power_kw: Some(n(power)),
744            min_power_kw: Some(n(power)),
745            ..PricedPeriod::new(dt(start))
746        };
747        let session = PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc())
748            .with_period(period("2024-01-15T10:00:00Z", "1", "6"))
749            .with_period(period("2024-01-15T10:10:00Z", "40", "48"))
750            .with_period(period("2024-01-15T11:00:00Z", "0.5", "4"));
751        let b = PricingEngine::new().price(&session, &[t]).unwrap();
752        assert_eq!(b.total_excl_vat, n("20.30"));
753    }
754
755    #[test]
756    fn spec_example_max_duration_makes_the_first_half_hour_free() {
757        // "First 30 minutes of charging is free; € 0.25/kWh after 30 minutes; € 0.40/kWh after 60.
758        //  5 kWh free + 1.2 kWh at 0.25 = € 0.30."
759        let t = tariff(vec![
760            restricted(
761                vec![component(TariffDimensionType::Energy, "0.00", None, 1)],
762                TariffRestrictions { max_duration: Some(1800), ..Default::default() },
763            ),
764            restricted(
765                vec![component(TariffDimensionType::Energy, "0.25", None, 1)],
766                TariffRestrictions { max_duration: Some(3600), ..Default::default() },
767            ),
768            element(vec![component(TariffDimensionType::Energy, "0.40", None, 1)]),
769        ]);
770        let session = PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc())
771            .with_period(PricedPeriod { energy_kwh: n("5"), ..PricedPeriod::new(dt("2024-01-15T10:00:00Z")) })
772            .with_period(PricedPeriod {
773                energy_kwh: n("1.2"),
774                ..PricedPeriod::new(dt("2024-01-15T10:30:00Z"))
775            });
776        let b = PricingEngine::new().price(&session, &[t]).unwrap();
777        assert_eq!(b.total_excl_vat, n("0.30"));
778    }
779
780    #[test]
781    fn a_reservation_is_priced_by_its_own_tariff_element() {
782        // "Reservation € 5.00 per hour (excl. VAT) billed per minute; start fee € 0.50;
783        //  energy € 0.25/kWh. A session started 15 minutes after the reservation, 20 kWh:
784        //  € 6.75 excl. VAT."
785        let t = tariff(vec![
786            restricted(
787                vec![component(TariffDimensionType::Time, "5.00", Some("20.0"), 60)],
788                TariffRestrictions {
789                    reservation: Some(crate::v2_3_0::tariffs::ReservationRestrictionType::Reservation),
790                    ..Default::default()
791                },
792            ),
793            element(vec![
794                component(TariffDimensionType::Flat, "0.50", Some("20.0"), 1),
795                component(TariffDimensionType::Energy, "0.25", Some("10.0"), 1),
796            ]),
797        ]);
798        let session = PricedSession::new(dt("2024-01-15T09:45:00Z"), TimeZone::utc())
799            .with_period(PricedPeriod {
800                reservation_hours: minutes(15),
801                ..PricedPeriod::new(dt("2024-01-15T09:45:00Z"))
802            })
803            .with_period(PricedPeriod {
804                energy_kwh: n("20"),
805                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
806            });
807        let b = PricingEngine::new().price(&session, &[t]).unwrap();
808        assert_eq!(b.total_excl_vat, n("6.75"));
809        assert_eq!(b.total_incl_vat, n("7.60"));
810    }
811
812    #[test]
813    fn a_free_of_charge_tariff_costs_nothing() {
814        let t = tariff(vec![element(vec![component(TariffDimensionType::Flat, "0.00", None, 0)])]);
815        assert!(t.is_free_of_charge());
816        let session =
817            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
818                energy_kwh: n("20"),
819                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
820            });
821        let b = PricingEngine::new().price(&session, &[t]).unwrap();
822        assert_eq!(b.total_excl_vat, Number::ZERO);
823        assert!(!b.notes.is_empty(), "the unpriced ENERGY is surfaced as a note");
824    }
825
826    #[test]
827    fn local_time_restrictions_follow_the_locations_time_zone() {
828        // 17:00 in Berlin is 15:00 UTC in summer and 16:00 UTC in winter. A session at 15:30 UTC
829        // in July is after 17:00 local, and the same UTC time in January is not.
830        let t = tariff(vec![
831            restricted(
832                vec![component(TariffDimensionType::Energy, "0.20", None, 1)],
833                TariffRestrictions { end_time: Some("17:00".parse().unwrap()), ..Default::default() },
834            ),
835            element(vec![component(TariffDimensionType::Energy, "0.40", None, 1)]),
836        ]);
837        let berlin = TimeZone::named("Europe/Berlin").unwrap();
838        let price_at = |instant: &str| {
839            let session = PricedSession::new(dt(instant), berlin.clone())
840                .with_period(PricedPeriod { energy_kwh: n("1"), ..PricedPeriod::new(dt(instant)) });
841            PricingEngine::new().price(&session, std::slice::from_ref(&t)).unwrap().total_excl_vat
842        };
843        assert_eq!(price_at("2024-07-15T15:30:00Z"), n("0.40"), "17:30 CEST is after 17:00");
844        assert_eq!(price_at("2024-01-15T15:30:00Z"), n("0.20"), "16:30 CET is before 17:00");
845    }
846
847    #[test]
848    fn day_of_week_restrictions_use_local_days() {
849        use crate::v2_3_0::tariffs::DayOfWeek;
850        let t = tariff(vec![
851            restricted(
852                vec![component(TariffDimensionType::Energy, "0.10", None, 1)],
853                TariffRestrictions {
854                    day_of_week: vec![DayOfWeek::Saturday, DayOfWeek::Sunday],
855                    ..Default::default()
856                },
857            ),
858            element(vec![component(TariffDimensionType::Energy, "0.30", None, 1)]),
859        ]);
860        let price_on = |instant: &str| {
861            let session = PricedSession::new(dt(instant), TimeZone::utc())
862                .with_period(PricedPeriod { energy_kwh: n("1"), ..PricedPeriod::new(dt(instant)) });
863            PricingEngine::new().price(&session, std::slice::from_ref(&t)).unwrap().total_excl_vat
864        };
865        assert_eq!(price_on("2024-01-13T10:00:00Z"), n("0.10"), "a Saturday");
866        assert_eq!(price_on("2024-01-15T10:00:00Z"), n("0.30"), "a Monday");
867    }
868
869    #[test]
870    fn the_breakdown_names_the_component_that_priced_each_segment() {
871        let t = tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", None, 1)])]);
872        let session =
873            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
874                energy_kwh: n("20"),
875                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
876            });
877        let b = PricingEngine::new().price(&session, &[t]).unwrap();
878        let applied: Vec<_> = b.applied_components().collect();
879        assert_eq!(applied.len(), 1);
880        assert_eq!(applied[0].tariff_id, "1");
881        assert_eq!(applied[0].element_index, 0);
882        assert_eq!(applied[0].component_index, 0);
883        assert!(applied[0].because.contains("local"), "{}", applied[0].because);
884    }
885
886    #[test]
887    fn a_period_can_name_which_tariff_applies_to_it() {
888        let cheap = Tariff {
889            id: "cheap".parse().unwrap(),
890            ..tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.10", None, 1)])])
891        };
892        let dear = Tariff {
893            id: "dear".parse().unwrap(),
894            ..tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.90", None, 1)])])
895        };
896        let session =
897            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
898                energy_kwh: n("1"),
899                tariff_id: Some("dear".to_owned()),
900                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
901            });
902        let b = PricingEngine::new().price(&session, &[cheap.clone(), dear]).unwrap();
903        assert_eq!(b.total_excl_vat, n("0.90"));
904
905        let missing =
906            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
907                energy_kwh: n("1"),
908                tariff_id: Some("nope".to_owned()),
909                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
910            });
911        assert_eq!(
912            PricingEngine::new().price(&missing, &[cheap]).unwrap_err(),
913            PricingError::UnknownTariff("nope".to_owned())
914        );
915    }
916
917    #[test]
918    fn the_result_converts_to_a_price_of_either_version() {
919        let t = tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", Some("10.0"), 1)])]);
920        let session =
921            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
922                energy_kwh: n("20"),
923                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
924            });
925        let b = PricingEngine::new().price(&session, &[t]).unwrap();
926        let new = b.to_price_v2_3_0();
927        assert_eq!(new.before_taxes, n("5.00"));
928        assert_eq!(new.after_taxes(), n("5.50"));
929        let old = b.to_price_v2_2_1();
930        assert_eq!((old.excl_vat, old.incl_vat.unwrap()), (n("5.00"), n("5.50")));
931    }
932
933    #[test]
934    fn disabling_step_size_bills_the_measured_quantity() {
935        let t = tariff(vec![element(vec![component(TariffDimensionType::Energy, "0.25", None, 500)])]);
936        let session =
937            PricedSession::new(dt("2024-01-15T10:00:00Z"), TimeZone::utc()).with_period(PricedPeriod {
938                energy_kwh: n("5.4"),
939                ..PricedPeriod::new(dt("2024-01-15T10:00:00Z"))
940            });
941        let with_steps = PricingEngine::new().price(&session, std::slice::from_ref(&t)).unwrap();
942        assert_eq!(with_steps.dimension(TariffDimensionType::Energy).unwrap().billed, n("5.5"));
943
944        let ocpi_3_style = PricingEngine::with_policy(PricingPolicy::default().without_step_size());
945        let exact = ocpi_3_style.price(&session, &[t]).unwrap();
946        assert_eq!(exact.dimension(TariffDimensionType::Energy).unwrap().billed, n("5.4"));
947        assert_eq!(exact.total_excl_vat, n("1.35"));
948    }
949
950    #[test]
951    fn an_unknown_time_zone_is_an_error_not_a_silent_utc() {
952        assert!(matches!(TimeZone::named("Mars/Olympus"), Err(PricingError::TimeZone(_))));
953        assert_eq!(TimeZone::named("UTC").unwrap(), TimeZone::utc());
954    }
955}