1use bon::Builder;
16use serde::{Deserialize, Serialize};
17
18use crate::ocpi_enum;
19use crate::types::validate_fields;
20use crate::types::{
21 CiString, CountryCode, Currency, DateTime, DisplayText, Extensions, LocalDate, LocalTime, Number,
22 PartyId, PartyRef, Url, Validate, Validator, ViolationCode,
23};
24
25use super::locations::EnergyMix;
26
27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
35#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
36#[builder(on(_, into))]
37pub struct Tariff {
38 pub country_code: CountryCode,
40 pub party_id: PartyId,
42 pub id: CiString<36>,
44 pub currency: Currency,
46 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
48 pub tariff_type: Option<TariffType>,
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 #[builder(default)]
56 pub tariff_alt_text: Vec<DisplayText>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub tariff_alt_url: Option<Url>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub min_price: Option<PriceLimit>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub max_price: Option<PriceLimit>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub preauthorize_amount: Option<Number>,
70 pub elements: Vec<TariffElement>,
72 pub tax_included: TaxIncluded,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub start_date_time: Option<DateTime>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub end_date_time: Option<DateTime>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub energy_mix: Option<EnergyMix>,
83 pub last_updated: DateTime,
85 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
87 #[builder(default)]
88 pub extensions: Extensions,
89}
90
91impl Tariff {
92 #[must_use]
94 pub fn owner_party(&self) -> PartyRef {
95 PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
96 }
97
98 #[must_use]
103 pub fn is_active_at(&self, instant: DateTime) -> bool {
104 self.start_date_time.is_none_or(|s| instant >= s) && self.end_date_time.is_none_or(|e| instant < e)
105 }
106
107 #[must_use]
113 pub fn is_free_of_charge(&self) -> bool {
114 match self.elements.as_slice() {
115 [element] if element.restrictions.is_none() => match element.price_components.as_slice() {
116 [pc] => pc.component_type == TariffDimensionType::Flat && pc.price.is_zero(),
117 _ => false,
118 },
119 _ => false,
120 }
121 }
122}
123
124impl Validate for Tariff {
125 fn validate_in(&self, v: &mut Validator) {
126 validate_fields!(
127 self, v, country_code, party_id, id, currency, tariff_type as "type", tariff_alt_text,
128 tariff_alt_url, min_price, max_price, preauthorize_amount, elements, tax_included,
129 start_date_time, end_date_time, energy_mix, last_updated,
130 );
131 if self.elements.is_empty() {
132 v.report_at(
133 "elements",
134 ViolationCode::EmptyRequiredList,
135 "a Tariff has cardinality `+` elements: at least one is required",
136 );
137 }
138 if let (Some(start), Some(end)) = (self.start_date_time, self.end_date_time)
139 && end <= start
140 {
141 v.report_at(
142 "end_date_time",
143 ViolationCode::Inconsistent,
144 "a tariff's validity window must be non-empty",
145 );
146 }
147 if let (Some(min), Some(max)) = (self.min_price.as_ref(), self.max_price.as_ref())
148 && max.before_taxes < min.before_taxes
149 {
150 v.report_at(
151 "max_price",
152 ViolationCode::Inconsistent,
153 "max_price.before_taxes is below min_price.before_taxes",
154 );
155 }
156 for (i, element) in self.elements.iter().enumerate() {
158 let Some(restrictions) = element.restrictions.as_ref() else { continue };
159 if restrictions.reservation.is_none() {
160 continue;
161 }
162 for (j, pc) in element.price_components.iter().enumerate() {
163 if !matches!(pc.component_type, TariffDimensionType::Flat | TariffDimensionType::Time) {
164 v.enter("elements");
165 v.enter(&i.to_string());
166 v.enter("price_components");
167 v.enter(&j.to_string());
168 v.report_at(
169 "type",
170 ViolationCode::Inconsistent,
171 format!(
172 "a reservation Tariff Element can only have FLAT and TIME dimensions, \
173 not {}",
174 pc.component_type
175 ),
176 );
177 v.leave();
178 v.leave();
179 v.leave();
180 v.leave();
181 }
182 }
183 }
184 }
185}
186
187#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
195#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
196#[builder(on(_, into))]
197pub struct TariffElement {
198 pub price_components: Vec<PriceComponent>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub restrictions: Option<TariffRestrictions>,
203 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
205 #[builder(default)]
206 pub extensions: Extensions,
207}
208
209impl TariffElement {
210 #[must_use]
212 pub fn component(&self, dimension: TariffDimensionType) -> Option<&PriceComponent> {
213 self.price_components.iter().find(|c| c.component_type == dimension)
214 }
215}
216
217impl Validate for TariffElement {
218 fn validate_in(&self, v: &mut Validator) {
219 validate_fields!(self, v, price_components, restrictions);
220 if self.price_components.is_empty() {
221 v.report_at(
222 "price_components",
223 ViolationCode::EmptyRequiredList,
224 "a TariffElement has cardinality `+` price_components: at least one is required",
225 );
226 }
227 let mut seen: Vec<TariffDimensionType> = Vec::new();
228 for pc in &self.price_components {
229 if seen.contains(&pc.component_type) {
230 v.report_at(
231 "price_components",
232 ViolationCode::Inconsistent,
233 format!(
234 "{} is priced twice in one Tariff Element; only one Price Component per \
235 dimension can be active at a time",
236 pc.component_type
237 ),
238 );
239 }
240 seen.push(pc.component_type);
241 }
242 }
243}
244
245#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
249#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
250#[builder(on(_, into))]
251pub struct PriceComponent {
252 #[serde(rename = "type")]
254 pub component_type: TariffDimensionType,
255 pub price: Number,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub vat: Option<Number>,
261 pub step_size: u32,
270 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
272 #[builder(default)]
273 pub extensions: Extensions,
274}
275
276impl PriceComponent {
277 #[must_use]
279 pub fn new(component_type: TariffDimensionType, price: Number) -> Self {
280 Self { component_type, price, vat: None, step_size: 1, extensions: Extensions::new() }
281 }
282}
283
284impl Validate for PriceComponent {
285 fn validate_in(&self, v: &mut Validator) {
286 validate_fields!(self, v, component_type as "type", price, vat);
287 if self.step_size == 0 && self.component_type.step_size_unit().is_some() {
291 v.report_at(
292 "step_size",
293 ViolationCode::OutOfRange,
294 format!(
295 "a step_size of 0 would bill no {}; the smallest meaningful value is 1",
296 self.component_type
297 ),
298 );
299 }
300 if self.vat.is_some_and(Number::is_negative) {
301 v.report_at("vat", ViolationCode::OutOfRange, "a VAT percentage cannot be negative");
302 }
303 }
304}
305
306#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
315pub struct PriceLimit {
316 pub before_taxes: Number,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub after_taxes: Option<Number>,
321 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
323 pub extensions: Extensions,
324}
325
326impl PriceLimit {
327 #[must_use]
329 pub fn before_taxes(amount: Number) -> Self {
330 Self { before_taxes: amount, after_taxes: None, extensions: Extensions::new() }
331 }
332}
333
334impl Validate for PriceLimit {
335 fn validate_in(&self, v: &mut Validator) {
336 validate_fields!(self, v, before_taxes, after_taxes);
337 if self.after_taxes.is_some_and(|a| a < self.before_taxes) {
338 v.report_at(
339 "after_taxes",
340 ViolationCode::Inconsistent,
341 "the amount including taxes cannot be lower than the amount excluding them",
342 );
343 }
344 }
345}
346
347#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Builder)]
354#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
355#[builder(on(_, into))]
356pub struct TariffRestrictions {
357 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub start_time: Option<LocalTime>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub end_time: Option<LocalTime>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub start_date: Option<LocalDate>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub end_date: Option<LocalDate>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub min_kwh: Option<Number>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub max_kwh: Option<Number>,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub min_current: Option<Number>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub max_current: Option<Number>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub min_power: Option<Number>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub max_power: Option<Number>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub min_duration: Option<u64>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub max_duration: Option<u64>,
396 #[serde(default, skip_serializing_if = "Vec::is_empty")]
398 #[builder(default)]
399 pub day_of_week: Vec<DayOfWeek>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub reservation: Option<ReservationRestrictionType>,
403 #[cfg(feature = "bookings")]
409 #[cfg_attr(docsrs, doc(cfg(feature = "bookings")))]
410 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub booking: Option<BookingRestrictionType>,
412 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
414 #[builder(default)]
415 pub extensions: Extensions,
416}
417
418impl TariffRestrictions {
419 #[must_use]
424 pub fn is_unrestricted(&self) -> bool {
425 self == &Self::default()
426 }
427
428 #[must_use]
430 pub const fn is_reservation(&self) -> bool {
431 self.reservation.is_some()
432 }
433}
434
435impl Validate for TariffRestrictions {
436 fn validate_in(&self, v: &mut Validator) {
437 validate_fields!(
438 self,
439 v,
440 start_time,
441 end_time,
442 start_date,
443 end_date,
444 min_kwh,
445 max_kwh,
446 min_current,
447 max_current,
448 min_power,
449 max_power,
450 day_of_week,
451 reservation,
452 );
453 for (lo_name, lo, hi_name, hi) in [
455 ("min_kwh", self.min_kwh, "max_kwh", self.max_kwh),
456 ("min_current", self.min_current, "max_current", self.max_current),
457 ("min_power", self.min_power, "max_power", self.max_power),
458 ] {
459 if let (Some(lo_v), Some(hi_v)) = (lo, hi)
460 && hi_v <= lo_v
461 {
462 v.report_at(
463 hi_name,
464 ViolationCode::Inconsistent,
465 format!("{hi_name} is not above {lo_name}, so this element can never apply"),
466 );
467 }
468 }
469 if let (Some(lo), Some(hi)) = (self.min_duration, self.max_duration)
470 && hi <= lo
471 {
472 v.report_at(
473 "max_duration",
474 ViolationCode::Inconsistent,
475 "max_duration is not above min_duration, so this element can never apply",
476 );
477 }
478 if let (Some(start), Some(end)) = (self.start_date, self.end_date)
479 && end <= start
480 {
481 v.report_at(
482 "end_date",
483 ViolationCode::Inconsistent,
484 "end_date is exclusive and must be after start_date",
485 );
486 }
487 let mut seen: Vec<DayOfWeek> = Vec::new();
488 for d in &self.day_of_week {
489 if seen.contains(d) {
490 v.report_at(
491 "day_of_week",
492 ViolationCode::Inconsistent,
493 format!("{d} is listed more than once"),
494 );
495 }
496 seen.push(*d);
497 }
498 }
499}
500
501ocpi_enum! {
502 pub enum DayOfWeek {
506 Monday = "MONDAY",
508 Tuesday = "TUESDAY",
510 Wednesday = "WEDNESDAY",
512 Thursday = "THURSDAY",
514 Friday = "FRIDAY",
516 Saturday = "SATURDAY",
518 Sunday = "SUNDAY",
520 }
521}
522
523impl DayOfWeek {
524 #[must_use]
528 pub const fn iso_number(self) -> u8 {
529 match self {
530 Self::Monday => 1,
531 Self::Tuesday => 2,
532 Self::Wednesday => 3,
533 Self::Thursday => 4,
534 Self::Friday => 5,
535 Self::Saturday => 6,
536 Self::Sunday => 7,
537 }
538 }
539
540 #[must_use]
542 pub const fn from_iso_number(n: u8) -> Option<Self> {
543 Some(match n {
544 1 => Self::Monday,
545 2 => Self::Tuesday,
546 3 => Self::Wednesday,
547 4 => Self::Thursday,
548 5 => Self::Friday,
549 6 => Self::Saturday,
550 7 => Self::Sunday,
551 _ => return None,
552 })
553 }
554}
555
556ocpi_enum! {
557 pub enum ReservationRestrictionType {
564 Reservation = "RESERVATION",
566 ReservationExpires = "RESERVATION_EXPIRES",
568 }
569}
570
571#[cfg(feature = "bookings")]
575ocpi_enum! {
576 #[cfg_attr(docsrs, doc(cfg(feature = "bookings")))]
580 pub enum BookingRestrictionType {
581 Booking = "BOOKING",
583 BookingExpires = "BOOKING_EXPIRES",
585 BookingCancellationFees = "BOOKING_CANCELLATION_FEES",
587 BookingOvertime = "BOOKING_OVERTIME",
589 }
590}
591
592ocpi_enum! {
593 pub enum TariffDimensionType {
597 Energy = "ENERGY",
599 Flat = "FLAT",
601 ParkingTime = "PARKING_TIME",
603 Time = "TIME",
608 }
609}
610
611impl TariffDimensionType {
612 #[must_use]
617 pub const fn step_size_unit(self) -> Option<&'static str> {
618 match self {
619 Self::Energy => Some("Wh"),
620 Self::ParkingTime | Self::Time => Some("s"),
621 Self::Flat => None,
622 }
623 }
624
625 #[must_use]
630 pub const fn is_time_based(self) -> bool {
631 matches!(self, Self::Time | Self::ParkingTime)
632 }
633}
634
635ocpi_enum! {
636 pub enum TariffType {
640 AdHocPayment = "AD_HOC_PAYMENT",
642 ProfileCheap = "PROFILE_CHEAP",
644 ProfileFast = "PROFILE_FAST",
646 ProfileGreen = "PROFILE_GREEN",
648 Regular = "REGULAR",
650 }
651}
652
653ocpi_enum! {
654 pub enum TaxIncluded {
661 Yes = "YES",
663 No = "NO",
665 NotApplicable = "N/A",
667 }
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673
674 fn tariff(elements: Vec<TariffElement>) -> Tariff {
675 Tariff::builder()
676 .country_code("DE")
677 .party_id("ALL")
678 .id("12")
679 .currency("EUR")
680 .elements(elements)
681 .tax_included(TaxIncluded::No)
682 .last_updated("2018-12-17T11:16:55Z".parse::<DateTime>().unwrap())
683 .build()
684 }
685
686 fn flat(price: &str) -> PriceComponent {
687 PriceComponent::new(TariffDimensionType::Flat, price.parse().unwrap())
688 }
689
690 #[test]
691 fn free_of_charge_has_the_exact_shape_the_spec_prescribes() {
692 let free = tariff(vec![TariffElement::builder().price_components(vec![flat("0.00")]).build()]);
693 assert!(free.is_free_of_charge());
694
695 let with_restriction = tariff(vec![
696 TariffElement::builder()
697 .price_components(vec![flat("0.00")])
698 .restrictions(TariffRestrictions {
699 max_kwh: Some("10".parse().unwrap()),
700 ..Default::default()
701 })
702 .build(),
703 ]);
704 assert!(!with_restriction.is_free_of_charge(), "a restricted zero price is not free");
705 assert!(
706 !tariff(vec![TariffElement::builder().price_components(vec![flat("0.25")]).build()])
707 .is_free_of_charge()
708 );
709 }
710
711 #[test]
712 fn reservation_elements_may_only_price_flat_and_time() {
713 let bad = tariff(vec![
714 TariffElement::builder()
715 .price_components(vec![PriceComponent::new(
716 TariffDimensionType::Energy,
717 "0.25".parse().unwrap(),
718 )])
719 .restrictions(TariffRestrictions {
720 reservation: Some(ReservationRestrictionType::Reservation),
721 ..Default::default()
722 })
723 .build(),
724 ]);
725 let err = bad.validate().unwrap_err();
726 assert_eq!(err.as_slice()[0].pointer, "/elements/0/price_components/0/type");
727 }
728
729 #[test]
730 fn a_dimension_cannot_be_priced_twice_in_one_element() {
731 let e = TariffElement::builder().price_components(vec![flat("1"), flat("2")]).build();
732 assert_eq!(e.validate().unwrap_err().as_slice()[0].code, ViolationCode::Inconsistent);
733 }
734
735 #[test]
736 fn impossible_restriction_windows_are_reported() {
737 let r = TariffRestrictions {
738 min_kwh: Some("20".parse().unwrap()),
739 max_kwh: Some("10".parse().unwrap()),
740 ..Default::default()
741 };
742 assert_eq!(r.validate().unwrap_err().as_slice()[0].pointer, "/max_kwh");
743 let wrap = TariffRestrictions {
745 start_time: Some("22:00".parse().unwrap()),
746 end_time: Some("06:00".parse().unwrap()),
747 ..Default::default()
748 };
749 assert!(wrap.validate().is_ok());
750 }
751
752 #[test]
753 fn step_size_units_follow_the_dimension() {
754 assert_eq!(TariffDimensionType::Energy.step_size_unit(), Some("Wh"));
755 assert_eq!(TariffDimensionType::Time.step_size_unit(), Some("s"));
756 assert_eq!(TariffDimensionType::Flat.step_size_unit(), None);
757 assert!(PriceComponent { step_size: 0, ..flat("0.00") }.validate().is_ok());
760 let no_energy = PriceComponent {
761 step_size: 0,
762 ..PriceComponent::new(TariffDimensionType::Energy, "0.25".parse().unwrap())
763 };
764 assert_eq!(no_energy.validate().unwrap_err().as_slice()[0].pointer, "/step_size");
765 }
766
767 #[test]
768 fn validity_window_is_checked_against_an_instant() {
769 let mut t = tariff(vec![TariffElement::builder().price_components(vec![flat("1")]).build()]);
770 t.end_date_time = Some("2019-06-30T00:00:00Z".parse().unwrap());
771 assert!(t.is_active_at("2019-01-01T00:00:00Z".parse().unwrap()));
772 assert!(!t.is_active_at("2019-07-01T00:00:00Z".parse().unwrap()));
773 }
774
775 #[test]
776 fn iso_weekday_numbering_matches_regular_hours() {
777 assert_eq!(DayOfWeek::Monday.iso_number(), 1);
778 assert_eq!(DayOfWeek::Sunday.iso_number(), 7);
779 assert_eq!(DayOfWeek::from_iso_number(3), Some(DayOfWeek::Wednesday));
780 assert_eq!(DayOfWeek::from_iso_number(0), None);
781 }
782}