Skip to main content

ocpi_kit/convert/
v2_2_1_v2_3_0.rs

1//! Conversions between OCPI 2.2.1 and OCPI 2.3.0.
2//!
3//! This is the bridge a hub needs today: 2.2.1 is what nearly everything in production speaks,
4//! and 2.3.0 is what regulation (EU AFIR, North American tax reporting) is pushing parties
5//! towards.
6//!
7//! Every field that OCPI 2.3.0 added is listed in [`v2_2_1`](crate::v2_2_1)'s module
8//! documentation, and each one appears below either as a documented default (going forward) or as
9//! a recorded [`Loss`](super::Loss) (going back).
10
11use crate::v2_2_1 as old;
12use crate::v2_3_0 as new;
13
14use super::{Converted, Downgrade, Lossy, Upgrade};
15
16// -------------------------------------------------------------------------------------------
17// Price
18// -------------------------------------------------------------------------------------------
19
20/// The tax name this crate writes when turning a 2.2.1 `incl_vat` into a 2.3.0 tax line.
21///
22/// A 2.2.1 `Price` says only "excluding VAT" and "including VAT"; it does not name the tax. `VAT`
23/// is the name the specification itself uses for it throughout the 2.2.1 text.
24pub const IMPLIED_VAT_NAME: &str = "VAT";
25
26impl Upgrade<new::types::Price> for old::types::Price {
27    /// `before_taxes` is the pre-VAT amount; when `incl_vat` is present, the difference becomes a
28    /// single [`TaxAmount`](crate::v2_3_0::types::TaxAmount) named `VAT`.
29    ///
30    /// A 2.2.1 price with no `incl_vat` becomes a 2.3.0 price with no taxes, which is exactly the
31    /// same statement: the amount of tax is not given.
32    fn upgrade(self) -> Converted<new::types::Price> {
33        let mut taxes = Vec::new();
34        if let Some(incl) = self.incl_vat {
35            let amount = incl - self.excl_vat;
36            taxes.push(new::types::TaxAmount {
37                name: crate::types::OcpiText::new_lenient(IMPLIED_VAT_NAME),
38                account_number: None,
39                percentage: None,
40                amount,
41                extensions: crate::types::Extensions::new(),
42            });
43        }
44        Converted::lossless(new::types::Price {
45            before_taxes: self.excl_vat,
46            taxes,
47            extensions: self.extensions,
48        })
49    }
50}
51
52impl Downgrade<old::types::Price> for new::types::Price {
53    /// `excl_vat` is `before_taxes`; `incl_vat` is the sum of every tax on top of it.
54    ///
55    /// The 2.2.1 shape can hold one number, so the *names*, percentages and account numbers of
56    /// the individual taxes are lost. That matters in Canada, where a receipt must itemise GST
57    /// and QST separately, so each one is reported.
58    fn downgrade(self) -> Converted<old::types::Price> {
59        let mut lossy = Lossy::none();
60        let incl_vat = if self.taxes.is_empty() { None } else { Some(self.after_taxes()) };
61        for (i, tax) in self.taxes.iter().enumerate() {
62            let carries_detail = tax.percentage.is_some() || tax.account_number.is_some();
63            if self.taxes.len() > 1 || tax.name.as_str() != IMPLIED_VAT_NAME || carries_detail {
64                lossy.record(
65                    format!("/taxes/{i}"),
66                    format!(
67                        "the tax {:?} was folded into `incl_vat`; OCPI 2.2.1 has no way to name \
68                         or itemise a tax",
69                        tax.name.as_str()
70                    ),
71                );
72            }
73        }
74        Converted::new(
75            old::types::Price { excl_vat: self.before_taxes, incl_vat, extensions: self.extensions },
76            lossy,
77        )
78    }
79}
80
81// -------------------------------------------------------------------------------------------
82// Role
83// -------------------------------------------------------------------------------------------
84
85impl Upgrade<new::types::Role> for old::types::Role {
86    /// `HUB` has no counterpart: OCPI 2.3.0 removed it and identifies a hub through
87    /// `Credentials.hub_party_id` instead. It maps to `OTHER`, and the
88    /// [`Credentials`](crate::v2_2_1::credentials::Credentials) conversion moves the information
89    /// to where 2.3.0 keeps it.
90    fn upgrade(self) -> Converted<new::types::Role> {
91        use new::types::Role as N;
92        use old::types::Role as O;
93        let mut lossy = Lossy::none();
94        let value = match self {
95            O::Cpo => N::Cpo,
96            O::Emsp => N::Emsp,
97            O::Nap => N::Nap,
98            O::Nsp => N::Nsp,
99            O::Other => N::Other,
100            O::Scsp => N::Scsp,
101            O::Hub => {
102                lossy.record(
103                    "",
104                    "the HUB role was removed in OCPI 2.3.0 and became OTHER; a hub is identified \
105                     by `Credentials.hub_party_id` there",
106                );
107                N::Other
108            }
109        };
110        Converted::new(value, lossy)
111    }
112}
113
114impl Downgrade<old::types::Role> for new::types::Role {
115    /// Total: every 2.3.0 role exists in 2.2.1.
116    fn downgrade(self) -> Converted<old::types::Role> {
117        use new::types::Role as N;
118        use old::types::Role as O;
119        Converted::lossless(match self {
120            N::Cpo => O::Cpo,
121            N::Emsp => O::Emsp,
122            N::Nap => O::Nap,
123            N::Nsp => O::Nsp,
124            N::Other => O::Other,
125            N::Scsp => O::Scsp,
126        })
127    }
128}
129
130// -------------------------------------------------------------------------------------------
131// Enumerations that were opened in 2.3.0
132//
133// Both sides keep the wire value verbatim, so these conversions are total in both directions:
134// a 2.3.0 `ConnectorType::Mcs` becomes a 2.2.1 `ConnectorType::Custom("MCS")`, which is the same
135// string on the wire, and upgrades straight back.
136// -------------------------------------------------------------------------------------------
137
138macro_rules! convert_by_wire_value {
139    ($($old:path => $new:path),* $(,)?) => {$(
140        impl Upgrade<$new> for $old {
141            fn upgrade(self) -> Converted<$new> {
142                Converted::lossless(<$new>::from(self.as_str()))
143            }
144        }
145        impl Downgrade<$old> for $new {
146            fn downgrade(self) -> Converted<$old> {
147                Converted::lossless(<$old>::from(self.as_str()))
148            }
149        }
150    )*};
151}
152
153convert_by_wire_value! {
154    old::locations::ConnectorType => new::locations::ConnectorType,
155    old::locations::ParkingRestriction => new::locations::ParkingRestriction,
156    old::tokens::TokenType => new::tokens::TokenType,
157}
158
159// -------------------------------------------------------------------------------------------
160// Locations
161// -------------------------------------------------------------------------------------------
162
163impl Upgrade<new::locations::PublishTokenType> for old::locations::PublishTokenType {
164    fn upgrade(self) -> Converted<new::locations::PublishTokenType> {
165        let token_type = self.token_type.map(|t| t.upgrade().value);
166        Converted::lossless(new::locations::PublishTokenType {
167            uid: self.uid,
168            token_type,
169            visual_number: self.visual_number,
170            issuer: self.issuer,
171            group_id: self.group_id,
172            extensions: self.extensions,
173        })
174    }
175}
176
177impl Downgrade<old::locations::PublishTokenType> for new::locations::PublishTokenType {
178    fn downgrade(self) -> Converted<old::locations::PublishTokenType> {
179        let token_type = self.token_type.map(|t| t.downgrade().value);
180        Converted::lossless(old::locations::PublishTokenType {
181            uid: self.uid,
182            token_type,
183            visual_number: self.visual_number,
184            issuer: self.issuer,
185            group_id: self.group_id,
186            extensions: self.extensions,
187        })
188    }
189}
190
191impl Upgrade<new::locations::Connector> for old::locations::Connector {
192    /// `capabilities` is new in 2.3.0 and starts empty: a 2.2.1 CPO said nothing about Plug &
193    /// Charge support, and claiming it on their behalf would be wrong.
194    fn upgrade(self) -> Converted<new::locations::Connector> {
195        Converted::lossless(new::locations::Connector {
196            id: self.id,
197            standard: self.standard.upgrade().value,
198            format: self.format,
199            power_type: self.power_type,
200            max_voltage: self.max_voltage,
201            max_amperage: self.max_amperage,
202            max_electric_power: self.max_electric_power,
203            tariff_ids: self.tariff_ids,
204            terms_and_conditions: self.terms_and_conditions,
205            capabilities: Vec::new(),
206            last_updated: self.last_updated,
207            extensions: self.extensions,
208        })
209    }
210}
211
212impl Downgrade<old::locations::Connector> for new::locations::Connector {
213    fn downgrade(self) -> Converted<old::locations::Connector> {
214        let mut lossy = Lossy::none();
215        if !self.capabilities.is_empty() {
216            lossy.record(
217                "/capabilities",
218                "OCPI 2.2.1 has no Connector.capabilities, so ISO 15118 Plug & Charge support \
219                 cannot be expressed",
220            );
221        }
222        Converted::new(
223            old::locations::Connector {
224                id: self.id,
225                standard: self.standard.downgrade().value,
226                format: self.format,
227                power_type: self.power_type,
228                max_voltage: self.max_voltage,
229                max_amperage: self.max_amperage,
230                max_electric_power: self.max_electric_power,
231                tariff_ids: self.tariff_ids,
232                terms_and_conditions: self.terms_and_conditions,
233                last_updated: self.last_updated,
234                extensions: self.extensions,
235            },
236            lossy,
237        )
238    }
239}
240
241impl Upgrade<new::locations::Evse> for old::locations::Evse {
242    fn upgrade(self) -> Converted<new::locations::Evse> {
243        let mut lossy = Lossy::none();
244        let mut connectors = Vec::with_capacity(self.connectors.len());
245        for (i, connector) in self.connectors.into_iter().enumerate() {
246            let converted = connector.upgrade();
247            lossy.absorb(&format!("/connectors/{i}"), converted.lossy);
248            connectors.push(converted.value);
249        }
250        Converted::new(
251            new::locations::Evse {
252                uid: self.uid,
253                evse_id: self.evse_id,
254                status: self.status,
255                status_schedule: self.status_schedule,
256                capabilities: self.capabilities,
257                connectors,
258                floor_level: self.floor_level,
259                coordinates: self.coordinates,
260                physical_reference: self.physical_reference,
261                directions: self.directions,
262                parking_restrictions: self
263                    .parking_restrictions
264                    .into_iter()
265                    .map(|p| p.upgrade().value)
266                    .collect(),
267                parking: Vec::new(),
268                images: self.images,
269                accepted_service_providers: Vec::new(),
270                last_updated: self.last_updated,
271                extensions: self.extensions,
272            },
273            lossy,
274        )
275    }
276}
277
278impl Downgrade<old::locations::Evse> for new::locations::Evse {
279    fn downgrade(self) -> Converted<old::locations::Evse> {
280        let mut lossy = Lossy::none();
281        if !self.parking.is_empty() {
282            lossy.record(
283                "/parking",
284                format!(
285                    "{} EVSEParking reference(s) dropped: OCPI 2.2.1 has no Parking object, which \
286                     is what EU AFIR reporting to a National Access Point needs",
287                    self.parking.len()
288                ),
289            );
290        }
291        if !self.accepted_service_providers.is_empty() {
292            lossy.record(
293                "/accepted_service_providers",
294                "OCPI 2.2.1 has no EVSE.accepted_service_providers; the list of eMSPs accepted at \
295                 this EVSE cannot be expressed",
296            );
297        }
298        let mut connectors = Vec::with_capacity(self.connectors.len());
299        for (i, connector) in self.connectors.into_iter().enumerate() {
300            let converted = connector.downgrade();
301            lossy.absorb(&format!("/connectors/{i}"), converted.lossy);
302            connectors.push(converted.value);
303        }
304        Converted::new(
305            old::locations::Evse {
306                uid: self.uid,
307                evse_id: self.evse_id,
308                status: self.status,
309                status_schedule: self.status_schedule,
310                capabilities: self.capabilities,
311                connectors,
312                floor_level: self.floor_level,
313                coordinates: self.coordinates,
314                physical_reference: self.physical_reference,
315                directions: self.directions,
316                parking_restrictions: self
317                    .parking_restrictions
318                    .into_iter()
319                    .map(|p| p.downgrade().value)
320                    .collect(),
321                images: self.images,
322                last_updated: self.last_updated,
323                extensions: self.extensions,
324            },
325            lossy,
326        )
327    }
328}
329
330impl Upgrade<new::locations::Location> for old::locations::Location {
331    fn upgrade(self) -> Converted<new::locations::Location> {
332        let mut lossy = Lossy::none();
333        let mut evses = Vec::with_capacity(self.evses.len());
334        for (i, evse) in self.evses.into_iter().enumerate() {
335            let converted = evse.upgrade();
336            lossy.absorb(&format!("/evses/{i}"), converted.lossy);
337            evses.push(converted.value);
338        }
339        let mut publish_allowed_to = Vec::with_capacity(self.publish_allowed_to.len());
340        for token in self.publish_allowed_to {
341            publish_allowed_to.push(token.upgrade().value);
342        }
343        Converted::new(
344            new::locations::Location {
345                country_code: self.country_code,
346                party_id: self.party_id,
347                id: self.id,
348                publish: self.publish,
349                publish_allowed_to,
350                name: self.name,
351                address: self.address,
352                city: self.city,
353                postal_code: self.postal_code,
354                state: self.state,
355                country: self.country,
356                coordinates: self.coordinates,
357                related_locations: self.related_locations,
358                parking_type: self.parking_type,
359                evses,
360                parking_places: Vec::new(),
361                directions: self.directions,
362                operator: self.operator,
363                suboperator: self.suboperator,
364                owner: self.owner,
365                facilities: self.facilities,
366                time_zone: self.time_zone,
367                opening_times: self.opening_times,
368                charging_when_closed: self.charging_when_closed,
369                images: self.images,
370                energy_mix: self.energy_mix,
371                help_phone: None,
372                last_updated: self.last_updated,
373                extensions: self.extensions,
374            },
375            lossy,
376        )
377    }
378}
379
380impl Downgrade<old::locations::Location> for new::locations::Location {
381    fn downgrade(self) -> Converted<old::locations::Location> {
382        let mut lossy = Lossy::none();
383        if !self.parking_places.is_empty() {
384            lossy.record(
385                "/parking_places",
386                format!(
387                    "{} Parking object(s) dropped: OCPI 2.2.1 has no such object",
388                    self.parking_places.len()
389                ),
390            );
391        }
392        if self.help_phone.is_some() {
393            lossy.record("/help_phone", "OCPI 2.2.1 has no Location.help_phone");
394        }
395        let mut evses = Vec::with_capacity(self.evses.len());
396        for (i, evse) in self.evses.into_iter().enumerate() {
397            let converted = evse.downgrade();
398            lossy.absorb(&format!("/evses/{i}"), converted.lossy);
399            evses.push(converted.value);
400        }
401        let publish_allowed_to = self.publish_allowed_to.into_iter().map(|t| t.downgrade().value).collect();
402        Converted::new(
403            old::locations::Location {
404                country_code: self.country_code,
405                party_id: self.party_id,
406                id: self.id,
407                publish: self.publish,
408                publish_allowed_to,
409                name: self.name,
410                address: self.address,
411                city: self.city,
412                postal_code: self.postal_code,
413                state: self.state,
414                country: self.country,
415                coordinates: self.coordinates,
416                related_locations: self.related_locations,
417                parking_type: self.parking_type,
418                evses,
419                directions: self.directions,
420                operator: self.operator,
421                suboperator: self.suboperator,
422                owner: self.owner,
423                facilities: self.facilities,
424                time_zone: self.time_zone,
425                opening_times: self.opening_times,
426                charging_when_closed: self.charging_when_closed,
427                images: self.images,
428                energy_mix: self.energy_mix,
429                last_updated: self.last_updated,
430                extensions: self.extensions,
431            },
432            lossy,
433        )
434    }
435}
436
437// -------------------------------------------------------------------------------------------
438// Tariffs
439// -------------------------------------------------------------------------------------------
440
441impl Upgrade<new::tariffs::Tariff> for old::tariffs::Tariff {
442    /// `tax_included` becomes `NO`.
443    ///
444    /// This is not a guess: a 2.2.1 `PriceComponent.price` is defined as *"Price per unit
445    /// (excl. VAT) for this dimension"*, so every amount in a 2.2.1 Tariff is pre-tax by
446    /// construction, which is exactly what `NO` means.
447    ///
448    /// `preauthorize_amount` starts absent — 2.2.1 has no Payments module to preauthorize for.
449    fn upgrade(self) -> Converted<new::tariffs::Tariff> {
450        let to_limit = |p: old::types::Price| new::tariffs::PriceLimit {
451            before_taxes: p.excl_vat,
452            after_taxes: p.incl_vat,
453            extensions: p.extensions,
454        };
455        Converted::lossless(new::tariffs::Tariff {
456            country_code: self.country_code,
457            party_id: self.party_id,
458            id: self.id,
459            currency: self.currency,
460            tariff_type: self.tariff_type,
461            tariff_alt_text: self.tariff_alt_text,
462            tariff_alt_url: self.tariff_alt_url,
463            min_price: self.min_price.map(to_limit),
464            max_price: self.max_price.map(to_limit),
465            preauthorize_amount: None,
466            elements: self.elements,
467            tax_included: new::tariffs::TaxIncluded::No,
468            start_date_time: self.start_date_time,
469            end_date_time: self.end_date_time,
470            energy_mix: self.energy_mix,
471            last_updated: self.last_updated,
472            extensions: self.extensions,
473        })
474    }
475}
476
477impl Downgrade<old::tariffs::Tariff> for new::tariffs::Tariff {
478    /// A 2.2.1 Tariff's prices are excluding VAT by definition, so a 2.3.0 Tariff that says
479    /// `tax_included: YES` **cannot be represented**: the same numbers would mean a different
480    /// amount of money. That is recorded as a loss rather than silently reinterpreted.
481    fn downgrade(self) -> Converted<old::tariffs::Tariff> {
482        let mut lossy = Lossy::none();
483        match self.tax_included {
484            new::tariffs::TaxIncluded::No => {}
485            new::tariffs::TaxIncluded::Yes => lossy.record(
486                "/tax_included",
487                "this Tariff's prices include tax, but a 2.2.1 `PriceComponent.price` is defined \
488                 as excluding VAT; the receiving party will read the same numbers as pre-tax \
489                 amounts",
490            ),
491            new::tariffs::TaxIncluded::NotApplicable => lossy.record(
492                "/tax_included",
493                "N/A (no taxes are applicable) cannot be expressed in OCPI 2.2.1, which always \
494                 treats prices as excluding VAT",
495            ),
496        }
497        if self.preauthorize_amount.is_some() {
498            lossy.record(
499                "/preauthorize_amount",
500                "OCPI 2.2.1 has no Payments module, so the preauthorization amount is dropped",
501            );
502        }
503        let mut to_price = |p: new::tariffs::PriceLimit, field: &str| {
504            if p.after_taxes.is_none() {
505                lossy.record(
506                    format!("/{field}"),
507                    "the pre-tax limit is kept as `excl_vat`; OCPI 2.2.1 has no separate \
508                     after-tax bound",
509                );
510            }
511            old::types::Price { excl_vat: p.before_taxes, incl_vat: p.after_taxes, extensions: p.extensions }
512        };
513        let min_price = self.min_price.map(|p| to_price(p, "min_price"));
514        let max_price = self.max_price.map(|p| to_price(p, "max_price"));
515        Converted::new(
516            old::tariffs::Tariff {
517                country_code: self.country_code,
518                party_id: self.party_id,
519                id: self.id,
520                currency: self.currency,
521                tariff_type: self.tariff_type,
522                tariff_alt_text: self.tariff_alt_text,
523                tariff_alt_url: self.tariff_alt_url,
524                min_price,
525                max_price,
526                elements: self.elements,
527                start_date_time: self.start_date_time,
528                end_date_time: self.end_date_time,
529                energy_mix: self.energy_mix,
530                last_updated: self.last_updated,
531                extensions: self.extensions,
532            },
533            lossy,
534        )
535    }
536}
537
538// -------------------------------------------------------------------------------------------
539// Tokens
540// -------------------------------------------------------------------------------------------
541
542impl Upgrade<new::tokens::Token> for old::tokens::Token {
543    fn upgrade(self) -> Converted<new::tokens::Token> {
544        Converted::lossless(new::tokens::Token {
545            country_code: self.country_code,
546            party_id: self.party_id,
547            uid: self.uid,
548            token_type: self.token_type.upgrade().value,
549            contract_id: self.contract_id,
550            visual_number: self.visual_number,
551            issuer: self.issuer,
552            group_id: self.group_id,
553            valid: self.valid,
554            whitelist: self.whitelist,
555            language: self.language,
556            default_profile_type: self.default_profile_type,
557            energy_contract: self.energy_contract,
558            last_updated: self.last_updated,
559            extensions: self.extensions,
560        })
561    }
562}
563
564impl Downgrade<old::tokens::Token> for new::tokens::Token {
565    fn downgrade(self) -> Converted<old::tokens::Token> {
566        Converted::lossless(old::tokens::Token {
567            country_code: self.country_code,
568            party_id: self.party_id,
569            uid: self.uid,
570            token_type: self.token_type.downgrade().value,
571            contract_id: self.contract_id,
572            visual_number: self.visual_number,
573            issuer: self.issuer,
574            group_id: self.group_id,
575            valid: self.valid,
576            whitelist: self.whitelist,
577            language: self.language,
578            default_profile_type: self.default_profile_type,
579            energy_contract: self.energy_contract,
580            last_updated: self.last_updated,
581            extensions: self.extensions,
582        })
583    }
584}
585
586impl Upgrade<new::tokens::AuthorizationInfo> for old::tokens::AuthorizationInfo {
587    fn upgrade(self) -> Converted<new::tokens::AuthorizationInfo> {
588        Converted::lossless(new::tokens::AuthorizationInfo {
589            allowed: self.allowed,
590            token: self.token.upgrade().value,
591            location: self.location,
592            authorization_reference: self.authorization_reference,
593            info: self.info,
594            extensions: self.extensions,
595        })
596    }
597}
598
599impl Downgrade<old::tokens::AuthorizationInfo> for new::tokens::AuthorizationInfo {
600    fn downgrade(self) -> Converted<old::tokens::AuthorizationInfo> {
601        Converted::lossless(old::tokens::AuthorizationInfo {
602            allowed: self.allowed,
603            token: self.token.downgrade().value,
604            location: self.location,
605            authorization_reference: self.authorization_reference,
606            info: self.info,
607            extensions: self.extensions,
608        })
609    }
610}
611
612// -------------------------------------------------------------------------------------------
613// Commands
614//
615// Only the two command bodies that carry a `Token` differ between the versions;
616// `CancelReservation`, `StopSession` and `UnlockConnector` are wire-identical and 2.2.1
617// re-exports them.
618// -------------------------------------------------------------------------------------------
619
620impl Upgrade<new::commands::StartSession> for old::commands::StartSession {
621    fn upgrade(self) -> Converted<new::commands::StartSession> {
622        Converted::lossless(new::commands::StartSession {
623            response_url: self.response_url,
624            token: self.token.upgrade().value,
625            location_id: self.location_id,
626            evse_uid: self.evse_uid,
627            connector_id: self.connector_id,
628            authorization_reference: self.authorization_reference,
629            extensions: self.extensions,
630        })
631    }
632}
633
634impl Downgrade<old::commands::StartSession> for new::commands::StartSession {
635    fn downgrade(self) -> Converted<old::commands::StartSession> {
636        Converted::lossless(old::commands::StartSession {
637            response_url: self.response_url,
638            token: self.token.downgrade().value,
639            location_id: self.location_id,
640            evse_uid: self.evse_uid,
641            connector_id: self.connector_id,
642            authorization_reference: self.authorization_reference,
643            extensions: self.extensions,
644        })
645    }
646}
647
648impl Upgrade<new::commands::ReserveNow> for old::commands::ReserveNow {
649    fn upgrade(self) -> Converted<new::commands::ReserveNow> {
650        Converted::lossless(new::commands::ReserveNow {
651            response_url: self.response_url,
652            token: self.token.upgrade().value,
653            expiry_date: self.expiry_date,
654            reservation_id: self.reservation_id,
655            location_id: self.location_id,
656            evse_uid: self.evse_uid,
657            authorization_reference: self.authorization_reference,
658            extensions: self.extensions,
659        })
660    }
661}
662
663impl Downgrade<old::commands::ReserveNow> for new::commands::ReserveNow {
664    fn downgrade(self) -> Converted<old::commands::ReserveNow> {
665        Converted::lossless(old::commands::ReserveNow {
666            response_url: self.response_url,
667            token: self.token.downgrade().value,
668            expiry_date: self.expiry_date,
669            reservation_id: self.reservation_id,
670            location_id: self.location_id,
671            evse_uid: self.evse_uid,
672            authorization_reference: self.authorization_reference,
673            extensions: self.extensions,
674        })
675    }
676}
677
678// -------------------------------------------------------------------------------------------
679// CDRs and Sessions
680// -------------------------------------------------------------------------------------------
681
682impl Upgrade<new::cdrs::CdrToken> for old::cdrs::CdrToken {
683    fn upgrade(self) -> Converted<new::cdrs::CdrToken> {
684        Converted::lossless(new::cdrs::CdrToken {
685            country_code: self.country_code,
686            party_id: self.party_id,
687            uid: self.uid,
688            token_type: self.token_type.upgrade().value,
689            contract_id: self.contract_id,
690            extensions: self.extensions,
691        })
692    }
693}
694
695impl Downgrade<old::cdrs::CdrToken> for new::cdrs::CdrToken {
696    fn downgrade(self) -> Converted<old::cdrs::CdrToken> {
697        Converted::lossless(old::cdrs::CdrToken {
698            country_code: self.country_code,
699            party_id: self.party_id,
700            uid: self.uid,
701            token_type: self.token_type.downgrade().value,
702            contract_id: self.contract_id,
703            extensions: self.extensions,
704        })
705    }
706}
707
708impl Upgrade<new::cdrs::CdrLocation> for old::cdrs::CdrLocation {
709    fn upgrade(self) -> Converted<new::cdrs::CdrLocation> {
710        Converted::lossless(new::cdrs::CdrLocation {
711            id: self.id,
712            name: self.name,
713            address: self.address,
714            city: self.city,
715            postal_code: self.postal_code,
716            state: self.state,
717            country: self.country,
718            coordinates: self.coordinates,
719            evse_uid: self.evse_uid,
720            evse_id: self.evse_id,
721            connector_id: self.connector_id,
722            connector_standard: self.connector_standard.upgrade().value,
723            connector_format: self.connector_format,
724            connector_power_type: self.connector_power_type,
725            extensions: self.extensions,
726        })
727    }
728}
729
730impl Downgrade<old::cdrs::CdrLocation> for new::cdrs::CdrLocation {
731    fn downgrade(self) -> Converted<old::cdrs::CdrLocation> {
732        Converted::lossless(old::cdrs::CdrLocation {
733            id: self.id,
734            name: self.name,
735            address: self.address,
736            city: self.city,
737            postal_code: self.postal_code,
738            state: self.state,
739            country: self.country,
740            coordinates: self.coordinates,
741            evse_uid: self.evse_uid,
742            evse_id: self.evse_id,
743            connector_id: self.connector_id,
744            connector_standard: self.connector_standard.downgrade().value,
745            connector_format: self.connector_format,
746            connector_power_type: self.connector_power_type,
747            extensions: self.extensions,
748        })
749    }
750}
751
752/// Converts an optional price field, lifting any loss into the parent's coordinates.
753macro_rules! price_field {
754    ($lossy:ident, $field:literal, $value:expr, $dir:ident) => {
755        $value.map(|p| {
756            let converted = Downgrade::<old::types::Price>::$dir(p);
757            $lossy.absorb(concat!("/", $field), converted.lossy);
758            converted.value
759        })
760    };
761}
762
763impl Upgrade<new::cdrs::Cdr> for old::cdrs::Cdr {
764    fn upgrade(self) -> Converted<new::cdrs::Cdr> {
765        let mut lossy = Lossy::none();
766        let mut tariffs = Vec::with_capacity(self.tariffs.len());
767        for (i, tariff) in self.tariffs.into_iter().enumerate() {
768            let converted = tariff.upgrade();
769            lossy.absorb(&format!("/tariffs/{i}"), converted.lossy);
770            tariffs.push(converted.value);
771        }
772        let up = |p: old::types::Price| Upgrade::<new::types::Price>::upgrade(p).value;
773        Converted::new(
774            new::cdrs::Cdr {
775                country_code: self.country_code,
776                party_id: self.party_id,
777                id: self.id,
778                start_date_time: self.start_date_time,
779                end_date_time: self.end_date_time,
780                session_id: self.session_id,
781                cdr_token: self.cdr_token.upgrade().value,
782                auth_method: self.auth_method,
783                authorization_reference: self.authorization_reference,
784                // 2.2.1 has no Bookings module, so there is nothing to carry over.
785                #[cfg(feature = "bookings")]
786                booking_id: None,
787                cdr_location: self.cdr_location.upgrade().value,
788                meter_id: self.meter_id,
789                currency: self.currency,
790                tariffs,
791                charging_periods: self.charging_periods,
792                signed_data: self.signed_data,
793                total_cost: up(self.total_cost),
794                total_fixed_cost: self.total_fixed_cost.map(up),
795                total_energy: self.total_energy,
796                total_energy_cost: self.total_energy_cost.map(up),
797                total_time: self.total_time,
798                total_time_cost: self.total_time_cost.map(up),
799                total_parking_time: self.total_parking_time,
800                total_parking_cost: self.total_parking_cost.map(up),
801                total_reservation_cost: self.total_reservation_cost.map(up),
802                remark: self.remark,
803                invoice_reference_id: self.invoice_reference_id,
804                credit: self.credit,
805                credit_reference_id: self.credit_reference_id,
806                home_charging_compensation: self.home_charging_compensation,
807                last_updated: self.last_updated,
808                extensions: self.extensions,
809            },
810            lossy,
811        )
812    }
813}
814
815impl Downgrade<old::cdrs::Cdr> for new::cdrs::Cdr {
816    fn downgrade(self) -> Converted<old::cdrs::Cdr> {
817        let mut lossy = Lossy::none();
818        let mut tariffs = Vec::with_capacity(self.tariffs.len());
819        for (i, tariff) in self.tariffs.into_iter().enumerate() {
820            let converted = tariff.downgrade();
821            lossy.absorb(&format!("/tariffs/{i}"), converted.lossy);
822            tariffs.push(converted.value);
823        }
824        let total_cost = {
825            let converted = self.total_cost.downgrade();
826            lossy.absorb("/total_cost", converted.lossy);
827            converted.value
828        };
829        let total_fixed_cost = price_field!(lossy, "total_fixed_cost", self.total_fixed_cost, downgrade);
830        let total_energy_cost = price_field!(lossy, "total_energy_cost", self.total_energy_cost, downgrade);
831        let total_time_cost = price_field!(lossy, "total_time_cost", self.total_time_cost, downgrade);
832        let total_parking_cost =
833            price_field!(lossy, "total_parking_cost", self.total_parking_cost, downgrade);
834        let total_reservation_cost =
835            price_field!(lossy, "total_reservation_cost", self.total_reservation_cost, downgrade);
836        #[cfg(feature = "bookings")]
837        if self.booking_id.is_some() {
838            lossy.record(
839                "/booking_id",
840                "OCPI 2.2.1 has no Bookings module, so the booking this CDR settles cannot be \
841                 named; the charge is carried across but the reservation it belongs to is not",
842            );
843        }
844        Converted::new(
845            old::cdrs::Cdr {
846                country_code: self.country_code,
847                party_id: self.party_id,
848                id: self.id,
849                start_date_time: self.start_date_time,
850                end_date_time: self.end_date_time,
851                session_id: self.session_id,
852                cdr_token: self.cdr_token.downgrade().value,
853                auth_method: self.auth_method,
854                authorization_reference: self.authorization_reference,
855                cdr_location: self.cdr_location.downgrade().value,
856                meter_id: self.meter_id,
857                currency: self.currency,
858                tariffs,
859                charging_periods: self.charging_periods,
860                signed_data: self.signed_data,
861                total_cost,
862                total_fixed_cost,
863                total_energy: self.total_energy,
864                total_energy_cost,
865                total_time: self.total_time,
866                total_time_cost,
867                total_parking_time: self.total_parking_time,
868                total_parking_cost,
869                total_reservation_cost,
870                remark: self.remark,
871                invoice_reference_id: self.invoice_reference_id,
872                credit: self.credit,
873                credit_reference_id: self.credit_reference_id,
874                home_charging_compensation: self.home_charging_compensation,
875                last_updated: self.last_updated,
876                extensions: self.extensions,
877            },
878            lossy,
879        )
880    }
881}
882
883impl Upgrade<new::sessions::Session> for old::sessions::Session {
884    fn upgrade(self) -> Converted<new::sessions::Session> {
885        Converted::lossless(new::sessions::Session {
886            country_code: self.country_code,
887            party_id: self.party_id,
888            id: self.id,
889            start_date_time: self.start_date_time,
890            end_date_time: self.end_date_time,
891            kwh: self.kwh,
892            cdr_token: self.cdr_token.upgrade().value,
893            auth_method: self.auth_method,
894            authorization_reference: self.authorization_reference,
895            location_id: self.location_id,
896            evse_uid: self.evse_uid,
897            connector_id: self.connector_id,
898            meter_id: self.meter_id,
899            currency: self.currency,
900            charging_periods: self.charging_periods,
901            total_cost: self.total_cost.map(|p| Upgrade::<new::types::Price>::upgrade(p).value),
902            status: self.status,
903            last_updated: self.last_updated,
904            extensions: self.extensions,
905        })
906    }
907}
908
909impl Downgrade<old::sessions::Session> for new::sessions::Session {
910    fn downgrade(self) -> Converted<old::sessions::Session> {
911        let mut lossy = Lossy::none();
912        let total_cost = price_field!(lossy, "total_cost", self.total_cost, downgrade);
913        Converted::new(
914            old::sessions::Session {
915                country_code: self.country_code,
916                party_id: self.party_id,
917                id: self.id,
918                start_date_time: self.start_date_time,
919                end_date_time: self.end_date_time,
920                kwh: self.kwh,
921                cdr_token: self.cdr_token.downgrade().value,
922                auth_method: self.auth_method,
923                authorization_reference: self.authorization_reference,
924                location_id: self.location_id,
925                evse_uid: self.evse_uid,
926                connector_id: self.connector_id,
927                meter_id: self.meter_id,
928                currency: self.currency,
929                charging_periods: self.charging_periods,
930                total_cost,
931                status: self.status,
932                last_updated: self.last_updated,
933                extensions: self.extensions,
934            },
935            lossy,
936        )
937    }
938}
939
940// -------------------------------------------------------------------------------------------
941// Credentials
942// -------------------------------------------------------------------------------------------
943
944impl Upgrade<new::credentials::Credentials> for old::credentials::Credentials {
945    /// A 2.2.1 `HUB` role becomes 2.3.0's `hub_party_id`, and the role entry is dropped: that is
946    /// exactly where OCPI 2.3.0 moved the information.
947    ///
948    /// > *A Platform that supports Hub functionality with the Message routing headers SHALL give
949    /// > the country code and party ID of the Hub in the `hub_party_id` field.*
950    fn upgrade(self) -> Converted<new::credentials::Credentials> {
951        let mut lossy = Lossy::none();
952        let hub_party_id =
953            self.roles.iter().find(|r| r.role == old::types::Role::Hub).map(|r| r.party().to_hub_party_id());
954        let mut roles = Vec::with_capacity(self.roles.len());
955        for (i, role) in self.roles.into_iter().enumerate() {
956            if role.role == old::types::Role::Hub {
957                // The information is not lost: it moved to `hub_party_id`.
958                continue;
959            }
960            let converted = Upgrade::<new::types::Role>::upgrade(role.role);
961            lossy.absorb(&format!("/roles/{i}/role"), converted.lossy);
962            roles.push(new::credentials::CredentialsRole {
963                role: converted.value,
964                business_details: role.business_details,
965                party_id: role.party_id,
966                country_code: role.country_code,
967                extensions: role.extensions,
968            });
969        }
970        Converted::new(
971            new::credentials::Credentials {
972                token: self.token,
973                url: self.url,
974                hub_party_id,
975                roles,
976                extensions: self.extensions,
977            },
978            lossy,
979        )
980    }
981}
982
983impl Downgrade<old::credentials::Credentials> for new::credentials::Credentials {
984    /// A 2.3.0 `hub_party_id` becomes a 2.2.1 `HUB` role.
985    ///
986    /// The 2.2.1 role needs `business_details`, which 2.3.0 does not carry for the hub party
987    /// itself; the first role's details are reused and the substitution is reported.
988    fn downgrade(self) -> Converted<old::credentials::Credentials> {
989        let mut lossy = Lossy::none();
990        let mut roles: Vec<old::credentials::CredentialsRole> = self
991            .roles
992            .iter()
993            .map(|role| old::credentials::CredentialsRole {
994                role: Downgrade::<old::types::Role>::downgrade(role.role).value,
995                business_details: role.business_details.clone(),
996                party_id: role.party_id.clone(),
997                country_code: role.country_code.clone(),
998                extensions: role.extensions.clone(),
999            })
1000            .collect();
1001
1002        if let Some(hub) = self.hub_party() {
1003            match self.roles.first() {
1004                Some(first) => {
1005                    lossy.record(
1006                        "/hub_party_id",
1007                        format!(
1008                            "re-expressed as a 2.2.1 HUB role for {hub}; its business_details were \
1009                             copied from {} because OCPI 2.3.0 does not carry them for the hub \
1010                             party itself",
1011                            first.party()
1012                        ),
1013                    );
1014                    roles.push(old::credentials::CredentialsRole {
1015                        role: old::types::Role::Hub,
1016                        business_details: first.business_details.clone(),
1017                        party_id: hub.party_id,
1018                        country_code: hub.country_code,
1019                        extensions: crate::types::Extensions::new(),
1020                    });
1021                }
1022                None => lossy.record(
1023                    "/hub_party_id",
1024                    format!("cannot be expressed in OCPI 2.2.1: no role to model {hub} on"),
1025                ),
1026            }
1027        }
1028
1029        Converted::new(
1030            old::credentials::Credentials {
1031                token: self.token,
1032                url: self.url,
1033                roles,
1034                extensions: self.extensions,
1035            },
1036            lossy,
1037        )
1038    }
1039}
1040
1041// -------------------------------------------------------------------------------------------
1042// Hub Client Info
1043// -------------------------------------------------------------------------------------------
1044
1045impl Upgrade<new::hub_client_info::ClientInfo> for old::hub_client_info::ClientInfo {
1046    fn upgrade(self) -> Converted<new::hub_client_info::ClientInfo> {
1047        let converted = Upgrade::<new::types::Role>::upgrade(self.role);
1048        let mut lossy = Lossy::none();
1049        lossy.absorb("/role", converted.lossy);
1050        Converted::new(
1051            new::hub_client_info::ClientInfo {
1052                party_id: self.party_id,
1053                country_code: self.country_code,
1054                role: converted.value,
1055                status: self.status,
1056                last_updated: self.last_updated,
1057                extensions: self.extensions,
1058            },
1059            lossy,
1060        )
1061    }
1062}
1063
1064impl Downgrade<old::hub_client_info::ClientInfo> for new::hub_client_info::ClientInfo {
1065    fn downgrade(self) -> Converted<old::hub_client_info::ClientInfo> {
1066        Converted::lossless(old::hub_client_info::ClientInfo {
1067            party_id: self.party_id,
1068            country_code: self.country_code,
1069            role: Downgrade::<old::types::Role>::downgrade(self.role).value,
1070            status: self.status,
1071            last_updated: self.last_updated,
1072            extensions: self.extensions,
1073        })
1074    }
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079    use super::*;
1080    use crate::types::{DateTime, Validate};
1081
1082    fn dt() -> DateTime {
1083        "2024-01-01T00:00:00Z".parse().unwrap()
1084    }
1085
1086    #[test]
1087    fn price_survives_a_round_trip_when_it_has_at_most_one_vat_line() {
1088        let original = old::types::Price::with_vat("5.00".parse().unwrap(), "5.50".parse().unwrap());
1089        let up: new::types::Price = original.clone().upgrade().expect_lossless();
1090        assert_eq!(up.taxes.len(), 1);
1091        assert_eq!(up.taxes[0].name.as_str(), "VAT");
1092        let back: old::types::Price = up.downgrade().expect_lossless();
1093        assert_eq!(back, original);
1094    }
1095
1096    #[test]
1097    fn several_named_taxes_collapse_and_say_so() {
1098        let mut price = new::types::Price::new("5.00".parse().unwrap());
1099        price.taxes.push(new::types::TaxAmount::new("GST", None, "0.25".parse().unwrap()).unwrap());
1100        price.taxes.push(new::types::TaxAmount::new("QST", None, "0.50".parse().unwrap()).unwrap());
1101        let converted = price.downgrade();
1102        assert_eq!(converted.value.excl_vat.to_string(), "5.00");
1103        assert_eq!(converted.value.incl_vat.unwrap().to_string(), "5.75");
1104        assert_eq!(converted.lossy.len(), 2, "both tax names are reported");
1105        assert!(converted.lossy.as_slice()[0].reason.contains("GST"));
1106    }
1107
1108    #[test]
1109    fn connector_types_added_in_2_3_0_survive_a_downgrade_as_their_wire_value() {
1110        let mcs = new::locations::ConnectorType::Mcs;
1111        let old_type: old::locations::ConnectorType = mcs.clone().downgrade().expect_lossless();
1112        assert!(!old_type.is_known(), "2.2.1 does not define MCS");
1113        assert_eq!(old_type.as_str(), "MCS", "but the wire value is unchanged");
1114        let round_trip: new::locations::ConnectorType = old_type.upgrade().expect_lossless();
1115        assert_eq!(round_trip, mcs);
1116    }
1117
1118    #[test]
1119    fn a_2_2_1_hub_role_becomes_hub_party_id_and_back() {
1120        let business = old::locations::BusinessDetails::builder().name("Example Hub").build();
1121        let credentials = old::credentials::Credentials::builder()
1122            .token("token")
1123            .url(crate::types::Url::new("https://hub.example.com/versions").unwrap())
1124            .roles(vec![
1125                old::credentials::CredentialsRole::builder()
1126                    .role(old::types::Role::Cpo)
1127                    .business_details(business.clone())
1128                    .party_id("TNM")
1129                    .country_code("NL")
1130                    .build(),
1131                old::credentials::CredentialsRole::builder()
1132                    .role(old::types::Role::Hub)
1133                    .business_details(business)
1134                    .party_id("HUB")
1135                    .country_code("NL")
1136                    .build(),
1137            ])
1138            .build();
1139
1140        let up: new::credentials::Credentials = credentials.upgrade().expect_lossless();
1141        assert_eq!(up.hub_party_id.as_ref().unwrap().as_str(), "NLHUB");
1142        assert_eq!(up.roles.len(), 1, "the HUB role became hub_party_id");
1143        assert!(up.is_routing_platform());
1144
1145        let back = up.downgrade();
1146        assert!(back.value.is_hub());
1147        assert_eq!(back.value.roles.len(), 2);
1148        // The business details had to be borrowed from another role, and that is reported.
1149        assert_eq!(back.lossy.len(), 1);
1150        assert_eq!(back.lossy.as_slice()[0].pointer, "/hub_party_id");
1151    }
1152
1153    #[test]
1154    fn a_tax_inclusive_tariff_cannot_be_downgraded_faithfully() {
1155        let tariff = new::tariffs::Tariff::builder()
1156            .country_code("CA")
1157            .party_id("ABC")
1158            .id("1")
1159            .currency("CAD")
1160            .elements(vec![
1161                new::tariffs::TariffElement::builder()
1162                    .price_components(vec![new::tariffs::PriceComponent::new(
1163                        new::tariffs::TariffDimensionType::Time,
1164                        "2.10".parse().unwrap(),
1165                    )])
1166                    .build(),
1167            ])
1168            .tax_included(new::tariffs::TaxIncluded::Yes)
1169            .last_updated(dt())
1170            .build();
1171        let converted = tariff.downgrade();
1172        assert_eq!(converted.lossy.as_slice()[0].pointer, "/tax_included");
1173        assert!(converted.lossy.as_slice()[0].reason.contains("excluding VAT"));
1174    }
1175
1176    #[test]
1177    fn a_2_2_1_tariff_upgrades_to_tax_excluded() {
1178        let tariff = old::tariffs::Tariff::builder()
1179            .country_code("DE")
1180            .party_id("ALL")
1181            .id("12")
1182            .currency("EUR")
1183            .elements(vec![
1184                old::tariffs::TariffElement::builder()
1185                    .price_components(vec![old::tariffs::PriceComponent::new(
1186                        old::tariffs::TariffDimensionType::Time,
1187                        "2.00".parse().unwrap(),
1188                    )])
1189                    .build(),
1190            ])
1191            .last_updated(dt())
1192            .build();
1193        let up: new::tariffs::Tariff = tariff.upgrade().expect_lossless();
1194        assert_eq!(up.tax_included, new::tariffs::TaxIncluded::No);
1195        assert!(up.validate().is_ok());
1196    }
1197
1198    #[test]
1199    fn location_fields_added_in_2_3_0_are_reported_when_dropped() {
1200        let mut location = new::locations::Location::builder()
1201            .country_code("NL")
1202            .party_id("TNM")
1203            .id("LOC1")
1204            .publish(true)
1205            .address("Street 1")
1206            .city("Amsterdam")
1207            .country("NLD")
1208            .coordinates(new::locations::GeoLocation::new("52.010000", "4.350000").unwrap())
1209            .time_zone("Europe/Amsterdam")
1210            .help_phone(crate::types::CiString::<25>::new("+31201234567").unwrap())
1211            .last_updated(dt())
1212            .build();
1213        location.parking_places.push(
1214            new::locations::Parking::builder()
1215                .id("P1")
1216                .vehicle_types(vec![new::locations::VehicleType::PersonalVehicle])
1217                .restricted_to_type(false)
1218                .reservation_required(false)
1219                .build(),
1220        );
1221        let converted = location.downgrade();
1222        let pointers: Vec<&str> = converted.lossy.as_slice().iter().map(|l| l.pointer.as_str()).collect();
1223        assert!(pointers.contains(&"/parking_places"), "{pointers:?}");
1224        assert!(pointers.contains(&"/help_phone"), "{pointers:?}");
1225        assert!(converted.value.validate().is_ok(), "the downgraded object still conforms");
1226    }
1227}