Skip to main content

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}