use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CreditorId(String);
impl CreditorId {
#[inline]
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[inline]
#[must_use]
pub fn country_code(&self) -> &str {
&self.0[..2]
}
#[inline]
#[must_use]
pub fn check_digits(&self) -> &str {
&self.0[2..4]
}
#[inline]
#[must_use]
pub fn business_code(&self) -> &str {
&self.0[4..7]
}
#[inline]
#[must_use]
pub fn national_id(&self) -> &str {
&self.0[7..]
}
}
impl std::fmt::Display for CreditorId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for CreditorId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::ops::Deref for CreditorId {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl std::borrow::Borrow<str> for CreditorId {
fn borrow(&self) -> &str {
&self.0
}
}
impl From<CreditorId> for String {
fn from(ci: CreditorId) -> Self {
ci.0
}
}
impl FromStr for CreditorId {
type Err = CreditorIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
validate_creditor_id(s)
}
}
impl TryFrom<&str> for CreditorId {
type Error = CreditorIdError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
validate_creditor_id(s)
}
}
impl TryFrom<String> for CreditorId {
type Error = CreditorIdError;
fn try_from(s: String) -> Result<Self, Self::Error> {
validate_creditor_id(&s)
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for CreditorId {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for CreditorId {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
validate_creditor_id(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum CreditorIdError {
#[error("Creditor Identifier length {len} is outside the valid range 8–35")]
InvalidLength {
len: usize,
},
#[error("Creditor Identifier country code {code:?} is not an ISO 3166-1 alpha-2 country")]
InvalidCountryCode {
code: String,
},
#[error("Creditor Identifier contains invalid character: {ch:?}")]
InvalidCharacter {
ch: char,
},
#[error("Creditor Identifier check digits are {actual}, expected {expected}")]
InvalidChecksum {
expected: String,
actual: String,
},
}
#[must_use = "ignoring a validated Creditor ID loses the result"]
pub fn validate_creditor_id(raw: &str) -> Result<CreditorId, CreditorIdError> {
let normalised: String = raw
.chars()
.filter(|c| !c.is_whitespace())
.map(|c| c.to_ascii_uppercase())
.collect();
let len = normalised.chars().count();
if !(8..=35).contains(&len) {
return Err(CreditorIdError::InvalidLength { len });
}
for c in normalised.chars() {
if !c.is_ascii_alphanumeric() {
return Err(CreditorIdError::InvalidCharacter { ch: c });
}
}
let cc = &normalised[..2];
if !crate::country::is_country_code(cc) {
return Err(CreditorIdError::InvalidCountryCode {
code: cc.to_owned(),
});
}
let expected = creditor_id_check_digits(&normalised[7..], cc);
let actual = &normalised[2..4];
if expected == actual {
Ok(CreditorId(normalised))
} else {
Err(CreditorIdError::InvalidChecksum {
expected,
actual: actual.to_owned(),
})
}
}
#[must_use]
pub fn creditor_id_check_digits(national_id: &str, country: &str) -> String {
fn feed(remainder: u64, c: char) -> u64 {
match c.to_digit(36) {
Some(d) if d < 10 => (remainder * 10 + u64::from(d)) % 97,
Some(d) => (remainder * 100 + u64::from(d)) % 97,
None => remainder,
}
}
let mut remainder = national_id
.chars()
.filter(char::is_ascii_alphanumeric)
.fold(0u64, feed);
remainder = country.chars().fold(remainder, feed);
remainder = feed(remainder, '0');
remainder = feed(remainder, '0');
format!("{:02}", 98 - remainder)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn de_creditor_id_valid() {
assert!(validate_creditor_id("DE98ZZZ09999999999").is_ok());
}
#[test]
fn business_code_excluded_from_checksum() {
for bc in ["ZZZ", "ABC", "001"] {
let ci = format!("DE98{bc}09999999999");
assert!(
validate_creditor_id(&ci).is_ok(),
"{ci} must stay valid — business code is not part of the checksum"
);
}
}
#[test]
fn iban_style_checksum_is_rejected() {
assert!(validate_creditor_id(concat!("DE", "74", "ZZZ09999999999")).is_err());
assert!(validate_creditor_id("DE98ZZZ09999999999").is_ok());
}
#[test]
fn check_digits_roundtrip() {
for (nid, cc) in [
("09999999999", "DE"),
("123456", "FR"),
("ZZZ00000000", "NL"),
("A1B2C3", "AT"),
] {
let cd = creditor_id_check_digits(nid, cc);
let ci = format!("{cc}{cd}ZZZ{nid}");
assert!(
validate_creditor_id(&ci).is_ok(),
"generated {ci} must validate"
);
}
}
#[test]
fn lowercase_normalised() {
let ci = validate_creditor_id("de98zzz09999999999").unwrap();
assert_eq!(ci.as_str(), "DE98ZZZ09999999999");
}
#[test]
fn parts() {
let ci = validate_creditor_id("DE98ZZZ09999999999").unwrap();
assert_eq!(ci.country_code(), "DE");
assert_eq!(ci.check_digits(), "98");
assert_eq!(ci.business_code(), "ZZZ");
assert_eq!(ci.national_id(), "09999999999");
}
#[test]
fn too_short() {
assert!(matches!(
validate_creditor_id("DE98ZZ").unwrap_err(),
CreditorIdError::InvalidLength { len: 6 }
));
}
#[test]
fn the_country_must_be_a_real_country() {
let forged = format!(
"ZZ{}ZZZ09999999999",
creditor_id_check_digits("09999999999", "ZZ")
);
assert!(matches!(
validate_creditor_id(&forged),
Err(CreditorIdError::InvalidCountryCode { .. })
));
assert!(matches!(
validate_creditor_id("1298ZZZ09999999999"),
Err(CreditorIdError::InvalidCountryCode { .. })
));
for cc in ["DE", "FR", "NL", "AT", "RS", "MD", "AL", "MK", "ME"] {
let cd = creditor_id_check_digits("0123456789", cc);
assert!(
validate_creditor_id(&format!("{cc}{cd}ZZZ0123456789")).is_ok(),
"{cc} must be accepted"
);
}
}
#[test]
fn invalid_check_digit() {
assert!(matches!(
validate_creditor_id("DE00ZZZ09999999999").unwrap_err(),
CreditorIdError::InvalidChecksum { .. }
));
}
#[test]
fn invalid_character_in_national_id() {
let result = validate_creditor_id("DE98ZZZ0999-999");
assert!(result.is_err());
}
#[test]
fn from_str() {
let ci: CreditorId = "DE98ZZZ09999999999".parse().unwrap();
assert_eq!(ci.country_code(), "DE");
}
#[test]
fn into_string() {
let ci = validate_creditor_id("DE98ZZZ09999999999").unwrap();
let s: String = ci.into();
assert_eq!(s, "DE98ZZZ09999999999");
}
#[test]
fn conversions_match_iban_and_bic() {
let ci = CreditorId::try_from("DE98ZZZ09999999999".to_owned()).unwrap();
assert_eq!(ci.len(), 18);
let mut by_id = std::collections::HashMap::new();
by_id.insert(ci.clone(), "Stadtwerke");
assert_eq!(by_id.get("DE98ZZZ09999999999"), Some(&"Stadtwerke"));
let other = validate_creditor_id("AT61ZZZ01234567890").unwrap();
assert!(other < ci); }
#[cfg(feature = "serde")]
#[test]
fn serde_roundtrip() {
let ci = validate_creditor_id("DE98ZZZ09999999999").unwrap();
let json = serde_json::to_string(&ci).unwrap();
assert_eq!(json, r#""DE98ZZZ09999999999""#);
let back: CreditorId = serde_json::from_str(&json).unwrap();
assert_eq!(back, ci);
}
#[cfg(feature = "serde")]
#[test]
fn serde_invalid_rejected() {
let result: Result<CreditorId, _> = serde_json::from_str(r#""INVALID""#);
assert!(result.is_err());
}
}