1use 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
35#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
36#[builder(on(_, into))]
37pub struct Terminal {
38 pub terminal_id: CiString<36>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub customer_reference: Option<CiString<36>>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub party_id: Option<PartyId>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub country_code: Option<CountryCode>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub address: Option<CiString<45>>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub city: Option<CiString<45>>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub postal_code: Option<CiString<10>>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub state: Option<CiString<20>>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub country: Option<CiString<3>>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub coordinates: Option<GeoLocation>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub invoice_base_url: Option<Url>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub invoice_creator: Option<InvoiceCreator>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub reference: Option<CiString<36>>,
79 #[serde(default, skip_serializing_if = "Vec::is_empty")]
81 #[builder(default)]
82 pub location_ids: Vec<CiString<36>>,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 #[builder(default)]
86 pub evse_uids: Vec<CiString<36>>,
87 pub last_updated: DateTime,
89 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
91 #[builder(default)]
92 pub extensions: Extensions,
93}
94
95impl Terminal {
96 #[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 #[must_use]
113 pub fn is_assigned(&self) -> bool {
114 !self.location_ids.is_empty() || !self.evse_uids.is_empty()
115 }
116
117 #[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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
174#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
175#[builder(on(_, into))]
176pub struct FinancialAdviceConfirmation {
177 pub id: CiString<36>,
179 pub authorization_reference: CiString<36>,
181 pub total_costs: Price,
183 pub currency: Currency,
185 pub eft_data: Vec<CiString<255>>,
187 pub capture_status_code: CaptureStatusCode,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub capture_status_message: Option<CiString<255>>,
192 pub last_updated: DateTime,
194 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
196 #[builder(default)]
197 pub extensions: Extensions,
198}
199
200impl FinancialAdviceConfirmation {
201 #[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 pub enum InvoiceCreator {
245 Cpo = "CPO",
247 Ptp = "PTP",
249 }
250}
251
252ocpi_enum! {
253 pub enum CaptureStatusCode {
257 Success = "SUCCESS",
259 PartialSuccess = "PARTIAL_SUCCESS",
261 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}