1use crate::types::{DateTime, LocalDate, LocalTime, Number};
58use crate::v2_3_0::tariffs::{
59 DayOfWeek, PriceComponent, ReservationRestrictionType, Tariff, TariffDimensionType, TariffElement,
60 TariffRestrictions,
61};
62
63use super::PricingError;
64use super::breakdown::{
65 AppliedComponent, CostBreakdown, DimensionCost, PriceLimitApplied, PricedSegment, PricingNote,
66 PricingNoteCode, TaxLine,
67};
68use super::input::{PricedPeriod, PricedSession};
69use super::policy::PricingPolicy;
70
71#[derive(Clone, Debug, Default)]
109pub struct PricingEngine {
110 policy: PricingPolicy,
111}
112
113impl PricingEngine {
114 #[must_use]
116 pub fn new() -> Self {
117 Self::default()
118 }
119
120 #[must_use]
122 pub fn with_policy(policy: PricingPolicy) -> Self {
123 Self { policy }
124 }
125
126 #[must_use]
128 pub const fn policy(&self) -> &PricingPolicy {
129 &self.policy
130 }
131
132 pub fn price(&self, session: &PricedSession, tariffs: &[Tariff]) -> Result<CostBreakdown, PricingError> {
143 if tariffs.is_empty() {
144 return Err(PricingError::NoTariff);
145 }
146 let mut notes: Vec<PricingNote> = Vec::new();
147
148 let mut segments: Vec<(TariffDimensionType, PricedSegment, u32)> = Vec::new();
150 let mut flat_charged = false;
151
152 if let Some(at) = session.first_out_of_order() {
155 notes.push(PricingNote::new(
156 PricingNoteCode::PeriodsOutOfOrder,
157 Some(at),
158 "this Charging Period does not start after the one before it; `step_size` and \
159 every duration-based restriction are evaluated against the order given, which \
160 is not a timeline this session could have had",
161 ));
162 }
163
164 for (index, period) in session.periods.iter().enumerate() {
165 let tariff = Self::select_tariff(session, period, tariffs)?;
166 let context = RestrictionContext::build(session, index, period)?;
167 let end_context = RestrictionContext::build_at_end(session, index, period)?;
168
169 for (dimension, quantity, reserving) in period_quantities(period) {
170 if quantity.is_zero() {
171 continue;
172 }
173 let context = context.reserving(reserving);
179 let Some(found) = find_component(tariff, dimension, &context) else {
180 notes.push(PricingNote::new(
181 PricingNoteCode::NoPriceComponent,
182 Some(period.start),
183 format!(
184 "no {dimension} Price Component in tariff {} matched{}; \
185 the specification says there are then no costs for that dimension",
186 tariff.id,
187 if reserving { " for the reserved time" } else { "" },
188 ),
189 ));
190 continue;
191 };
192
193 if let Some(end) = end_context.as_ref().map(|c| c.reserving(reserving))
196 && let Some(later) = find_component(tariff, dimension, &end)
197 && (later.element_index, later.component_index)
198 != (found.element_index, found.component_index)
199 {
200 notes.push(PricingNote::new(
201 PricingNoteCode::PeriodSpansPriceChange,
202 Some(period.start),
203 format!(
204 "the {dimension} Charging Period starting here outlasts the Price \
205 Component that prices it: element {} applies at the start and \
206 element {} by the time the period ends. A CPO SHALL start a new \
207 Charging Period at a price change, so this one should have been \
208 split; its {dimension} is billed in full at the earlier rate, \
209 because nothing in the period says how it divides",
210 found.element_index, later.element_index,
211 ),
212 ));
213 }
214 segments.push((
215 dimension,
216 PricedSegment {
217 start: period.start,
218 quantity,
219 price: found.component.price,
220 vat_percentage: found.component.vat,
221 cost: Number::ZERO, applied: found.applied(tariff, &context),
223 },
224 found.component.step_size,
225 ));
226 }
227
228 let context = context.reserving(!period.reservation_hours.is_zero());
231 if !flat_charged && let Some(found) = find_component(tariff, TariffDimensionType::Flat, &context)
232 {
233 flat_charged = true;
234 segments.push((
235 TariffDimensionType::Flat,
236 PricedSegment {
237 start: period.start,
238 quantity: Number::ONE,
239 price: found.component.price,
240 vat_percentage: found.component.vat,
241 cost: Number::ZERO,
242 applied: found.applied(tariff, &context),
243 },
244 1,
245 ));
246 }
247 }
248
249 let dimensions = self.quantise_and_cost(segments);
250 let tariff = Self::select_tariff_for_limits(session, tariffs)?;
251 Ok(self.finish(dimensions, tariff, notes))
252 }
253
254 fn quantise_and_cost(
256 &self,
257 segments: Vec<(TariffDimensionType, PricedSegment, u32)>,
258 ) -> Vec<DimensionCost> {
259 use TariffDimensionType::{Energy, Flat, ParkingTime, Time};
260
261 let quantised_time_dimension =
270 if segments.iter().any(|(d, _, _)| *d == TariffDimensionType::ParkingTime) {
271 Some(ParkingTime)
272 } else {
273 segments.iter().find(|(d, _, _)| d.is_time_based()).map(|(d, _, _)| *d)
274 };
275
276 let mut by_dimension: Vec<(TariffDimensionType, Vec<(PricedSegment, u32)>)> = Vec::new();
277 for (dimension, segment, step) in segments {
278 match by_dimension.iter_mut().find(|(d, _)| *d == dimension) {
279 Some((_, list)) => list.push((segment, step)),
280 None => by_dimension.push((dimension, vec![(segment, step)])),
281 }
282 }
283
284 let mut out = Vec::with_capacity(by_dimension.len());
285 for (dimension, mut list) in by_dimension {
286 let measured: Number = list.iter().map(|(s, _)| s.quantity).sum();
287
288 let quantise = match dimension {
290 Energy => true,
291 Time | ParkingTime => quantised_time_dimension == Some(dimension),
292 Flat => false,
293 };
294 let billed = if quantise {
295 let step = list.last().map_or(1, |(_, step)| *step);
297 let unit_scale = match dimension {
298 Energy => 1000, _ => 3600, };
301 self.policy.quantisation.apply(measured, step, unit_scale)
302 } else {
303 measured
304 };
305
306 if billed != measured
309 && let Some((last, _)) = list.last_mut()
310 {
311 last.quantity = last.quantity + (billed - measured);
312 }
313
314 let mut cost = Number::ZERO;
315 let mut vat = Number::ZERO;
316 let mut priced_segments = Vec::with_capacity(list.len());
317 for (mut segment, _) in list {
318 segment.cost = self.policy.round_component(segment.quantity * segment.price);
319 segment.quantity = self.policy.round_quantity(segment.quantity);
320 cost = cost + segment.cost;
321 if let Some(percentage) = segment.vat_percentage {
322 vat = vat + self.policy.round_component(segment.cost * percentage / Number::from(100u32));
323 }
324 priced_segments.push(segment);
325 }
326
327 out.push(DimensionCost {
328 dimension,
329 measured: self.policy.round_quantity(measured),
331 billed: self.policy.round_quantity(billed),
332 cost: self.policy.round_component(cost),
333 vat: self.policy.round_component(vat),
334 segments: priced_segments,
335 });
336 }
337 out
338 }
339
340 fn finish(
361 &self,
362 dimensions: Vec<DimensionCost>,
363 tariff: &Tariff,
364 mut notes: Vec<PricingNote>,
365 ) -> CostBreakdown {
366 let mut taxes: Vec<TaxLine> = Vec::new();
367 for dimension in &dimensions {
368 for segment in &dimension.segments {
369 let Some(percentage) = segment.vat_percentage else { continue };
370 let amount = self.policy.round_component(segment.cost * percentage / Number::from(100u32));
371 match taxes.iter_mut().find(|t| t.percentage == Some(percentage)) {
372 Some(line) => {
373 line.taxable = line.taxable + segment.cost;
374 line.amount = line.amount + amount;
375 }
376 None => {
377 taxes.push(TaxLine { percentage: Some(percentage), taxable: segment.cost, amount });
378 }
379 }
380 }
381 }
382 taxes.sort_by_key(|a| a.percentage);
383
384 let raw_excl: Number = dimensions.iter().map(|d| d.cost).sum();
385 let raw_vat: Number = taxes.iter().map(|t| t.amount).sum();
386 let mut total_excl = self.policy.round_currency(raw_excl);
387 let mut total_incl = self.policy.round_currency(raw_excl + raw_vat);
388 let mut limit_applied = None;
389
390 if let Some(min) = tariff.min_price.as_ref() {
391 if total_excl < min.before_taxes {
392 total_excl = self.policy.round_currency(min.before_taxes);
393 limit_applied = Some(PriceLimitApplied::Minimum);
394 }
395 if let Some(after) = min.after_taxes
396 && total_incl < after
397 {
398 total_incl = self.policy.round_currency(after);
399 limit_applied = Some(PriceLimitApplied::Minimum);
400 }
401 }
402 if let Some(max) = tariff.max_price.as_ref() {
403 if total_excl > max.before_taxes {
404 total_excl = self.policy.round_currency(max.before_taxes);
405 limit_applied = Some(PriceLimitApplied::Maximum);
406 }
407 if let Some(after) = max.after_taxes
408 && total_incl > after
409 {
410 total_incl = self.policy.round_currency(after);
411 limit_applied = Some(PriceLimitApplied::Maximum);
412 }
413 }
414
415 let mut base_ratio = Number::ONE;
416 if let Some(applied) = limit_applied {
417 base_ratio = if raw_excl.is_zero() { Number::ONE } else { total_excl / raw_excl };
421 let bounded_after_tax = match applied {
422 PriceLimitApplied::Minimum => tariff.min_price.as_ref().and_then(|p| p.after_taxes),
423 PriceLimitApplied::Maximum => tariff.max_price.as_ref().and_then(|p| p.after_taxes),
424 };
425 if bounded_after_tax.is_none() {
426 let scaled_vat = if raw_excl.is_zero() { Number::ZERO } else { raw_vat * base_ratio };
428 total_incl = self.policy.round_currency(total_excl + scaled_vat);
429 }
430 total_incl = total_incl.max(total_excl);
431 notes.push(PricingNote::new(
432 PricingNoteCode::TotalClamped,
433 None,
434 format!(
435 "the session metered {raw_excl} before tax, which the tariff's {} price \
436 limit moved to {total_excl}; the tax lines were moved in proportion so they \
437 still account for the difference between the two totals",
438 match applied {
439 PriceLimitApplied::Minimum => "minimum",
440 PriceLimitApplied::Maximum => "maximum",
441 },
442 ),
443 ));
444 }
445
446 if total_incl < total_excl {
450 notes.push(PricingNote::new(
451 PricingNoteCode::NegativeTax,
452 None,
453 format!(
454 "the price components of this tariff describe {} of tax, which no tariff can \
455 mean; the inclusive total is held at the exclusive one. A VAT percentage \
456 below zero is what causes this, and `Tariff::validate` names the component",
457 total_incl - total_excl,
458 ),
459 ));
460 total_incl = total_excl;
461 }
462
463 self.present_taxes(&mut taxes, total_incl - total_excl, base_ratio, total_excl, &mut notes);
464
465 CostBreakdown {
466 dimensions,
467 total_excl_vat: total_excl,
468 total_incl_vat: total_incl,
469 taxes,
470 limit_applied,
471 notes,
472 }
473 }
474
475 fn present_taxes(
489 &self,
490 taxes: &mut Vec<TaxLine>,
491 owed: Number,
492 base_ratio: Number,
493 taxable_base: Number,
494 notes: &mut Vec<PricingNote>,
495 ) {
496 let current: Number = taxes.iter().map(|t| t.amount).sum();
497 if taxes.is_empty() || current.is_zero() {
498 if owed.is_zero() {
499 for line in taxes.iter_mut() {
500 line.taxable = self.policy.round_currency(line.taxable * base_ratio);
501 line.amount = Number::ZERO;
502 }
503 return;
504 }
505 notes.push(PricingNote::new(
509 PricingNoteCode::UnattributedTax,
510 None,
511 format!(
512 "{owed} of tax is owed that no price component in this session accounts for; \
513 it comes from a price limit's `after_taxes` bound, which names an amount but \
514 not a rate",
515 ),
516 ));
517 taxes.clear();
518 taxes.push(TaxLine { percentage: None, taxable: taxable_base, amount: owed });
519 return;
520 }
521
522 let mut running = Number::ZERO;
523 let last = taxes.len() - 1;
524 for (i, line) in taxes.iter_mut().enumerate() {
525 line.taxable = self.policy.round_currency(line.taxable * base_ratio);
526 if i == last {
527 line.amount = owed - running;
528 } else {
529 line.amount = self.policy.round_currency(line.amount * owed / current);
530 running = running + line.amount;
531 }
532 }
533 }
534
535 fn select_tariff<'a>(
537 session: &PricedSession,
538 period: &PricedPeriod,
539 tariffs: &'a [Tariff],
540 ) -> Result<&'a Tariff, PricingError> {
541 if let Some(id) = period.tariff_id.as_deref() {
542 return tariffs
543 .iter()
544 .find(|t| t.id.eq_ignore_case(id))
545 .ok_or_else(|| PricingError::UnknownTariff(id.to_owned()));
546 }
547 Self::select_by_preference(session, period.start, tariffs)
548 }
549
550 fn select_tariff_for_limits<'a>(
552 session: &PricedSession,
553 tariffs: &'a [Tariff],
554 ) -> Result<&'a Tariff, PricingError> {
555 Self::select_by_preference(session, session.start, tariffs)
556 }
557
558 fn select_by_preference<'a>(
559 session: &PricedSession,
560 at: DateTime,
561 tariffs: &'a [Tariff],
562 ) -> Result<&'a Tariff, PricingError> {
563 use crate::v2_3_0::sessions::ProfileType;
564 use crate::v2_3_0::tariffs::TariffType;
565 let wanted = if session.ad_hoc_payment {
566 Some(TariffType::AdHocPayment)
567 } else {
568 match session.profile_type {
569 Some(ProfileType::Cheap) => Some(TariffType::ProfileCheap),
570 Some(ProfileType::Fast) => Some(TariffType::ProfileFast),
571 Some(ProfileType::Green) => Some(TariffType::ProfileGreen),
572 Some(ProfileType::Regular) => Some(TariffType::Regular),
573 None => None,
574 }
575 };
576 let active: Vec<&Tariff> = tariffs.iter().filter(|t| t.is_active_at(at)).collect();
577 if active.is_empty() {
578 return Err(PricingError::NoActiveTariff(at));
579 }
580 if let Some(wanted) = wanted
583 && let Some(t) = active.iter().find(|t| t.tariff_type == Some(wanted))
584 {
585 return Ok(t);
586 }
587 if let Some(t) = active.iter().find(|t| t.tariff_type.is_none()) {
588 return Ok(t);
589 }
590 Ok(active[0])
591 }
592}
593
594fn period_quantities(period: &PricedPeriod) -> [(TariffDimensionType, Number, bool); 4] {
602 [
603 (TariffDimensionType::Energy, period.energy_kwh, false),
604 (TariffDimensionType::Time, period.charging_hours, false),
605 (TariffDimensionType::Time, period.reservation_hours, true),
606 (TariffDimensionType::ParkingTime, period.parking_hours, false),
607 ]
608}
609
610#[derive(Clone, Copy)]
612struct RestrictionContext {
613 local_time: LocalTime,
614 local_date: LocalDate,
615 weekday: DayOfWeek,
616 energy_so_far: Number,
617 duration_so_far_seconds: i64,
618 current_lower: Option<Number>,
619 current_upper: Option<Number>,
620 power_lower: Option<Number>,
621 power_upper: Option<Number>,
622 is_reservation: bool,
623 reservation_expired: bool,
624}
625
626impl RestrictionContext {
627 fn build(session: &PricedSession, index: usize, period: &PricedPeriod) -> Result<Self, PricingError> {
628 let local = session.time_zone.to_local(period.start)?;
629 Ok(Self {
630 local_time: local.time,
631 local_date: local.date,
632 weekday: DayOfWeek::from_iso_number(local.iso_weekday).unwrap_or(DayOfWeek::Monday),
633 energy_so_far: session.energy_before(index),
634 duration_so_far_seconds: session.duration_before(index),
635 current_lower: period.current_for_lower_bound(),
636 current_upper: period.current_for_upper_bound(),
637 power_lower: period.power_for_lower_bound(),
638 power_upper: period.power_for_upper_bound(),
639 is_reservation: false,
640 reservation_expired: session.reservation_expired,
641 })
642 }
643
644 fn build_at_end(
651 session: &PricedSession,
652 index: usize,
653 period: &PricedPeriod,
654 ) -> Result<Option<Self>, PricingError> {
655 let Some(end) = session.period_end(index) else { return Ok(None) };
656 let Some(last_instant) = DateTime::from_unix_timestamp(end.unix_timestamp() - 1).ok() else {
657 return Ok(None);
658 };
659 if last_instant <= period.start {
660 return Ok(None);
662 }
663 let local = session.time_zone.to_local(last_instant)?;
664 Ok(Some(Self {
665 local_time: local.time,
666 local_date: local.date,
667 weekday: DayOfWeek::from_iso_number(local.iso_weekday).unwrap_or(DayOfWeek::Monday),
668 energy_so_far: session.energy_before(index) + period.energy_kwh,
670 duration_so_far_seconds: last_instant.unix_timestamp() - session.start.unix_timestamp(),
671 current_lower: period.current_for_lower_bound(),
672 current_upper: period.current_for_upper_bound(),
673 power_lower: period.power_for_lower_bound(),
674 power_upper: period.power_for_upper_bound(),
675 is_reservation: false,
676 reservation_expired: session.reservation_expired,
677 }))
678 }
679
680 const fn reserving(&self, is_reservation: bool) -> Self {
682 Self { is_reservation, ..*self }
683 }
684
685 fn describe(&self) -> String {
686 format!(
687 "at {} {} local ({}), {} kWh and {}s into the session",
688 self.local_date, self.local_time, self.weekday, self.energy_so_far, self.duration_so_far_seconds
689 )
690 }
691}
692
693struct Found<'a> {
695 component: &'a PriceComponent,
696 element_index: usize,
697 component_index: usize,
698}
699
700impl Found<'_> {
701 fn applied(&self, tariff: &Tariff, context: &RestrictionContext) -> AppliedComponent {
702 AppliedComponent {
703 tariff_id: tariff.id.as_str().to_owned(),
704 element_index: self.element_index,
705 component_index: self.component_index,
706 because: context.describe(),
707 }
708 }
709}
710
711fn find_component<'a>(
713 tariff: &'a Tariff,
714 dimension: TariffDimensionType,
715 context: &RestrictionContext,
716) -> Option<Found<'a>> {
717 for (element_index, element) in tariff.elements.iter().enumerate() {
718 if !restrictions_match(element, context) {
719 continue;
720 }
721 for (component_index, component) in element.price_components.iter().enumerate() {
722 if component.component_type == dimension {
723 return Some(Found { component, element_index, component_index });
724 }
725 }
726 }
727 None
728}
729
730fn restrictions_match(element: &TariffElement, context: &RestrictionContext) -> bool {
731 let Some(restrictions) = element.restrictions.as_ref() else {
732 return !context.is_reservation || element_prices_reservation_dimension(element);
734 };
735 matches(restrictions, context)
736}
737
738fn element_prices_reservation_dimension(element: &TariffElement) -> bool {
741 element
742 .price_components
743 .iter()
744 .any(|c| matches!(c.component_type, TariffDimensionType::Flat | TariffDimensionType::Time))
745}
746
747fn matches(r: &TariffRestrictions, context: &RestrictionContext) -> bool {
749 match r.reservation {
751 Some(ReservationRestrictionType::Reservation) => {
752 if !context.is_reservation || context.reservation_expired {
753 return false;
754 }
755 }
756 Some(ReservationRestrictionType::ReservationExpires) => {
757 if !context.is_reservation || !context.reservation_expired {
758 return false;
759 }
760 }
761 None => {
762 if context.is_reservation {
763 return false;
765 }
766 }
767 }
768
769 if let (Some(start), Some(end)) = (r.start_time, r.end_time) {
770 if !context.local_time.is_within(start, end) {
771 return false;
772 }
773 } else if let Some(start) = r.start_time {
774 if context.local_time < start {
775 return false;
776 }
777 } else if let Some(end) = r.end_time
778 && context.local_time >= end
779 {
780 return false;
781 }
782
783 if r.start_date.is_some_and(|d| context.local_date < d) {
785 return false;
786 }
787 if r.end_date.is_some_and(|d| context.local_date >= d) {
788 return false;
789 }
790
791 if r.min_kwh.is_some_and(|min| context.energy_so_far < min) {
793 return false;
794 }
795 if r.max_kwh.is_some_and(|max| context.energy_so_far >= max) {
797 return false;
798 }
799
800 if let Some(min) = r.min_current
801 && context.current_lower.is_none_or(|c| c < min)
802 {
803 return false;
804 }
805 if let Some(max) = r.max_current
806 && context.current_upper.is_none_or(|c| c >= max)
807 {
808 return false;
809 }
810 if let Some(min) = r.min_power
811 && context.power_lower.is_none_or(|p| p < min)
812 {
813 return false;
814 }
815 if let Some(max) = r.max_power
816 && context.power_upper.is_none_or(|p| p >= max)
817 {
818 return false;
819 }
820
821 if let Some(min) = r.min_duration
822 && context.duration_so_far_seconds < i64_of(min)
823 {
824 return false;
825 }
826 if let Some(max) = r.max_duration
827 && context.duration_so_far_seconds >= i64_of(max)
828 {
829 return false;
830 }
831
832 if !r.day_of_week.is_empty() && !r.day_of_week.contains(&context.weekday) {
833 return false;
834 }
835
836 true
837}
838
839fn i64_of(value: u64) -> i64 {
840 i64::try_from(value).unwrap_or(i64::MAX)
841}