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;
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<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#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
145#[non_exhaustive]
146pub enum PricingError {
147 #[error("no tariff to price against")]
149 NoTariff,
150 #[error("charging period refers to tariff {0:?}, which was not provided")]
152 UnknownTariff(String),
153 #[error("no tariff is active at {0}")]
155 NoActiveTariff(DateTime),
156 #[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 fn minutes(count: u32) -> Number {
179 Number::from(count) / Number::from(60u32)
180 }
181
182 #[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 #[test]
233 fn spec_example_simple_025_per_kwh() {
234 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 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 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 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 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 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 assert_eq!(time.measured, n("1.5"));
350 }
351
352 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}