use bon::Builder;
use serde::{Deserialize, Serialize};
use crate::ocpi_enum;
use crate::types::validate_fields;
use crate::types::{
CiString, CountryCode, Currency, DateTime, Extensions, PartyId, PartyRef, Url, Validate, Validator,
ViolationCode,
};
use super::locations::GeoLocation;
use super::types::Price;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Terminal {
pub terminal_id: CiString<36>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub customer_reference: Option<CiString<36>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub party_id: Option<PartyId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub country_code: Option<CountryCode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub address: Option<CiString<45>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub city: Option<CiString<45>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub postal_code: Option<CiString<10>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<CiString<20>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub country: Option<CiString<3>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coordinates: Option<GeoLocation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invoice_base_url: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invoice_creator: Option<InvoiceCreator>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference: Option<CiString<36>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub location_ids: Vec<CiString<36>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub evse_uids: Vec<CiString<36>>,
pub last_updated: DateTime,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Terminal {
#[must_use]
pub fn assigned_party(&self) -> Option<PartyRef> {
match (&self.country_code, &self.party_id) {
(Some(country_code), Some(party_id)) => {
Some(PartyRef { country_code: country_code.clone(), party_id: party_id.clone() })
}
_ => None,
}
}
#[must_use]
pub fn is_assigned(&self) -> bool {
!self.location_ids.is_empty() || !self.evse_uids.is_empty()
}
#[must_use]
pub fn invoice_url(&self, authorization_reference: &str) -> Option<Url> {
self.invoice_base_url.as_ref().map(|base| base.join(authorization_reference))
}
}
impl Validate for Terminal {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
terminal_id,
customer_reference,
party_id,
country_code,
address,
city,
postal_code,
state,
country,
coordinates,
invoice_base_url,
invoice_creator,
reference,
location_ids,
evse_uids,
last_updated,
);
if self.party_id.is_some() != self.country_code.is_some() {
v.report(
ViolationCode::MissingConditional,
"`party_id` and `country_code` identify a party together; set both or neither",
);
}
if self.invoice_creator == Some(InvoiceCreator::Cpo) && self.invoice_base_url.is_none() {
v.report_at(
"invoice_base_url",
ViolationCode::MissingConditional,
"the CPO provides the invoice via invoice_base_url + authorization_reference",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct FinancialAdviceConfirmation {
pub id: CiString<36>,
pub authorization_reference: CiString<36>,
pub total_costs: Price,
pub currency: Currency,
pub eft_data: Vec<CiString<255>>,
pub capture_status_code: CaptureStatusCode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capture_status_message: Option<CiString<255>>,
pub last_updated: DateTime,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl FinancialAdviceConfirmation {
#[must_use]
pub fn is_fully_captured(&self) -> bool {
self.capture_status_code == CaptureStatusCode::Success
}
}
impl Validate for FinancialAdviceConfirmation {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
id,
authorization_reference,
total_costs,
currency,
eft_data,
capture_status_code,
capture_status_message,
last_updated,
);
if self.eft_data.is_empty() {
v.report_at(
"eft_data",
ViolationCode::EmptyRequiredList,
"eft_data has cardinality `+`: it is mandatory on invoices, so at least one \
entry is required",
);
}
if self.capture_status_code != CaptureStatusCode::Success && self.capture_status_message.is_none() {
v.report_at(
"capture_status_message",
ViolationCode::MissingConditional,
"a non-successful capture should say what went wrong",
);
}
}
}
ocpi_enum! {
pub enum InvoiceCreator {
Cpo = "CPO",
Ptp = "PTP",
}
}
ocpi_enum! {
pub enum CaptureStatusCode {
Success = "SUCCESS",
PartialSuccess = "PARTIAL_SUCCESS",
Failed = "FAILED",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn terminal() -> Terminal {
Terminal::builder()
.terminal_id("TERM0001")
.last_updated("2024-03-15T10:00:00Z".parse::<DateTime>().unwrap())
.build()
}
#[test]
fn a_newly_created_terminal_is_valid_but_unassigned() {
let t = terminal();
assert!(!t.is_assigned());
assert!(t.validate().is_ok());
}
#[test]
fn party_identification_needs_both_halves() {
let mut t = terminal();
t.party_id = Some(PartyId::new("TNM").unwrap());
assert_eq!(t.validate().unwrap_err().as_slice()[0].code, ViolationCode::MissingConditional);
t.country_code = Some(CountryCode::new("NL").unwrap());
assert!(t.validate().is_ok());
assert_eq!(t.assigned_party(), Some(PartyRef::new("NL", "TNM").unwrap()));
}
#[test]
fn a_cpo_invoice_creator_needs_a_base_url() {
let mut t = terminal();
t.invoice_creator = Some(InvoiceCreator::Cpo);
assert_eq!(t.validate().unwrap_err().as_slice()[0].pointer, "/invoice_base_url");
t.invoice_base_url = Some(Url::new("https://cpo.example.com/invoices").unwrap());
assert!(t.validate().is_ok());
assert_eq!(t.invoice_url("AUTH123").unwrap().as_str(), "https://cpo.example.com/invoices/AUTH123");
}
#[test]
fn a_failed_capture_must_explain_itself() {
let mut fac = FinancialAdviceConfirmation::builder()
.id("FAC1")
.authorization_reference("AUTH123")
.total_costs(Price::new("12.50".parse().unwrap()))
.currency("EUR")
.eft_data(vec![CiString::new("DEBIT 12.50 EUR").unwrap()])
.capture_status_code(CaptureStatusCode::Failed)
.last_updated("2024-03-15T10:00:00Z".parse::<DateTime>().unwrap())
.build();
assert!(!fac.is_fully_captured());
assert_eq!(fac.validate().unwrap_err().as_slice()[0].pointer, "/capture_status_message");
fac.capture_status_message = Some(CiString::new("insufficient funds").unwrap());
assert!(fac.validate().is_ok());
}
#[test]
fn eft_data_is_mandatory() {
let fac = FinancialAdviceConfirmation::builder()
.id("FAC1")
.authorization_reference("AUTH123")
.total_costs(Price::new("12.50".parse().unwrap()))
.currency("EUR")
.eft_data(vec![])
.capture_status_code(CaptureStatusCode::Success)
.last_updated("2024-03-15T10:00:00Z".parse::<DateTime>().unwrap())
.build();
assert_eq!(fac.validate().unwrap_err().as_slice()[0].code, ViolationCode::EmptyRequiredList);
}
}