use crate::validate::{CharsetPolicy, ValidationError, check_name};
use crate::xml_util::write_escaped;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum IdentifierKind {
Organisation,
Private,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PartyIdentifier {
pub kind: IdentifierKind,
pub id: String,
pub scheme_name: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Party {
name: Option<String>,
identifier: Option<PartyIdentifier>,
}
impl Party {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: Some(name.into()),
identifier: None,
}
}
pub fn organisation_id(id: impl Into<String>, scheme_name: Option<&str>) -> Self {
Self {
name: None,
identifier: Some(PartyIdentifier {
kind: IdentifierKind::Organisation,
id: id.into(),
scheme_name: scheme_name.map(str::to_owned),
}),
}
}
#[must_use]
pub fn with_organisation_id(
mut self,
id: impl Into<String>,
scheme_name: Option<&str>,
) -> Self {
self.identifier = Some(PartyIdentifier {
kind: IdentifierKind::Organisation,
id: id.into(),
scheme_name: scheme_name.map(str::to_owned),
});
self
}
#[must_use]
pub fn with_private_id(mut self, id: impl Into<String>, scheme_name: Option<&str>) -> Self {
self.identifier = Some(PartyIdentifier {
kind: IdentifierKind::Private,
id: id.into(),
scheme_name: scheme_name.map(str::to_owned),
});
self
}
#[must_use]
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
#[must_use]
pub fn identifier(&self) -> Option<&PartyIdentifier> {
self.identifier.as_ref()
}
pub fn validate(
&self,
field: &'static str,
charset: CharsetPolicy,
) -> Result<(), ValidationError> {
if self.name.is_none() && self.identifier.is_none() {
return Err(ValidationError::Empty { field });
}
if let Some(name) = &self.name {
check_name(field, &charset.apply(field, name)?)?;
}
if let Some(id) = &self.identifier {
crate::validate::check_id(field, &id.id)?;
if let Some(scheme) = &id.scheme_name {
crate::validate::check_text(
field,
&charset.apply(field, scheme)?,
crate::validate::MAX_ID_LEN,
)?;
}
}
Ok(())
}
pub(crate) fn write_xml<W: std::fmt::Write>(
&self,
w: &mut W,
tag: &str,
indent: &str,
charset: CharsetPolicy,
) -> std::fmt::Result {
write!(w, "{indent}")?;
self.write_xml_inline(w, tag, charset)?;
writeln!(w)
}
pub(crate) fn write_xml_inline<W: std::fmt::Write>(
&self,
w: &mut W,
tag: &str,
charset: CharsetPolicy,
) -> std::fmt::Result {
write!(w, "<{tag}>")?;
if let Some(name) = &self.name {
w.write_str("<Nm>")?;
write_escaped(w, &charset.render(name))?;
w.write_str("</Nm>")?;
}
if let Some(id) = &self.identifier {
let branch = match id.kind {
IdentifierKind::Organisation => "OrgId",
IdentifierKind::Private => "PrvtId",
};
write!(w, "<Id><{branch}><Othr><Id>")?;
write_escaped(w, &id.id)?;
w.write_str("</Id>")?;
if let Some(scheme) = &id.scheme_name {
w.write_str("<SchmeNm><Prtry>")?;
write_escaped(w, &charset.render(scheme))?;
w.write_str("</Prtry></SchmeNm>")?;
}
write!(w, "</Othr></{branch}></Id>")?;
}
write!(w, "</{tag}>")
}
}
impl From<&str> for Party {
fn from(name: &str) -> Self {
Self::new(name)
}
}
impl From<String> for Party {
fn from(name: String) -> Self {
Self::new(name)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render(p: &Party, tag: &str) -> String {
let mut out = String::new();
p.write_xml(&mut out, tag, "", CharsetPolicy::default())
.unwrap();
out
}
#[test]
fn name_only_is_the_common_case() {
let xml = render(&Party::new("Endbeguenstigter GmbH"), "UltmtCdtr");
assert_eq!(
xml,
"<UltmtCdtr><Nm>Endbeguenstigter GmbH</Nm></UltmtCdtr>\n"
);
}
#[test]
fn organisation_and_private_identifiers_use_different_branches() {
let org = render(
&Party::new("Tochter AG").with_organisation_id("CUST-4711", Some("CUST")),
"UltmtDbtr",
);
assert!(org.contains("<Id><OrgId><Othr><Id>CUST-4711</Id>"));
assert!(org.contains("<SchmeNm><Prtry>CUST</Prtry></SchmeNm>"));
let prv = render(
&Party::new("Max Mustermann").with_private_id("MEMBER-9", None),
"UltmtDbtr",
);
assert!(prv.contains("<Id><PrvtId><Othr><Id>MEMBER-9</Id>"));
assert!(!prv.contains("SchmeNm"), "no scheme name was supplied");
}
#[test]
fn only_nm_and_id_are_ever_emitted() {
let xml = render(
&Party::new("Test").with_organisation_id("X", Some("Y")),
"UltmtDbtr",
);
for forbidden in ["PstlAdr", "CtryOfRes", "CtctDtls", "AnyBIC"] {
assert!(!xml.contains(forbidden), "{forbidden} must not be emitted");
}
}
#[test]
fn name_is_capped_at_seventy_not_the_schema_one_forty() {
let policy = CharsetPolicy::default();
assert!(
Party::new("A".repeat(70))
.validate("UltmtDbtr/Nm", policy)
.is_ok()
);
assert!(matches!(
Party::new("A".repeat(71)).validate("UltmtDbtr/Nm", policy),
Err(ValidationError::TooLong { max: 70, .. })
));
}
#[test]
fn an_empty_party_is_rejected() {
assert!(matches!(
Party::default().validate("UltmtDbtr", CharsetPolicy::default()),
Err(ValidationError::Empty { .. })
));
}
#[test]
fn identifier_respects_max35text() {
let policy = CharsetPolicy::default();
assert!(
Party::organisation_id("X".repeat(35), None)
.validate("UltmtDbtr", policy)
.is_ok()
);
assert!(matches!(
Party::organisation_id("X".repeat(36), None).validate("UltmtDbtr", policy),
Err(ValidationError::TooLong { max: 35, .. })
));
}
#[test]
fn names_are_transliterated_and_escaped() {
let xml = render(&Party::new("Müller & Söhne"), "UltmtCdtr");
assert!(xml.contains("<Nm>Mueller + Soehne</Nm>"));
}
}