1mod 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#[derive(Clone)]
75pub struct TimeZone {
76 name: String,
77 zone: Option<tz::TimeZoneRef<'static>>,
78}
79
80impl TimeZone {
81 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 #[must_use]
97 pub fn utc() -> Self {
98 Self { name: "UTC".to_owned(), zone: None }
99 }
100
101 #[must_use]
103 pub fn name(&self) -> &str {
104 &self.name
105 }
106
107 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 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#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
144#[non_exhaustive]
145pub enum PricingError {
146 #[error("no tariff to price against")]
148 NoTariff,
149 #[error("charging period refers to tariff {0:?}, which was not provided")]
151 UnknownTariff(String),
152 #[error("no tariff is active at {0}")]
154 NoActiveTariff(DateTime),
155 #[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 fn minutes(count: u32) -> Number {
178 Number::from(count) / Number::from(60u32)
179 }
180
181 #[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 #[test]
232 fn spec_example_simple_025_per_kwh() {
233 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 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 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 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 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 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 assert_eq!(time.measured, n("1.5"));
349 }
350
351 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}