ocpi_kit/tariffs/breakdown.rs
1//! The auditable output of a pricing run.
2//!
3//! The point of a breakdown is that somebody can *check* it: a dispute over a €12 session is
4//! settled by pointing at which Tariff Element priced which quantity, not by re-running the
5//! engine and hoping.
6
7use core::fmt;
8
9use serde::{Deserialize, Serialize};
10
11use crate::types::{DateTime, Number};
12use crate::v2_3_0::tariffs::TariffDimensionType;
13
14/// What one dimension of a session cost, and how that was arrived at.
15#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
16pub struct DimensionCost {
17 /// The dimension priced.
18 pub dimension: TariffDimensionType,
19 /// The quantity actually measured, before `step_size` was applied.
20 pub measured: Number,
21 /// The quantity billed, after `step_size` was applied.
22 pub billed: Number,
23 /// The cost, excluding any VAT named on the price components.
24 pub cost: Number,
25 /// The VAT owed on that cost.
26 pub vat: Number,
27 /// Each stretch of the session that was priced at one rate.
28 pub segments: Vec<PricedSegment>,
29}
30
31impl DimensionCost {
32 /// The cost including VAT.
33 #[must_use]
34 pub fn cost_with_vat(&self) -> Number {
35 self.cost + self.vat
36 }
37
38 /// Whether `step_size` changed the billed quantity.
39 #[must_use]
40 pub fn was_quantised(&self) -> bool {
41 self.billed != self.measured
42 }
43}
44
45/// One stretch of a session priced at a single rate.
46#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
47pub struct PricedSegment {
48 /// When this stretch began.
49 pub start: DateTime,
50 /// The quantity billed in this stretch.
51 pub quantity: Number,
52 /// The unit price applied.
53 pub price: Number,
54 /// The VAT percentage applied, if the price component named one.
55 pub vat_percentage: Option<Number>,
56 /// The cost of this stretch, excluding VAT.
57 pub cost: Number,
58 /// Which Tariff Element priced it.
59 pub applied: AppliedComponent,
60}
61
62/// The exact Price Component that priced a segment.
63///
64/// This is the audit trail: a `tariff_id` plus an index into its `elements` names one row of one
65/// object the CPO published, which can be quoted back at them.
66#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
67pub struct AppliedComponent {
68 /// The `Tariff.id` the component came from.
69 pub tariff_id: String,
70 /// The index of the Tariff Element within that Tariff's `elements`.
71 pub element_index: usize,
72 /// The index of the Price Component within that element's `price_components`.
73 pub component_index: usize,
74 /// Why this element was selected — which restrictions it satisfied — in words.
75 pub because: String,
76}
77
78impl fmt::Display for AppliedComponent {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 write!(
81 f,
82 "tariff {} element {} component {}",
83 self.tariff_id, self.element_index, self.component_index
84 )
85 }
86}
87
88/// One tax line of the result.
89///
90/// The tax lines of a breakdown always sum to `total_incl_vat - total_excl_vat`. That invariant
91/// is what makes the breakdown filable: a document whose tax lines disagree with its own totals
92/// is not something a tax authority or a disputing partner can act on.
93#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
94pub struct TaxLine {
95 /// The VAT percentage this line covers.
96 ///
97 /// `None` for tax that could not be attributed to a rate — which happens when a
98 /// `min_price.after_taxes` raises the inclusive total of a session that had no VAT to
99 /// apportion it to. The 2.3.0 wire type
100 /// [`TaxAmount`](crate::v2_3_0::types::TaxAmount) has the same optionality, for the same
101 /// reason: an amount of tax is a fact, a rate is an explanation.
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub percentage: Option<Number>,
104 /// The amount the percentage was applied to.
105 pub taxable: Number,
106 /// The tax owed.
107 pub amount: Number,
108}
109
110/// What a [`PricingNote`] is about, without reading the English.
111///
112/// An invoice-reconciliation pipeline has to be able to *count* these — how many CDRs this month
113/// span a price change? — and grepping a sentence is not a way to do that.
114#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116#[non_exhaustive]
117pub enum PricingNoteCode {
118 /// A dimension was consumed that no Tariff Element prices.
119 ///
120 /// Not an error: *"there will be no costs for that Tariff Dimension"*. But a session that
121 /// charged 40 kWh for nothing is worth looking at.
122 NoPriceComponent,
123 /// A Charging Period outlasted the Price Component that priced it.
124 ///
125 /// > *A CPO SHALL at least start (and add) a ChargingPeriod every moment/event that has
126 /// > relevance for the total costs of a CDR. … When an energy changes in price after 17:00,
127 /// > the CPO has to start a new Charging Period at 17:00.*
128 ///
129 /// So this is a defect in the **CDR**, not in the tariff: the period should have been split.
130 /// Its quantities cannot be apportioned after the fact — nothing in the data says how much
131 /// of the energy fell either side of the boundary — so the period is priced at the rate that
132 /// applied when it began, and this note says that happened. See
133 /// [`PricingEngine`](crate::tariffs::PricingEngine).
134 PeriodSpansPriceChange,
135 /// The Charging Periods are not in chronological order.
136 ///
137 /// Nothing in the property tables says they must be, but everything built on them assumes it:
138 /// `step_size` is defined in terms of *"the last relevant PriceComponent"*, and a period's
139 /// duration is only knowable as the gap to the next one. Out of order, both are wrong.
140 ///
141 /// The session is still priced — the quantities are all there — but any restriction that
142 /// depends on elapsed time is evaluated against a timeline that does not exist.
143 PeriodsOutOfOrder,
144 /// A `min_price` or `max_price` moved the total, and the tax lines were moved with it.
145 TotalClamped,
146 /// The tax the tariff describes came out negative.
147 ///
148 /// No tariff can mean that: a VAT percentage below zero is a malformed
149 /// [`PriceComponent`](crate::v2_3_0::tariffs::PriceComponent), which
150 /// [`Validate`](crate::types::Validate) reports. The engine does not require validated input,
151 /// so rather than publish a session that costs less with tax than without, it holds the
152 /// inclusive total at the exclusive one and says why.
153 NegativeTax,
154 /// Tax was owed that no rate in the session accounts for.
155 ///
156 /// Raised when a `min_price.after_taxes` lifts the inclusive total of a session whose price
157 /// components named no VAT at all. The amount is real; the rate is not knowable.
158 UnattributedTax,
159}
160
161impl PricingNoteCode {
162 /// A short, stable, machine-readable slug.
163 #[must_use]
164 pub const fn as_str(self) -> &'static str {
165 match self {
166 Self::NoPriceComponent => "no_price_component",
167 Self::PeriodsOutOfOrder => "periods_out_of_order",
168 Self::PeriodSpansPriceChange => "period_spans_price_change",
169 Self::TotalClamped => "total_clamped",
170 Self::NegativeTax => "negative_tax",
171 Self::UnattributedTax => "unattributed_tax",
172 }
173 }
174}
175
176impl fmt::Display for PricingNoteCode {
177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178 f.write_str(self.as_str())
179 }
180}
181
182/// Something the engine wants the reader of a breakdown to know.
183#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
184pub struct PricingNote {
185 /// What kind of note this is.
186 pub code: PricingNoteCode,
187 /// The moment in the session it concerns, when it concerns one.
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub at: Option<DateTime>,
190 /// The same thing in words, for a human reading the breakdown.
191 pub message: String,
192}
193
194impl PricingNote {
195 pub(super) fn new(code: PricingNoteCode, at: Option<DateTime>, message: impl Into<String>) -> Self {
196 Self { code, at, message: message.into() }
197 }
198}
199
200impl fmt::Display for PricingNote {
201 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202 match self.at {
203 Some(at) => write!(f, "[{}] at {at}: {}", self.code, self.message),
204 None => write!(f, "[{}] {}", self.code, self.message),
205 }
206 }
207}
208
209/// Why the total was clamped.
210#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum PriceLimitApplied {
213 /// The session cost less than `Tariff.min_price`, so the minimum was charged.
214 Minimum,
215 /// The session cost more than `Tariff.max_price`, so the maximum was charged.
216 Maximum,
217}
218
219/// The complete, auditable result of pricing a session.
220///
221/// ```
222/// # use ocpi_kit::tariffs::CostBreakdown;
223/// # fn show(breakdown: &CostBreakdown) {
224/// for dimension in &breakdown.dimensions {
225/// println!(
226/// "{:>12} {:>8} billed (measured {:>8}) = {}",
227/// dimension.dimension, dimension.billed, dimension.measured, dimension.cost,
228/// );
229/// for segment in &dimension.segments {
230/// println!(" via {}", segment.applied);
231/// }
232/// }
233/// println!("total {} ({} incl. VAT)", breakdown.total_excl_vat, breakdown.total_incl_vat);
234/// # }
235/// ```
236#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
237pub struct CostBreakdown {
238 /// What each dimension cost.
239 pub dimensions: Vec<DimensionCost>,
240 /// The total excluding VAT, after any `min_price`/`max_price` clamp.
241 pub total_excl_vat: Number,
242 /// The total including VAT, after any clamp.
243 pub total_incl_vat: Number,
244 /// The VAT owed, grouped by percentage.
245 pub taxes: Vec<TaxLine>,
246 /// Whether a `min_price` or `max_price` changed the total.
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub limit_applied: Option<PriceLimitApplied>,
249 /// Anything the reader of this breakdown should know: see [`PricingNoteCode`].
250 ///
251 /// Empty is the ordinary case. A note is never an error — the total beside it is the engine's
252 /// best answer — but every one of them is a reason to look, and most of them are defects in
253 /// the *input* rather than in the tariff.
254 #[serde(default, skip_serializing_if = "Vec::is_empty")]
255 pub notes: Vec<PricingNote>,
256}
257
258impl CostBreakdown {
259 /// The cost of one dimension, if it was priced.
260 #[must_use]
261 pub fn dimension(&self, dimension: TariffDimensionType) -> Option<&DimensionCost> {
262 self.dimensions.iter().find(|d| d.dimension == dimension)
263 }
264
265 /// The total for one dimension, or zero if it was not priced.
266 #[must_use]
267 pub fn dimension_total(&self, dimension: TariffDimensionType) -> Number {
268 self.dimension(dimension).map_or(Number::ZERO, |d| d.cost)
269 }
270
271 /// The total VAT across all dimensions.
272 ///
273 /// Always equal to `total_incl_vat - total_excl_vat`; see [`TaxLine`].
274 #[must_use]
275 pub fn total_vat(&self) -> Number {
276 self.taxes.iter().map(|t| t.amount).sum()
277 }
278
279 /// The notes carrying `code`.
280 pub fn notes_with(&self, code: PricingNoteCode) -> impl Iterator<Item = &PricingNote> {
281 self.notes.iter().filter(move |n| n.code == code)
282 }
283
284 /// Whether anything about this session needs a human's attention.
285 ///
286 /// True when the breakdown carries any note at all. A reconciliation pipeline can use this to
287 /// split a month's CDRs into the ones that priced cleanly and the ones that did not.
288 #[must_use]
289 pub fn needs_review(&self) -> bool {
290 !self.notes.is_empty()
291 }
292
293 /// Every Price Component that contributed, in the order they were applied.
294 pub fn applied_components(&self) -> impl Iterator<Item = &AppliedComponent> {
295 self.dimensions.iter().flat_map(|d| d.segments.iter().map(|s| &s.applied))
296 }
297
298 /// The result as an OCPI 2.3.0 [`Price`](crate::v2_3_0::types::Price).
299 ///
300 /// The VAT lines become [`TaxAmount`](crate::v2_3_0::types::TaxAmount)s named `VAT`, one per
301 /// distinct percentage, which is what a Tariff's per-component `vat` fields describe.
302 #[cfg(feature = "v2_3_0")]
303 #[must_use]
304 pub fn to_price_v2_3_0(&self) -> crate::v2_3_0::types::Price {
305 crate::v2_3_0::types::Price {
306 before_taxes: self.total_excl_vat,
307 taxes: self
308 .taxes
309 .iter()
310 .map(|t| crate::v2_3_0::types::TaxAmount {
311 name: crate::types::OcpiText::new_lenient("VAT"),
312 account_number: None,
313 percentage: t.percentage,
314 amount: t.amount,
315 extensions: crate::types::Extensions::new(),
316 })
317 .collect(),
318 extensions: crate::types::Extensions::new(),
319 }
320 }
321
322 /// The result as an OCPI 2.2.1 [`Price`](crate::v2_2_1::types::Price).
323 #[cfg(feature = "v2_2_1")]
324 #[must_use]
325 pub fn to_price_v2_2_1(&self) -> crate::v2_2_1::types::Price {
326 crate::v2_2_1::types::Price {
327 excl_vat: self.total_excl_vat,
328 incl_vat: Some(self.total_incl_vat),
329 extensions: crate::types::Extensions::new(),
330 }
331 }
332}
333
334impl fmt::Display for PriceLimitApplied {
335 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336 f.write_str(match self {
337 Self::Minimum => "minimum",
338 Self::Maximum => "maximum",
339 })
340 }
341}
342
343impl fmt::Display for CostBreakdown {
344 /// The whole breakdown, **including its notes**.
345 ///
346 /// The notes are the part somebody has to act on, so they are not something the default
347 /// rendering may quietly leave out. An engine that records a finding and then prints a total
348 /// as if nothing happened is worse than one that never looked.
349 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
350 for d in &self.dimensions {
351 writeln!(
352 f,
353 "{:<13} {:>10} billed ({:>10} measured) = {:>10} excl. VAT, {:>10} VAT",
354 d.dimension.as_str(),
355 d.billed,
356 d.measured,
357 d.cost,
358 d.vat
359 )?;
360 }
361 for tax in &self.taxes {
362 let rate = tax.percentage.map_or_else(|| "unattributed".to_owned(), |p| format!("{p}%"));
363 writeln!(f, "{:<13} {:>10} on {:>10}", format!("VAT {rate}"), tax.amount, tax.taxable)?;
364 }
365 if let Some(limit) = self.limit_applied {
366 writeln!(f, "{:<13} the tariff's {limit} price limit moved the total", "LIMIT")?;
367 }
368 writeln!(
369 f,
370 "{:<13} {:>10} excl. VAT, {:>10} incl. VAT",
371 "TOTAL", self.total_excl_vat, self.total_incl_vat
372 )?;
373 for note in &self.notes {
374 write!(f, "\n[{}] {}", note.code, note.message)?;
375 if let Some(at) = note.at {
376 write!(f, " (at {at})")?;
377 }
378 }
379 Ok(())
380 }
381}