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: LocalTime::new(local.hour(), local.minute())
631 .map_err(|e| PricingError::TimeZone(e.to_string()))?,
632 local_date: LocalDate::from_date(local.date()),
633 weekday: DayOfWeek::from_iso_number(local.weekday().number_from_monday())
634 .unwrap_or(DayOfWeek::Monday),
635 energy_so_far: session.energy_before(index),
636 duration_so_far_seconds: session.duration_before(index),
637 current_lower: period.current_for_lower_bound(),
638 current_upper: period.current_for_upper_bound(),
639 power_lower: period.power_for_lower_bound(),
640 power_upper: period.power_for_upper_bound(),
641 is_reservation: false,
642 reservation_expired: session.reservation_expired,
643 })
644 }
645
646 fn build_at_end(
653 session: &PricedSession,
654 index: usize,
655 period: &PricedPeriod,
656 ) -> Result<Option<Self>, PricingError> {
657 let Some(end) = session.period_end(index) else { return Ok(None) };
658 let Some(last_instant) = DateTime::from_unix_timestamp(end.unix_timestamp() - 1).ok() else {
659 return Ok(None);
660 };
661 if last_instant <= period.start {
662 return Ok(None);
664 }
665 let local = session.time_zone.to_local(last_instant)?;
666 Ok(Some(Self {
667 local_time: LocalTime::new(local.hour(), local.minute())
668 .map_err(|e| PricingError::TimeZone(e.to_string()))?,
669 local_date: LocalDate::from_date(local.date()),
670 weekday: DayOfWeek::from_iso_number(local.weekday().number_from_monday())
671 .unwrap_or(DayOfWeek::Monday),
672 energy_so_far: session.energy_before(index) + period.energy_kwh,
674 duration_so_far_seconds: last_instant.unix_timestamp() - session.start.unix_timestamp(),
675 current_lower: period.current_for_lower_bound(),
676 current_upper: period.current_for_upper_bound(),
677 power_lower: period.power_for_lower_bound(),
678 power_upper: period.power_for_upper_bound(),
679 is_reservation: false,
680 reservation_expired: session.reservation_expired,
681 }))
682 }
683
684 const fn reserving(&self, is_reservation: bool) -> Self {
686 Self { is_reservation, ..*self }
687 }
688
689 fn describe(&self) -> String {
690 format!(
691 "at {} {} local ({}), {} kWh and {}s into the session",
692 self.local_date, self.local_time, self.weekday, self.energy_so_far, self.duration_so_far_seconds
693 )
694 }
695}
696
697struct Found<'a> {
699 component: &'a PriceComponent,
700 element_index: usize,
701 component_index: usize,
702}
703
704impl Found<'_> {
705 fn applied(&self, tariff: &Tariff, context: &RestrictionContext) -> AppliedComponent {
706 AppliedComponent {
707 tariff_id: tariff.id.as_str().to_owned(),
708 element_index: self.element_index,
709 component_index: self.component_index,
710 because: context.describe(),
711 }
712 }
713}
714
715fn find_component<'a>(
717 tariff: &'a Tariff,
718 dimension: TariffDimensionType,
719 context: &RestrictionContext,
720) -> Option<Found<'a>> {
721 for (element_index, element) in tariff.elements.iter().enumerate() {
722 if !restrictions_match(element, context) {
723 continue;
724 }
725 for (component_index, component) in element.price_components.iter().enumerate() {
726 if component.component_type == dimension {
727 return Some(Found { component, element_index, component_index });
728 }
729 }
730 }
731 None
732}
733
734fn restrictions_match(element: &TariffElement, context: &RestrictionContext) -> bool {
735 let Some(restrictions) = element.restrictions.as_ref() else {
736 return !context.is_reservation || element_prices_reservation_dimension(element);
738 };
739 matches(restrictions, context)
740}
741
742fn element_prices_reservation_dimension(element: &TariffElement) -> bool {
745 element
746 .price_components
747 .iter()
748 .any(|c| matches!(c.component_type, TariffDimensionType::Flat | TariffDimensionType::Time))
749}
750
751fn matches(r: &TariffRestrictions, context: &RestrictionContext) -> bool {
753 match r.reservation {
755 Some(ReservationRestrictionType::Reservation) => {
756 if !context.is_reservation || context.reservation_expired {
757 return false;
758 }
759 }
760 Some(ReservationRestrictionType::ReservationExpires) => {
761 if !context.is_reservation || !context.reservation_expired {
762 return false;
763 }
764 }
765 None => {
766 if context.is_reservation {
767 return false;
769 }
770 }
771 }
772
773 if let (Some(start), Some(end)) = (r.start_time, r.end_time) {
774 if !context.local_time.is_within(start, end) {
775 return false;
776 }
777 } else if let Some(start) = r.start_time {
778 if context.local_time < start {
779 return false;
780 }
781 } else if let Some(end) = r.end_time
782 && context.local_time >= end
783 {
784 return false;
785 }
786
787 if r.start_date.is_some_and(|d| context.local_date < d) {
789 return false;
790 }
791 if r.end_date.is_some_and(|d| context.local_date >= d) {
792 return false;
793 }
794
795 if r.min_kwh.is_some_and(|min| context.energy_so_far < min) {
797 return false;
798 }
799 if r.max_kwh.is_some_and(|max| context.energy_so_far >= max) {
801 return false;
802 }
803
804 if let Some(min) = r.min_current
805 && context.current_lower.is_none_or(|c| c < min)
806 {
807 return false;
808 }
809 if let Some(max) = r.max_current
810 && context.current_upper.is_none_or(|c| c >= max)
811 {
812 return false;
813 }
814 if let Some(min) = r.min_power
815 && context.power_lower.is_none_or(|p| p < min)
816 {
817 return false;
818 }
819 if let Some(max) = r.max_power
820 && context.power_upper.is_none_or(|p| p >= max)
821 {
822 return false;
823 }
824
825 if let Some(min) = r.min_duration
826 && context.duration_so_far_seconds < i64_of(min)
827 {
828 return false;
829 }
830 if let Some(max) = r.max_duration
831 && context.duration_so_far_seconds >= i64_of(max)
832 {
833 return false;
834 }
835
836 if !r.day_of_week.is_empty() && !r.day_of_week.contains(&context.weekday) {
837 return false;
838 }
839
840 true
841}
842
843fn i64_of(value: u64) -> i64 {
844 i64::try_from(value).unwrap_or(i64::MAX)
845}