Skip to main content

ocpi_kit/v2_3_0/
payments.rs

1//! The *Payments* module, new in OCPI 2.3.0: ad-hoc payment terminals and their transactions.
2//!
3//! *Module Identifier: `payments`* — Data owner: PTP (Payment Terminal Provider).
4//!
5//! The module maps payment terminals onto Locations and EVSEs, and carries the financial
6//! confirmations back so a CDR can be reconciled against what was actually captured at the
7//! payment service provider.
8//!
9//! **Spec erratum.** `payments` is missing from the `ModuleID` table in
10//! §version_information_endpoint_moduleid_enum of the same release that defines this chapter;
11//! see [`ModuleId::Payments`](crate::ModuleId::Payments).
12//!
13//! Spec: 2.3.0 §mod_payments_payments_module
14
15use bon::Builder;
16use serde::{Deserialize, Serialize};
17
18use crate::ocpi_enum;
19use crate::types::validate_fields;
20use crate::types::{
21    CiString, CountryCode, Currency, DateTime, Extensions, PartyId, PartyRef, Url, Validate, Validator,
22    ViolationCode,
23};
24
25use super::locations::GeoLocation;
26use super::types::Price;
27
28/// One physical payment terminal, and the charge points it serves.
29///
30/// > *It is designed primarily to establish a mapping between charge points (locations and/or
31/// > EVSEs) and payment terminals.*
32///
33/// Spec: 2.3.0 §mod_payments_terminal_object
34#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
35#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
36#[builder(on(_, into))]
37pub struct Terminal {
38    /// Unique ID that identifies a terminal.
39    pub terminal_id: CiString<36>,
40    /// Reference used to link the terminal to a CSMS.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub customer_reference: Option<CiString<36>>,
43    /// Party ID, as an alternative to the customer reference.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub party_id: Option<PartyId>,
46    /// Country code, as an alternative to the customer reference.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub country_code: Option<CountryCode>,
49    /// Street/block name and house number if available.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub address: Option<CiString<45>>,
52    /// City or town.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub city: Option<CiString<45>>,
55    /// Postal code of the terminal.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub postal_code: Option<CiString<10>>,
58    /// State or province, only where relevant.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub state: Option<CiString<20>>,
61    /// ISO 3166-1 alpha-3 code for the country of this terminal.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub country: Option<CiString<3>>,
64    /// Coordinates of the terminal.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub coordinates: Option<GeoLocation>,
67    /// Base URL of the downloadable invoice.
68    ///
69    /// The full URL is this base plus the session's `authorization_reference`; see
70    /// [`InvoiceCreator::Cpo`].
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub invoice_base_url: Option<Url>,
73    /// Which party creates the invoice for the eDriver.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub invoice_creator: Option<InvoiceCreator>,
76    /// Mapping value as issued by the PTP, e.g. a serial number.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub reference: Option<CiString<36>>,
79    /// All Locations assigned to this terminal.
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    #[builder(default)]
82    pub location_ids: Vec<CiString<36>>,
83    /// All EVSEs assigned to this terminal.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    #[builder(default)]
86    pub evse_uids: Vec<CiString<36>>,
87    /// Timestamp when this Terminal was last updated (or created).
88    pub last_updated: DateTime,
89    /// Undocumented JSON fields, preserved verbatim.
90    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
91    #[builder(default)]
92    pub extensions: Extensions,
93}
94
95impl Terminal {
96    /// The party this terminal is assigned to, when it was identified by party rather than by
97    /// customer reference.
98    #[must_use]
99    pub fn assigned_party(&self) -> Option<PartyRef> {
100        match (&self.country_code, &self.party_id) {
101            (Some(country_code), Some(party_id)) => {
102                Some(PartyRef { country_code: country_code.clone(), party_id: party_id.clone() })
103            }
104            _ => None,
105        }
106    }
107
108    /// Whether the terminal has been assigned to any charge point yet.
109    ///
110    /// A newly created terminal has neither, which is a legitimate intermediate state: the spec's
111    /// own "newly created" example has both lists empty.
112    #[must_use]
113    pub fn is_assigned(&self) -> bool {
114        !self.location_ids.is_empty() || !self.evse_uids.is_empty()
115    }
116
117    /// The URL an eDriver can download the invoice from for a given authorization reference.
118    ///
119    /// > *The CPO issues the invoice and provides it via the `invoice_base_url` +
120    /// > `authorization_reference`.*
121    #[must_use]
122    pub fn invoice_url(&self, authorization_reference: &str) -> Option<Url> {
123        self.invoice_base_url.as_ref().map(|base| base.join(authorization_reference))
124    }
125}
126
127impl Validate for Terminal {
128    fn validate_in(&self, v: &mut Validator) {
129        validate_fields!(
130            self,
131            v,
132            terminal_id,
133            customer_reference,
134            party_id,
135            country_code,
136            address,
137            city,
138            postal_code,
139            state,
140            country,
141            coordinates,
142            invoice_base_url,
143            invoice_creator,
144            reference,
145            location_ids,
146            evse_uids,
147            last_updated,
148        );
149        // "This is an alternative to the customer reference which can be used" — a lone half of
150        // the pair identifies nothing.
151        if self.party_id.is_some() != self.country_code.is_some() {
152            v.report(
153                ViolationCode::MissingConditional,
154                "`party_id` and `country_code` identify a party together; set both or neither",
155            );
156        }
157        if self.invoice_creator == Some(InvoiceCreator::Cpo) && self.invoice_base_url.is_none() {
158            v.report_at(
159                "invoice_base_url",
160                ViolationCode::MissingConditional,
161                "the CPO provides the invoice via invoice_base_url + authorization_reference",
162            );
163        }
164    }
165}
166
167/// What was actually captured at the payment service provider for one ad-hoc session.
168///
169/// > *It correlates payment transactions with charging sessions by using the
170/// > `authorization_reference` obtained from the Commands.StartSession, Session, and CDR.*
171///
172/// Spec: 2.3.0 §mod_payments_financial_advice_confirmation_object
173#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
174#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
175#[builder(on(_, into))]
176pub struct FinancialAdviceConfirmation {
177    /// Unique ID that identifies a financial advice confirmation.
178    pub id: CiString<36>,
179    /// Reference to the authorization given by the PTP in `Commands.StartSession`.
180    pub authorization_reference: CiString<36>,
181    /// The real amount that was captured at the PSP. A consumer price, with VAT.
182    pub total_costs: Price,
183    /// ISO-4217 code of the currency.
184    pub currency: Currency,
185    /// Invoice-relevant data from the direct payment. Cardinality `+`.
186    pub eft_data: Vec<CiString<255>>,
187    /// Code identifying the financial advice status.
188    pub capture_status_code: CaptureStatusCode,
189    /// Message about any error in the financial advice.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub capture_status_message: Option<CiString<255>>,
192    /// Timestamp when this confirmation was last updated (or created).
193    pub last_updated: DateTime,
194    /// Undocumented JSON fields, preserved verbatim.
195    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
196    #[builder(default)]
197    pub extensions: Extensions,
198}
199
200impl FinancialAdviceConfirmation {
201    /// Whether the full amount was captured.
202    #[must_use]
203    pub fn is_fully_captured(&self) -> bool {
204        self.capture_status_code == CaptureStatusCode::Success
205    }
206}
207
208impl Validate for FinancialAdviceConfirmation {
209    fn validate_in(&self, v: &mut Validator) {
210        validate_fields!(
211            self,
212            v,
213            id,
214            authorization_reference,
215            total_costs,
216            currency,
217            eft_data,
218            capture_status_code,
219            capture_status_message,
220            last_updated,
221        );
222        if self.eft_data.is_empty() {
223            v.report_at(
224                "eft_data",
225                ViolationCode::EmptyRequiredList,
226                "eft_data has cardinality `+`: it is mandatory on invoices, so at least one \
227                 entry is required",
228            );
229        }
230        if self.capture_status_code != CaptureStatusCode::Success && self.capture_status_message.is_none() {
231            v.report_at(
232                "capture_status_message",
233                ViolationCode::MissingConditional,
234                "a non-successful capture should say what went wrong",
235            );
236        }
237    }
238}
239
240ocpi_enum! {
241    /// Which party issues the invoice for an ad-hoc session.
242    ///
243    /// Spec: 2.3.0 §mod_payments_invoice_creator_enum
244    pub enum InvoiceCreator {
245        /// The CPO issues the invoice, via `invoice_base_url` + `authorization_reference`.
246        Cpo = "CPO",
247        /// The PTP issues the invoice and shows it to the eDriver at the payment terminal.
248        Ptp = "PTP",
249    }
250}
251
252ocpi_enum! {
253    /// The outcome of the payment capture following a transaction.
254    ///
255    /// Spec: 2.3.0 §mod_payments_capture_status_code_enum
256    pub enum CaptureStatusCode {
257        /// Completed successfully; funds were secured.
258        Success = "SUCCESS",
259        /// Only part of the amount was approved, or conditions were altered during processing.
260        PartialSuccess = "PARTIAL_SUCCESS",
261        /// The capture attempt was unsuccessful.
262        Failed = "FAILED",
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    fn terminal() -> Terminal {
271        Terminal::builder()
272            .terminal_id("TERM0001")
273            .last_updated("2024-03-15T10:00:00Z".parse::<DateTime>().unwrap())
274            .build()
275    }
276
277    #[test]
278    fn a_newly_created_terminal_is_valid_but_unassigned() {
279        let t = terminal();
280        assert!(!t.is_assigned());
281        assert!(t.validate().is_ok());
282    }
283
284    #[test]
285    fn party_identification_needs_both_halves() {
286        let mut t = terminal();
287        t.party_id = Some(PartyId::new("TNM").unwrap());
288        assert_eq!(t.validate().unwrap_err().as_slice()[0].code, ViolationCode::MissingConditional);
289        t.country_code = Some(CountryCode::new("NL").unwrap());
290        assert!(t.validate().is_ok());
291        assert_eq!(t.assigned_party(), Some(PartyRef::new("NL", "TNM").unwrap()));
292    }
293
294    #[test]
295    fn a_cpo_invoice_creator_needs_a_base_url() {
296        let mut t = terminal();
297        t.invoice_creator = Some(InvoiceCreator::Cpo);
298        assert_eq!(t.validate().unwrap_err().as_slice()[0].pointer, "/invoice_base_url");
299        t.invoice_base_url = Some(Url::new("https://cpo.example.com/invoices").unwrap());
300        assert!(t.validate().is_ok());
301        assert_eq!(t.invoice_url("AUTH123").unwrap().as_str(), "https://cpo.example.com/invoices/AUTH123");
302    }
303
304    #[test]
305    fn a_failed_capture_must_explain_itself() {
306        let mut fac = FinancialAdviceConfirmation::builder()
307            .id("FAC1")
308            .authorization_reference("AUTH123")
309            .total_costs(Price::new("12.50".parse().unwrap()))
310            .currency("EUR")
311            .eft_data(vec![CiString::new("DEBIT 12.50 EUR").unwrap()])
312            .capture_status_code(CaptureStatusCode::Failed)
313            .last_updated("2024-03-15T10:00:00Z".parse::<DateTime>().unwrap())
314            .build();
315        assert!(!fac.is_fully_captured());
316        assert_eq!(fac.validate().unwrap_err().as_slice()[0].pointer, "/capture_status_message");
317        fac.capture_status_message = Some(CiString::new("insufficient funds").unwrap());
318        assert!(fac.validate().is_ok());
319    }
320
321    #[test]
322    fn eft_data_is_mandatory() {
323        let fac = FinancialAdviceConfirmation::builder()
324            .id("FAC1")
325            .authorization_reference("AUTH123")
326            .total_costs(Price::new("12.50".parse().unwrap()))
327            .currency("EUR")
328            .eft_data(vec![])
329            .capture_status_code(CaptureStatusCode::Success)
330            .last_updated("2024-03-15T10:00:00Z".parse::<DateTime>().unwrap())
331            .build();
332        assert_eq!(fac.validate().unwrap_err().as_slice()[0].code, ViolationCode::EmptyRequiredList);
333    }
334}