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