Skip to main content

ocpi_tariffs/schema/
v221.rs

1#[cfg(test)]
2mod tests;
3
4#[cfg(test)]
5mod tests_ir;
6
7#[cfg(test)]
8mod tests_cdr_ir;
9
10use super::{
11    build, ir_object, ocpi_enum, BuilderKind, Enum, Field, HasElement, Integrity, List, Number,
12    Object, Scalar, Schema, Str, Warning,
13};
14
15use crate::{json, Caveat};
16
17// Maximum character lengths for OCPI string fields, as declared in the spec
18// via the `string(n)` / `CiString(n)` notation.
19
20// -- Identifiers ---------------------------------------------------------
21const MAX_LEN_IDENTIFIER: usize = 36;
22const MAX_LEN_LONG_IDENTIFIER: usize = 39;
23const MAX_LEN_EVSE_ID: usize = 48;
24
25// -- Codes ---------------------------------------------------------------
26const MAX_LEN_CURRENCY: usize = 3;
27const MAX_LEN_PARTY_ID: usize = 3;
28const MAX_LEN_COUNTRY: usize = 3;
29const MAX_LEN_COUNTRY_CODE: usize = 2;
30const MAX_LEN_LANGUAGE: usize = 2;
31
32// -- Geographic and address ----------------------------------------------
33const MAX_LEN_LATITUDE: usize = 10;
34const MAX_LEN_LONGITUDE: usize = 11;
35const MAX_LEN_POSTAL_CODE: usize = 10;
36const MAX_LEN_ADDRESS: usize = 45;
37const MAX_LEN_CITY: usize = 45;
38const MAX_LEN_STATE: usize = 20;
39
40// -- Free text and names -------------------------------------------------
41// `time_zone` is not a `v2.2.1` field; the bound is the one `v2.1.1` sets on
42// `Location::time_zone`, since that is where a CDR carrying it copied it from.
43const MAX_LEN_TIME_ZONE: usize = 255;
44const MAX_LEN_NAME: usize = 255;
45const MAX_LEN_URL: usize = 255;
46const MAX_LEN_METER_ID: usize = 255;
47const MAX_LEN_REMARK: usize = 255;
48const MAX_LEN_ENERGY_MIX_NAME: usize = 64;
49const MAX_LEN_DISPLAY_TEXT: usize = 512;
50
51// -- Signed data ---------------------------------------------------------
52const MAX_LEN_NATURE: usize = 32;
53const MAX_LEN_PLAIN_DATA: usize = 512;
54const MAX_LEN_SIGNED_DATA: usize = 5000;
55const MAX_LEN_PUBLIC_KEY: usize = 512;
56const MAX_LEN_SIGNED_DATA_URL: usize = 512;
57
58// -- Enum value sets ---------------------------------------------------------
59// A Rust enum for each OCPI 2.2.1 enum that appears in the CDR/Tariff graph.
60// The values are matched case-insensitively. Each `ocpi_enum!`
61// generates the `VARIANTS` table used by `Scalar::Enum`.
62
63// `TokenType` (mod_tokens).
64ocpi_enum!(TokenType {
65    AdHocUser = "AD_HOC_USER",
66    AppUser = "APP_USER",
67    Other = "OTHER",
68    Rfid = "RFID",
69});
70
71// `ConnectorFormat` (mod_locations).
72ocpi_enum!(ConnectorFormat {
73    Socket = "SOCKET",
74    Cable = "CABLE",
75});
76
77// `PowerType` (mod_locations).
78ocpi_enum!(PowerType {
79    Ac1Phase = "AC_1_PHASE",
80    Ac2Phase = "AC_2_PHASE",
81    Ac2PhaseSplit = "AC_2_PHASE_SPLIT",
82    Ac3Phase = "AC_3_PHASE",
83    Dc = "DC",
84});
85
86// `ConnectorType` (mod_locations).
87ocpi_enum!(ConnectorType {
88    Chademo = "CHADEMO",
89    Chaoji = "CHAOJI",
90    DomesticA = "DOMESTIC_A",
91    DomesticB = "DOMESTIC_B",
92    DomesticC = "DOMESTIC_C",
93    DomesticD = "DOMESTIC_D",
94    DomesticE = "DOMESTIC_E",
95    DomesticF = "DOMESTIC_F",
96    DomesticG = "DOMESTIC_G",
97    DomesticH = "DOMESTIC_H",
98    DomesticI = "DOMESTIC_I",
99    DomesticJ = "DOMESTIC_J",
100    DomesticK = "DOMESTIC_K",
101    DomesticL = "DOMESTIC_L",
102    DomesticM = "DOMESTIC_M",
103    DomesticN = "DOMESTIC_N",
104    DomesticO = "DOMESTIC_O",
105    GbtAc = "GBT_AC",
106    GbtDc = "GBT_DC",
107    Iec603092Single16 = "IEC_60309_2_single_16",
108    Iec603092Three16 = "IEC_60309_2_three_16",
109    Iec603092Three32 = "IEC_60309_2_three_32",
110    Iec603092Three64 = "IEC_60309_2_three_64",
111    Iec62196T1 = "IEC_62196_T1",
112    Iec62196T1Combo = "IEC_62196_T1_COMBO",
113    Iec62196T2 = "IEC_62196_T2",
114    Iec62196T2Combo = "IEC_62196_T2_COMBO",
115    Iec62196T3A = "IEC_62196_T3A",
116    Iec62196T3C = "IEC_62196_T3C",
117    Nema520 = "NEMA_5_20",
118    Nema630 = "NEMA_6_30",
119    Nema650 = "NEMA_6_50",
120    Nema1030 = "NEMA_10_30",
121    Nema1050 = "NEMA_10_50",
122    Nema1430 = "NEMA_14_30",
123    Nema1450 = "NEMA_14_50",
124    PantographBottomUp = "PANTOGRAPH_BOTTOM_UP",
125    PantographTopDown = "PANTOGRAPH_TOP_DOWN",
126    TeslaR = "TESLA_R",
127    TeslaS = "TESLA_S",
128});
129
130// `AuthMethod` (`mod_cdrs`).
131ocpi_enum!(AuthMethod {
132    AuthRequest = "AUTH_REQUEST",
133    Command = "COMMAND",
134    Whitelist = "WHITELIST",
135});
136
137// `CdrDimensionType` (`mod_cdrs`).
138ocpi_enum!(CdrDimensionType {
139    Current = "CURRENT",
140    Energy = "ENERGY",
141    EnergyExport = "ENERGY_EXPORT",
142    EnergyImport = "ENERGY_IMPORT",
143    MaxCurrent = "MAX_CURRENT",
144    MinCurrent = "MIN_CURRENT",
145    MaxPower = "MAX_POWER",
146    MinPower = "MIN_POWER",
147    ParkingTime = "PARKING_TIME",
148    Power = "POWER",
149    ReservationTime = "RESERVATION_TIME",
150    StateOfCharge = "STATE_OF_CHARGE",
151    Time = "TIME",
152});
153
154// `TariffDimensionType` (mod_tariffs).
155ocpi_enum!(TariffDimensionType {
156    Energy = "ENERGY",
157    Flat = "FLAT",
158    ParkingTime = "PARKING_TIME",
159    Time = "TIME",
160});
161
162// `DayOfWeek` (mod_tariffs).
163ocpi_enum!(DayOfWeek {
164    Monday = "MONDAY",
165    Tuesday = "TUESDAY",
166    Wednesday = "WEDNESDAY",
167    Thursday = "THURSDAY",
168    Friday = "FRIDAY",
169    Saturday = "SATURDAY",
170    Sunday = "SUNDAY",
171});
172
173// `ReservationRestrictionType` (mod_tariffs).
174ocpi_enum!(ReservationRestrictionType {
175    Reservation = "RESERVATION",
176    ReservationExpires = "RESERVATION_EXPIRES",
177});
178
179// `TariffType` (mod_tariffs).
180ocpi_enum!(TariffType {
181    AdHocPayment = "AD_HOC_PAYMENT",
182    ProfileCheap = "PROFILE_CHEAP",
183    ProfileFast = "PROFILE_FAST",
184    ProfileGreen = "PROFILE_GREEN",
185    Regular = "REGULAR",
186});
187
188// `EnergySourceCategory` (mod_locations).
189ocpi_enum!(EnergySourceCategory {
190    Nuclear = "NUCLEAR",
191    GeneralFossil = "GENERAL_FOSSIL",
192    Coal = "COAL",
193    Gas = "GAS",
194    GeneralGreen = "GENERAL_GREEN",
195    Solar = "SOLAR",
196    Wind = "WIND",
197    Water = "WATER",
198});
199
200// `EnvironmentalImpactCategory` (mod_locations).
201ocpi_enum!(EnvironmentalImpactCategory {
202    NuclearWaste = "NUCLEAR_WASTE",
203    CarbonDioxide = "CARBON_DIOXIDE",
204});
205
206// Item schema for arrays of enum values, which serialize as JSON strings.
207static DAY_OF_WEEK: Schema = Schema::Scalar(Scalar::Enum(DayOfWeek::VARIANTS));
208
209static PRICE_OBJ: Object = Object {
210    fields: &[
211        Field::required("excl_vat", Scalar::Number),
212        Field::optional("incl_vat", Scalar::Number),
213    ],
214    kind: BuilderKind::V221Price,
215};
216
217static GEO_LOCATION_OBJ: Object = Object {
218    // Latitude/longitude are decimal-degree strings in OCPI, not numbers.
219    fields: &[
220        Field::required("latitude", Scalar::StringMax(MAX_LEN_LATITUDE)),
221        Field::required("longitude", Scalar::StringMax(MAX_LEN_LONGITUDE)),
222    ],
223    kind: BuilderKind::Ignore,
224};
225
226static CDR_TOKEN_OBJ: Object = Object {
227    fields: &[
228        Field::required("contract_id", Scalar::StringMax(MAX_LEN_IDENTIFIER)),
229        Field::required("country_code", Scalar::StringMax(MAX_LEN_COUNTRY_CODE)),
230        Field::required("party_id", Scalar::StringMax(MAX_LEN_PARTY_ID)),
231        Field::required("type", Scalar::Enum(TokenType::VARIANTS)),
232        Field::required("uid", Scalar::StringMax(MAX_LEN_IDENTIFIER)),
233    ],
234    kind: BuilderKind::Ignore,
235};
236
237static CDR_LOCATION_OBJ: Object = Object {
238    fields: &[
239        Field::required("address", Scalar::StringMax(MAX_LEN_ADDRESS)),
240        Field::required("city", Scalar::StringMax(MAX_LEN_CITY)),
241        Field::required("connector_format", Scalar::Enum(ConnectorFormat::VARIANTS)),
242        Field::required("connector_id", Scalar::StringMax(MAX_LEN_IDENTIFIER)),
243        Field::required("connector_power_type", Scalar::Enum(PowerType::VARIANTS)),
244        Field::required("connector_standard", Scalar::Enum(ConnectorType::VARIANTS)),
245        Field::required_object("coordinates", &GEO_LOCATION_OBJ),
246        Field::required("country", Scalar::StringMax(MAX_LEN_COUNTRY)),
247        Field::required("evse_id", Scalar::StringMax(MAX_LEN_EVSE_ID)),
248        Field::required("evse_uid", Scalar::StringMax(MAX_LEN_IDENTIFIER)),
249        Field::required("id", Scalar::StringMax(MAX_LEN_IDENTIFIER)),
250        Field::optional("name", Scalar::StringMax(MAX_LEN_NAME)),
251        Field::optional("postal_code", Scalar::StringMax(MAX_LEN_POSTAL_CODE)),
252        Field::optional("state", Scalar::StringMax(MAX_LEN_STATE)),
253        // `time_zone` was a `Location` field in 2.1.1 and is not part of the 2.2.1
254        // `CdrLocation`. Enough CDRs still carry it that the timezone is read from it.
255        Field::non_spec("time_zone", Scalar::StringMax(MAX_LEN_TIME_ZONE)),
256    ],
257    kind: BuilderKind::V221CdrLocation,
258};
259
260/// The `Location` a pre-2.2.1 CDR embeds, as carried by a 2.2.1 CDR that still uses the
261/// old field name.
262///
263/// Only the two fields the timezone is found or inferred from are listed. Every other
264/// field a `v2.1.1` `Location` carries is left unreported: the object as a whole is
265/// already flagged as non-spec.
266static NON_SPEC_LOCATION_OBJ: Object = Object {
267    fields: &[
268        Field::required("country", Scalar::StringMax(MAX_LEN_COUNTRY)),
269        Field::optional("time_zone", Scalar::StringMax(MAX_LEN_TIME_ZONE)),
270    ],
271    kind: BuilderKind::V221CdrLocation,
272};
273
274static CDR_DIMENSION_OBJ: Object = Object {
275    fields: &[
276        Field::required("type", Scalar::Enum(CdrDimensionType::VARIANTS)),
277        Field::required("volume", Scalar::Number),
278    ],
279    kind: BuilderKind::V221CdrDimension,
280};
281static CDR_DIMENSION: Schema = Schema::Object(&CDR_DIMENSION_OBJ);
282
283static CHARGING_PERIOD_OBJ: Object = Object {
284    fields: &[
285        Field::required_array("dimensions", &CDR_DIMENSION),
286        Field::required("start_date_time", Scalar::String),
287        Field::optional("tariff_id", Scalar::StringMax(MAX_LEN_IDENTIFIER)),
288    ],
289    kind: BuilderKind::V221ChargingPeriod,
290};
291static CHARGING_PERIOD: Schema = Schema::Object(&CHARGING_PERIOD_OBJ);
292
293static SIGNED_VALUE_OBJ: Object = Object {
294    fields: &[
295        Field::required("nature", Scalar::StringMax(MAX_LEN_NATURE)),
296        Field::required("plain_data", Scalar::StringMax(MAX_LEN_PLAIN_DATA)),
297        Field::required("signed_data", Scalar::StringMax(MAX_LEN_SIGNED_DATA)),
298    ],
299    kind: BuilderKind::Ignore,
300};
301static SIGNED_VALUE: Schema = Schema::Object(&SIGNED_VALUE_OBJ);
302
303// NOTE: encoding_method_version` is typed `int` in the 2.2.1 spec but modeled
304// here as `Scalar::String`. That is a preexisting type discrepancy, not a
305// length one, so it is left unchanged (and thus length-unconstrained).
306static SIGNED_DATA_OBJ: Object = Object {
307    fields: &[
308        Field::required("encoding_method", Scalar::StringMax(MAX_LEN_IDENTIFIER)),
309        Field::optional("encoding_method_version", Scalar::String),
310        Field::optional("public_key", Scalar::StringMax(MAX_LEN_PUBLIC_KEY)),
311        Field::required_array("signed_values", &SIGNED_VALUE),
312        Field::optional("url", Scalar::StringMax(MAX_LEN_SIGNED_DATA_URL)),
313    ],
314    kind: BuilderKind::Ignore,
315};
316
317static DISPLAY_TEXT_OBJ: Object = Object {
318    fields: &[
319        Field::required("language", Scalar::StringMax(MAX_LEN_LANGUAGE)),
320        Field::required("text", Scalar::StringMax(MAX_LEN_DISPLAY_TEXT)),
321    ],
322    kind: BuilderKind::Ignore,
323};
324static DISPLAY_TEXT: Schema = Schema::Object(&DISPLAY_TEXT_OBJ);
325
326static ENERGY_SOURCE_OBJ: Object = Object {
327    fields: &[
328        Field::required("percentage", Scalar::Number),
329        Field::required("source", Scalar::Enum(EnergySourceCategory::VARIANTS)),
330    ],
331    kind: BuilderKind::Ignore,
332};
333static ENERGY_SOURCE: Schema = Schema::Object(&ENERGY_SOURCE_OBJ);
334
335static ENVIRONMENTAL_IMPACT_OBJ: Object = Object {
336    fields: &[
337        Field::required("amount", Scalar::Number),
338        Field::required(
339            "category",
340            Scalar::Enum(EnvironmentalImpactCategory::VARIANTS),
341        ),
342    ],
343    kind: BuilderKind::Ignore,
344};
345static ENVIRONMENTAL_IMPACT: Schema = Schema::Object(&ENVIRONMENTAL_IMPACT_OBJ);
346
347static ENERGY_MIX_OBJ: Object = Object {
348    fields: &[
349        Field::optional(
350            "energy_product_name",
351            Scalar::StringMax(MAX_LEN_ENERGY_MIX_NAME),
352        ),
353        Field::optional_array("energy_sources", &ENERGY_SOURCE),
354        Field::optional_array("environ_impact", &ENVIRONMENTAL_IMPACT),
355        Field::required("is_green_energy", Scalar::Boolean),
356        Field::optional("supplier_name", Scalar::StringMax(MAX_LEN_ENERGY_MIX_NAME)),
357    ],
358    kind: BuilderKind::Ignore,
359};
360
361static PRICE_COMPONENT_OBJ: Object = Object {
362    fields: &[
363        Field::required("price", Scalar::Number),
364        Field::required("step_size", Scalar::Number),
365        Field::required("type", Scalar::Enum(TariffDimensionType::VARIANTS)),
366        Field::optional("vat", Scalar::Number),
367    ],
368    kind: BuilderKind::V221PriceComponent,
369};
370static PRICE_COMPONENT: Schema = Schema::Object(&PRICE_COMPONENT_OBJ);
371
372static TARIFF_RESTRICTIONS_OBJ: Object = Object {
373    fields: &[
374        Field::optional_array("day_of_week", &DAY_OF_WEEK),
375        Field::optional("end_date", Scalar::String),
376        Field::optional("end_time", Scalar::String),
377        Field::optional("max_current", Scalar::Number),
378        Field::optional("max_duration", Scalar::Number),
379        Field::optional("max_kwh", Scalar::Number),
380        Field::optional("max_power", Scalar::Number),
381        Field::optional("min_current", Scalar::Number),
382        Field::optional("min_duration", Scalar::Number),
383        Field::optional("min_kwh", Scalar::Number),
384        Field::optional("min_power", Scalar::Number),
385        Field::optional(
386            "reservation",
387            Scalar::Enum(ReservationRestrictionType::VARIANTS),
388        ),
389        Field::optional("start_date", Scalar::String),
390        Field::optional("start_time", Scalar::String),
391    ],
392    kind: BuilderKind::V221Restrictions,
393};
394
395static TARIFF_ELEMENT_OBJ: Object = Object {
396    fields: &[
397        Field::required_array("price_components", &PRICE_COMPONENT),
398        Field::optional_object("restrictions", &TARIFF_RESTRICTIONS_OBJ),
399    ],
400    kind: BuilderKind::V221Element,
401};
402static TARIFF_ELEMENT: Schema = Schema::Object(&TARIFF_ELEMENT_OBJ);
403
404// -- Tariff ------------------------------------------------------------------
405
406static TARIFF_OBJ: Object = Object {
407    fields: &[
408        Field::required("country_code", Scalar::StringMax(MAX_LEN_COUNTRY_CODE)),
409        Field::required("currency", Scalar::StringMax(MAX_LEN_CURRENCY)),
410        Field::required_array("elements", &TARIFF_ELEMENT),
411        Field::optional("end_date_time", Scalar::String),
412        Field::optional_object("energy_mix", &ENERGY_MIX_OBJ),
413        Field::required("id", Scalar::StringMax(MAX_LEN_IDENTIFIER)),
414        Field::required("last_updated", Scalar::String),
415        Field::optional_price("max_price", &PRICE_OBJ),
416        Field::optional_price("min_price", &PRICE_OBJ),
417        Field::required("party_id", Scalar::StringMax(MAX_LEN_PARTY_ID)),
418        Field::optional("start_date_time", Scalar::String),
419        Field::optional_array("tariff_alt_text", &DISPLAY_TEXT),
420        Field::optional("tariff_alt_url", Scalar::StringMax(MAX_LEN_URL)),
421        Field::optional("type", Scalar::Enum(TariffType::VARIANTS)),
422    ],
423    kind: BuilderKind::V221Tariff,
424};
425
426/// OCPI 2.2.1 Tariff schema.
427///
428/// Validates top-level `Tariff` objects: required fields are `country_code`,
429/// `currency`, `elements`, `id`, `last_updated`, and `party_id`.
430static TARIFF: Schema = Schema::Object(&TARIFF_OBJ);
431
432// -- CDR ---------------------------------------------------------------------
433
434static CDR_OBJ: Object = Object {
435    fields: &[
436        Field::required("auth_method", Scalar::Enum(AuthMethod::VARIANTS)),
437        Field::optional(
438            "authorization_reference",
439            Scalar::StringMax(MAX_LEN_IDENTIFIER),
440        ),
441        Field::required_object("cdr_location", &CDR_LOCATION_OBJ),
442        Field::required_object("cdr_token", &CDR_TOKEN_OBJ),
443        Field::required_array("charging_periods", &CHARGING_PERIOD),
444        Field::required("country_code", Scalar::StringMax(MAX_LEN_COUNTRY_CODE)),
445        Field::optional("credit", Scalar::Boolean),
446        Field::optional(
447            "credit_reference_id",
448            Scalar::StringMax(MAX_LEN_LONG_IDENTIFIER),
449        ),
450        Field::required("currency", Scalar::StringMax(MAX_LEN_CURRENCY)),
451        Field::required("end_date_time", Scalar::String),
452        Field::optional("home_charging_compensation", Scalar::Boolean),
453        Field::required("id", Scalar::StringMax(MAX_LEN_LONG_IDENTIFIER)),
454        Field::optional(
455            "invoice_reference_id",
456            Scalar::StringMax(MAX_LEN_LONG_IDENTIFIER),
457        ),
458        Field::required("last_updated", Scalar::String),
459        // 2.2.1 renamed `location` to `cdr_location`. A CDR that still uses the old name
460        // is read from it, so the timezone can be found the same way.
461        Field::non_spec_object("location", &NON_SPEC_LOCATION_OBJ),
462        Field::optional("meter_id", Scalar::StringMax(MAX_LEN_METER_ID)),
463        Field::required("party_id", Scalar::StringMax(MAX_LEN_PARTY_ID)),
464        Field::optional("remark", Scalar::StringMax(MAX_LEN_REMARK)),
465        Field::optional("session_id", Scalar::StringMax(MAX_LEN_IDENTIFIER)),
466        Field::optional_object("signed_data", &SIGNED_DATA_OBJ),
467        Field::required("start_date_time", Scalar::String),
468        Field::optional_array("tariffs", &TARIFF),
469        Field::required_price("total_cost", &PRICE_OBJ),
470        Field::required("total_energy", Scalar::Number),
471        Field::optional_price("total_energy_cost", &PRICE_OBJ),
472        Field::optional_price("total_fixed_cost", &PRICE_OBJ),
473        Field::optional_price("total_parking_cost", &PRICE_OBJ),
474        Field::optional("total_parking_time", Scalar::Number),
475        Field::optional_price("total_reservation_cost", &PRICE_OBJ),
476        Field::required("total_time", Scalar::Number),
477        Field::optional_price("total_time_cost", &PRICE_OBJ),
478    ],
479    kind: BuilderKind::V221Cdr,
480};
481
482/// OCPI 2.2.1 CDR (Charge Detail Record) schema.
483///
484/// Validates top-level `CDR` objects including all nested types: `CdrToken`,
485/// `CdrLocation`, `GeoLocation`, `ChargingPeriod`, `CdrDimension`,
486/// `SignedData`, `SignedValue`, `Price`, and embedded `Tariff` objects.
487static CDR: Schema = Schema::Object(&CDR_OBJ);
488
489// -- CDR intermediate representation (IR) ------------------------------------
490//
491// The CDR IR models the fields pricing and validation consume: the session window,
492// currency, charging periods (each with its CDR dimensions), the cost/volume totals,
493// and the embedded `tariffs`. As with the tariff IR, every field is an `Integrity`
494// value, so building is infallible. Fields the spec defines but this layer does not
495// use (`cdr_token`, `signed_data`, the various identifiers) are validated by the walk
496// but not represented here. Of `cdr_location` only what the timezone is found or
497// inferred from is kept.
498
499/// The intermediate representation of a `v2.2.1` CDR.
500#[derive(Clone, Debug)]
501pub(crate) struct Cdr<'buf> {
502    pub country_code: Integrity<Str<'buf>>,
503    pub party_id: Integrity<Str<'buf>>,
504    pub currency: Integrity<Str<'buf>>,
505    pub start_date_time: Integrity<Str<'buf>>,
506    pub end_date_time: Integrity<Str<'buf>>,
507    pub charging_periods: Integrity<List<'buf, ChargingPeriod<'buf>>>,
508    pub total_cost: Integrity<Price<'buf>>,
509    pub total_energy: Integrity<Number<'buf>>,
510    pub total_time: Integrity<Number<'buf>>,
511    pub total_parking_time: Integrity<Option<Number<'buf>>>,
512    pub total_energy_cost: Integrity<Option<Price<'buf>>>,
513    pub total_fixed_cost: Integrity<Option<Price<'buf>>>,
514    pub total_parking_cost: Integrity<Option<Price<'buf>>>,
515    pub total_reservation_cost: Integrity<Option<Price<'buf>>>,
516    pub total_time_cost: Integrity<Option<Price<'buf>>>,
517    pub tariffs: Integrity<Option<List<'buf, Tariff<'buf>>>>,
518    #[expect(
519        clippy::struct_field_names,
520        reason = "every IR field is named after the OCPI field it holds"
521    )]
522    pub cdr_location: Integrity<CdrLocation<'buf>>,
523    /// The `location` object a CDR that still uses the pre-2.2.1 field name carries.
524    /// `Missing` for a spec-compliant CDR.
525    pub location: Integrity<CdrLocation<'buf>>,
526}
527
528/// The intermediate representation of the `CdrLocation` a `v2.2.1` CDR embeds.
529///
530/// Only the two fields the timezone is found or inferred from are retained. `time_zone` is
531/// not a `v2.2.1` field; it is read anyway and reported as
532/// [`schema::Warning::NonSpecField`](crate::schema::Warning::NonSpecField).
533#[derive(Clone, Debug)]
534pub(crate) struct CdrLocation<'buf> {
535    pub country: Integrity<Str<'buf>>,
536    pub time_zone: Integrity<Option<Str<'buf>>>,
537}
538
539/// The intermediate representation of a `v2.2.1` CDR charging period.
540#[derive(Clone, Debug)]
541pub(crate) struct ChargingPeriod<'buf> {
542    pub start_date_time: Integrity<Str<'buf>>,
543    pub dimensions: Integrity<List<'buf, Dimension<'buf>>>,
544}
545
546/// The intermediate representation of a `v2.2.1` CDR dimension.
547#[derive(Clone, Debug)]
548pub(crate) struct Dimension<'buf> {
549    pub dimension_type: Integrity<Enum<'buf, CdrDimensionType>>,
550    pub volume: Integrity<Number<'buf>>,
551}
552
553/// The intermediate representation of a `v2.2.1` tariff.
554#[derive(Clone, Debug)]
555pub(crate) struct Tariff<'buf> {
556    pub country_code: Integrity<Str<'buf>>,
557    pub currency: Integrity<Str<'buf>>,
558    pub id: Integrity<Str<'buf>>,
559    pub party_id: Integrity<Str<'buf>>,
560    pub min_price: Integrity<Option<Price<'buf>>>,
561    pub max_price: Integrity<Option<Price<'buf>>>,
562    pub start_date_time: Integrity<Option<Str<'buf>>>,
563    pub end_date_time: Integrity<Option<Str<'buf>>>,
564    pub elements: Integrity<List<'buf, Element<'buf>>>,
565}
566
567/// The intermediate representation of a tariff element.
568#[derive(Clone, Debug)]
569pub(crate) struct Element<'buf> {
570    pub price_components: Integrity<List<'buf, PriceComponent<'buf>>>,
571    pub restrictions: Integrity<Option<Restrictions<'buf>>>,
572}
573
574/// The intermediate representation of a price component.
575#[derive(Clone, Debug)]
576pub(crate) struct PriceComponent<'buf> {
577    pub dimension_type: Integrity<Enum<'buf, TariffDimensionType>>,
578    pub vat: Integrity<Option<Number<'buf>>>,
579    pub price: Integrity<Number<'buf>>,
580    pub step_size: Integrity<Number<'buf>>,
581}
582
583/// The intermediate representation of a `Price`.
584///
585/// OCPI 2.1.1 wrote a price as a bare `number`; 2.2.1 made it a `Price` object. Both
586/// forms are accepted (see [`super::Schema::Price`]); the variant records which was
587/// seen, just as [`super::Number`] records whether a number was written bare or
588/// string-encoded.
589#[derive(Clone, Debug)]
590pub(crate) enum Price<'buf> {
591    /// A JSON `Price` object.
592    Object {
593        /// The element this object was built from. The lowering step attaches its
594        /// object-level warnings (such as excl-vat-greater-than-incl-vat) to it.
595        elem: json::Element<'buf>,
596        excl_vat: Integrity<Number<'buf>>,
597        incl_vat: Integrity<Option<Number<'buf>>>,
598    },
599    /// A bare JSON number (the 2.1.1 shape), taken as `excl_vat` with no `incl_vat`.
600    Number(Number<'buf>),
601}
602
603impl<'buf> Price<'buf> {
604    /// Create an empty `Price` object builder for the object at `elem`. Its fields start
605    /// `Missing` and are filled by the walk as the object's children close.
606    pub(super) fn new(elem: json::Element<'buf>) -> Self {
607        Self::Object {
608            excl_vat: Integrity::Missing(crate::warning::Element::from_json(&elem)),
609            incl_vat: Integrity::Missing(crate::warning::Element::from_json(&elem)),
610            elem,
611        }
612    }
613
614    /// Build a `Price` from a bare JSON number. Per OCPI's evolution (a price was a bare
615    /// `number` in 2.1.1 and became a `Price` object in 2.2.1) the number is taken as
616    /// `excl_vat`, leaving `incl_vat` absent.
617    pub(super) fn from_number(elem: json::Element<'buf>, digits: &'buf str) -> Self {
618        Self::Number(Number::Number { elem, digits })
619    }
620}
621
622impl<'buf> HasElement<'buf> for Price<'buf> {
623    fn element(&self) -> &json::Element<'buf> {
624        match self {
625            Self::Object { elem, .. } => elem,
626            Self::Number(number) => number.element(),
627        }
628    }
629}
630
631/// The intermediate representation of a tariff element's restrictions.
632#[derive(Clone, Debug)]
633pub(crate) struct Restrictions<'buf> {
634    pub start_time: Integrity<Option<Str<'buf>>>,
635    pub end_time: Integrity<Option<Str<'buf>>>,
636    pub start_date: Integrity<Option<Str<'buf>>>,
637    pub end_date: Integrity<Option<Str<'buf>>>,
638    pub min_kwh: Integrity<Option<Number<'buf>>>,
639    pub max_kwh: Integrity<Option<Number<'buf>>>,
640    pub min_current: Integrity<Option<Number<'buf>>>,
641    pub max_current: Integrity<Option<Number<'buf>>>,
642    pub min_power: Integrity<Option<Number<'buf>>>,
643    pub max_power: Integrity<Option<Number<'buf>>>,
644    pub min_duration: Integrity<Option<Number<'buf>>>,
645    pub max_duration: Integrity<Option<Number<'buf>>>,
646    pub day_of_week: Integrity<Option<List<'buf, Enum<'buf, DayOfWeek>>>>,
647    pub reservation: Integrity<Option<Enum<'buf, ReservationRestrictionType>>>,
648}
649
650// All-`Missing` `new` constructors, replacing the `#[derive(Default)]` that
651// [`Integrity`]'s element-carrying `Missing`/`Err` no longer permit.
652ir_object!(Cdr {
653    country_code,
654    party_id,
655    currency,
656    start_date_time,
657    end_date_time,
658    charging_periods,
659    total_cost,
660    total_energy,
661    total_time,
662    total_parking_time,
663    total_energy_cost,
664    total_fixed_cost,
665    total_parking_cost,
666    total_reservation_cost,
667    total_time_cost,
668    tariffs,
669    cdr_location,
670    location,
671});
672ir_object!(CdrLocation { country, time_zone });
673ir_object!(ChargingPeriod {
674    start_date_time,
675    dimensions,
676});
677ir_object!(Dimension {
678    dimension_type,
679    volume,
680});
681ir_object!(Tariff {
682    country_code,
683    currency,
684    id,
685    party_id,
686    min_price,
687    max_price,
688    start_date_time,
689    end_date_time,
690    elements,
691});
692ir_object!(Element {
693    price_components,
694    restrictions,
695});
696ir_object!(PriceComponent {
697    dimension_type,
698    vat,
699    price,
700    step_size,
701});
702ir_object!(Restrictions {
703    start_time,
704    end_time,
705    start_date,
706    end_date,
707    min_kwh,
708    max_kwh,
709    min_current,
710    max_current,
711    min_power,
712    max_power,
713    min_duration,
714    max_duration,
715    day_of_week,
716    reservation,
717});
718
719/// Build and validate a `v2.2.1` tariff from a `json::Document`.
720///
721/// Building is infallible: the returned [`Tariff`] always exists, with each field in
722/// an [`Integrity`] state, alongside the [`Warning`]s found during the walk.
723pub(crate) fn build_tariff<'buf>(doc: &json::Document<'buf>) -> Caveat<Tariff<'buf>, Warning> {
724    let (node, warnings) = super::walk(doc, &TARIFF).into_parts();
725
726    // An object root yields `Node::Tariff` (the `TARIFF` schema's `BuilderKind`); a
727    // non-object root yields no tariff builder, so it falls back to an empty tariff.
728    let tariff = if let build::Node::Tariff(tariff) = node {
729        tariff
730    } else {
731        Tariff::new(doc.root())
732    };
733
734    Caveat::new(tariff, warnings)
735}
736
737/// Build and validate a `v2.2.1` CDR from a `json::Document`.
738///
739/// Building is infallible: the returned [`Cdr`] always exists, with each field in
740/// an [`Integrity`] state, alongside the [`Warning`]s found during the walk.
741pub(crate) fn build_cdr<'buf>(doc: &json::Document<'buf>) -> Caveat<Cdr<'buf>, Warning> {
742    let (node, warnings) = super::walk(doc, &CDR).into_parts();
743
744    // An object root yields `Node::Cdr` (the `CDR` schema's `BuilderKind`); a
745    // non-object root yields no CDR builder, so it falls back to an empty CDR.
746    let cdr = if let build::Node::Cdr(cdr) = node {
747        cdr
748    } else {
749        Cdr::new(doc.root())
750    };
751
752    Caveat::new(cdr, warnings)
753}