Tariffs
An auditable pricing engine: what a charging session cost, per dimension, and exactly why.
On this page
OCPI is the only protocol that carries both the tariff and the metering data, which means the cost of a charging session is computable from what crosses the wire. That is how an eMSP checks a CPO's invoice, how a CPO checks its own, and how the Payments module's financial advice confirmations get reconciled against the CDRs they belong to.
let breakdown = PricingEngine::new().price(&session, &tariffs)?;
assert_eq!(breakdown.total_excl_vat.to_string(), "5.00");
assert_eq!(breakdown.total_incl_vat.to_string(), "5.50");What makes this engine different
The answer is auditable. CostBreakdown does not just say 12.28. It says, for each
dimension, which quantity was billed, what step_size did to it, which Tariff Element and which
Price Component priced it, and why that element was selected:
CostBreakdown
├── dimensions: Vec<DimensionCost> one per ENERGY / TIME / PARKING_TIME / FLAT …
│ ├── measured, billed, cost, vat what was metered, what step_size made of it, what it cost
│ └── segments: Vec<PricedSegment> the split where the applicable element changed
│ └── applied: AppliedComponent tariff id, element index, component index, and `because`
├── total_excl_vat / total_incl_vat
├── taxes: Vec<TaxLine> name, percentage, taxable base, amount
├── limit_applied: Option<PriceLimitApplied> min_price / max_price, if either bit
└── notes: Vec<String> anything the engine wants you to know
AppliedComponent::because is a sentence, not a code: why that Tariff Element matched this
segment. That is the field that ends an invoice dispute.
It serialises, so a disputed invoice becomes a diff of two JSON documents rather than an argument.
The arithmetic is exact. Every value is a decimal. There is no f64 anywhere in this module.
See Numbers and money.
The undefined parts are parameters. The specification says nothing about rounding, on purpose,
and OCPI 3.0 removes step_size altogether. Both are settings on PricingPolicy rather than
assumptions baked into the code.
And the breakdown survives being written down. A duration in hours is a repeating decimal —
eight minutes is 0.1333… — so a report that carried its measurements verbatim would hold values a
JSON number cannot, which this crate's own validator flags as imprecise. quantity_decimals
(default 6, enough for a second and a tenth of a watt hour) decides what the breakdown says was
measured; money is computed from the exact quantity and rounded separately by component_decimals.
Rounding the report and rounding the charge are different decisions, so they are different
settings. A test asserts that a whole breakdown round-trips: an audit artefact that does not
survive being stored is not an audit artefact.
And it audits the CDR it is pricing. See below.
When the CDR is the problem
A Charging Period carries totals, not a curve. There is no way to know how much of its energy fell before a tariff switched and how much after — so the specification puts the obligation on the CPO:
A CPO SHALL at least start (and add) a ChargingPeriod every moment/event that has relevance for the total costs of a CDR. … When an energy changes in price after 17:00. The CPO has to start a new Charging Period at 17:00.
Every implementation assumes that holds and prices each period at the rate that applied when it
began. ocpi-tariffs documents the assumption plainly: "No attempt will be made to subdivide or
interpolate data inside a single provided period." The assumption is right — there is nothing
better to do with the data. The silence is the problem.
This engine re-evaluates the restrictions at the moment each period ends. If a different Price Component would apply by then, the period outlasted its price, and the breakdown says so:
[period_spans_price_change] the ENERGY Charging Period starting here outlasts the Price Component
that prices it: element 1 applies at the start and element 0 by the time the period ends. A CPO
SHALL start a new Charging Period at a price change, so this one should have been split; its
ENERGY is billed in full at the earlier rate, because nothing in the period says how it divides
The total beside it is unchanged — nothing is guessed or interpolated. What changed is that a
defect which produces a plausible number is now a line somebody can act on. A CDR can total
correctly by luck and still be malformed, which is why ocpi price exits non-zero on a note as
well as on a mismatch.
Two other things are checked the same way: Charging Periods that are not in chronological order
(step_size is defined in terms of "the last relevant PriceComponent", so out of order it is
quietly wrong), and dimensions the tariff prices nothing for.
Notes carry a machine-readable code, not just a sentence, because a reconciliation pipeline has to be able to count how many of this month's CDRs span a price change:
let breakdown = PricingEngine::new().price(&session, &tariffs)?;
if breakdown.needs_review() {
for note in breakdown.notes_with(PricingNoteCode::PeriodSpansPriceChange) {
tracing::warn!(at = %note.at.unwrap(), "{}", note.message);
}
}The breakdown adds up
The tax lines of a breakdown always sum to exactly total_incl_vat - total_excl_vat. That sounds
free and is not — three things break it, and none is visible in an assertion on a total:
- Precision. Lines accumulated at
component_decimals(four places) beside totals rounded tocurrency_decimals(two) leave a 2% VAT printing as500.4720next to totals differing by500.47. Half a cent, and an audit finding. Lines are rounded to currency precision, and the last one absorbs the residue so they sum to the difference exactly. - A price limit. A
min_priceclamp raises the pre-tax total; leaving the tax lines describing what was metered makes a €0.50 session under a €5.00 minimum come out as €5.00 net with €0.00 VAT and a line still claiming €0.105. The clamp moves the lines with the base, in proportion. - A malformed tariff. A negative
vatpercentage describes a session that costs less with tax than without. The inclusive total is held at the exclusive one and aNegativeTaxnote names the cause, rather than publishing a bill nobody can use.
A property test asserts the invariant over generated tariffs and sessions, and the generator produces malformed tariffs deliberately: one that only produces well-formed input proves the engine works on well-formed input, which is not the interesting half.
Where a min_price.after_taxes demands tax that no rate in the session accounts for, the line is
emitted with percentage: None — the amount is a fact, the rate is not knowable, and inventing one
would be a lie in a document somebody files.
step_size, precisely
This is the rule most implementations get wrong, and the spec states it carefully:
- an
ENERGYstep_sizeis applied once per session, to the total, not per period TIMEandPARKING_TIMEstep_sizeis applied once, to the two combined, andPARKING_TIMEis what absorbs it whenever the session has any — "In the cases thatTIMEandPARKING_TIMETariff Elements are both used,step_sizeis only taken into account for the total parking duration". The specification's own worked example reaches the same answer by a different route ("the charging duration is not rounded up, as it is followed by another time based period"); the two readings agree on every session that charges and then parks, and this engine follows the sentence- a
step_sizeof0means no quantisation — the specification's own free-of-charge example uses it — while astep_sizeof1is meaningful and is applied
Quantisation is a pluggable stage: StepSize for OCPI 2.x, off for 3.0-style full-precision
metering.
Restrictions in local time
TariffRestrictions evaluate start_time/end_time and day_of_week in the Location's local
time, not UTC — which is the difference between a night tariff starting at 22:00 and starting at
20:00 in summer. The engine takes an IANA time zone (TimeZone::named("Europe/Berlin")) and
resolves it with a bundled tzdb, so there is no dependency on the host's zoneinfo files.
Also evaluated: kWh, current, power and duration windows, reservation, and element switching
mid-period.
Two of those need a decision the specification does not make.
start_time == end_time is the whole day. The spec is silent, and the two readings are far
apart: as an empty interval the element never matches, through the wrap-around rule it always
does. This crate takes the whole day, because it is what the wrap rule produces with no special
case and because it fails safe. An element that never matches leaves its dimension with no Price
Component, and the specification's answer to that is that the dimension is free — so the other
reading silently gives the energy away.
Reserved time is priced separately from charging time. A ChargingPeriod can carry TIME and
RESERVATION_TIME at once, and the two are priced by different Tariff Elements — one restricted
with reservation, one not. Summing them and looking the total up once would bill the charging
minutes at the reservation rate, usually the dearer of the two. Each is looked up in the context
that describes it, and both appear as their own segment in the breakdown. They still share the
TIME dimension, and so the one step_size budget the specification allows.
Multiple tariffs
A Connector can list several tariff_ids in preference order, each with its own validity window.
The engine selects per period, records which tariff it chose in the breakdown, and falls back
through the list the way the specification describes. min_price and max_price (2.3.0's
PriceLimit, with after_taxes) are applied to the session as a whole.
Conformance
Ten of the specification's own worked tariff examples are tests in this repository, and they
pass — including the free-of-charge case, the ad-hoc payment cases, and the ones with combined
time and parking step_size.
Two more are snapshots: the step_size example rendered in
full, next to the same session priced under the OCPI 3.0 policy that has no step_size at all.
Side by side, that pair is the clearest statement of what block billing costs a driver.
Checking a CDR against itself
$ ocpi price cdr.json --tariff tariff.json --time-zone Europe/Berlin
prints the breakdown and then compares the total with the one the CDR claims. That is the invoice check, and it is one command.