use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Iban(String);
impl Iban {
#[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]
}
}
impl std::fmt::Display for Iban {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = &self.0;
let mut first = true;
for chunk in s.as_bytes().chunks(4) {
if !first {
write!(f, " ")?;
}
first = false;
f.write_str(std::str::from_utf8(chunk).unwrap_or(""))?;
}
Ok(())
}
}
impl AsRef<str> for Iban {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::ops::Deref for Iban {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl std::borrow::Borrow<str> for Iban {
fn borrow(&self) -> &str {
&self.0
}
}
impl From<Iban> for String {
fn from(iban: Iban) -> Self {
iban.0
}
}
impl FromStr for Iban {
type Err = IbanError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
validate_iban(s)
}
}
impl TryFrom<&str> for Iban {
type Error = IbanError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
validate_iban(s)
}
}
impl TryFrom<String> for Iban {
type Error = IbanError;
fn try_from(s: String) -> Result<Self, Self::Error> {
validate_iban(&s)
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for Iban {
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 Iban {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
validate_iban(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum IbanError {
#[error("IBAN length {len} is outside the valid range 15–34")]
InvalidLength {
len: usize,
},
#[error("IBAN contains invalid character: {ch:?}")]
InvalidCharacter {
ch: char,
},
#[error("IBAN checksum mismatch (mod97 = {remainder}, expected 1)")]
InvalidChecksum {
remainder: u64,
},
}
#[must_use = "ignoring a validated IBAN loses the result"]
pub fn validate_iban(raw: &str) -> Result<Iban, IbanError> {
let normalised: String = raw
.chars()
.filter(|c| !c.is_whitespace())
.map(|c| c.to_ascii_uppercase())
.collect();
let len = normalised.len();
if !(15..=34).contains(&len) {
return Err(IbanError::InvalidLength { len });
}
for c in normalised.chars() {
if !c.is_ascii_alphanumeric() {
return Err(IbanError::InvalidCharacter { ch: c });
}
}
let rearranged = format!("{}{}", &normalised[4..], &normalised[..4]);
let numeric: String = rearranged
.chars()
.flat_map(|c| {
if c.is_ascii_alphabetic() {
let n = (c as u8 - b'A' + 10).to_string();
n.chars().collect::<Vec<_>>()
} else {
vec![c]
}
})
.collect();
let mut remainder: u64 = 0;
for ch in numeric.chars() {
let digit = ch.to_digit(10).ok_or(IbanError::InvalidCharacter { ch })?;
remainder = (remainder * 10 + u64::from(digit)) % 97;
}
if remainder == 1 {
Ok(Iban(normalised))
} else {
Err(IbanError::InvalidChecksum { remainder })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn de_iban_with_spaces() {
let iban = validate_iban("DE89 3704 0044 0532 0130 00").unwrap();
assert_eq!(iban.as_str(), "DE89370400440532013000");
assert_eq!(iban.country_code(), "DE");
assert_eq!(iban.check_digits(), "89");
}
#[test]
fn de_iban_no_spaces() {
assert!(validate_iban("DE89370400440532013000").is_ok());
}
#[test]
fn de_iban_sparkasse() {
assert!(validate_iban("DE29100500005001065004").is_ok());
}
#[test]
fn nl_iban() {
assert!(validate_iban("NL91ABNA0417164300").is_ok());
}
#[test]
fn gb_iban() {
assert!(validate_iban("GB29 NWBK 6016 1331 9268 19").is_ok());
}
#[test]
fn at_iban() {
assert!(validate_iban("AT611904300234573201").is_ok());
}
#[test]
fn ch_iban() {
assert!(validate_iban("CH5604835012345678009").is_ok());
}
#[test]
fn lowercase_normalised() {
assert!(validate_iban("de89370400440532013000").is_ok());
}
#[test]
fn wrong_checksum() {
let err = validate_iban("DE89370400440532013001").unwrap_err();
assert!(matches!(err, IbanError::InvalidChecksum { .. }));
}
#[test]
fn too_short() {
assert!(matches!(
validate_iban("DE89").unwrap_err(),
IbanError::InvalidLength { len: 4 }
));
}
#[test]
fn too_long() {
let long = "DE".to_string() + &"1".repeat(33);
assert!(matches!(
validate_iban(&long).unwrap_err(),
IbanError::InvalidLength { len: 35 }
));
}
#[test]
fn empty() {
assert!(matches!(
validate_iban("").unwrap_err(),
IbanError::InvalidLength { len: 0 }
));
}
#[test]
fn special_chars_rejected() {
assert!(matches!(
validate_iban("DE89@70400440532013000").unwrap_err(),
IbanError::InvalidCharacter { ch: '@' }
));
}
#[test]
fn display_groups_of_four() {
let iban = validate_iban("DE89370400440532013000").unwrap();
assert_eq!(iban.to_string(), "DE89 3704 0044 0532 0130 00");
}
#[test]
fn only_whitespace_rejected() {
assert!(matches!(
validate_iban(" ").unwrap_err(),
IbanError::InvalidLength { len: 0 }
));
}
#[test]
fn minimum_valid_length_norway() {
assert!(validate_iban("NO9386011117947").is_ok());
}
#[test]
fn from_str() {
let iban: Iban = "DE89370400440532013000".parse().unwrap();
assert_eq!(iban.as_str(), "DE89370400440532013000");
}
#[test]
fn try_from_str() {
let iban = Iban::try_from("DE89370400440532013000").unwrap();
assert_eq!(iban.country_code(), "DE");
}
#[test]
fn try_from_string() {
let iban = Iban::try_from("DE89370400440532013000".to_owned()).unwrap();
assert_eq!(iban.as_str(), "DE89370400440532013000");
}
#[test]
fn into_string() {
let iban = validate_iban("DE89370400440532013000").unwrap();
let s: String = iban.into();
assert_eq!(s, "DE89370400440532013000");
}
#[test]
fn ord() {
let a = validate_iban("AT611904300234573201").unwrap();
let b = validate_iban("DE89370400440532013000").unwrap();
assert!(a < b); }
#[test]
fn deref_to_str() {
let iban = validate_iban("DE89370400440532013000").unwrap();
assert_eq!(iban.len(), 22); }
#[cfg(feature = "serde")]
#[test]
fn serde_roundtrip() {
let iban = validate_iban("DE89370400440532013000").unwrap();
let json = serde_json::to_string(&iban).unwrap();
assert_eq!(json, r#""DE89370400440532013000""#);
let back: Iban = serde_json::from_str(&json).unwrap();
assert_eq!(back, iban);
}
#[cfg(feature = "serde")]
#[test]
fn serde_invalid_rejected() {
let result: Result<Iban, _> = serde_json::from_str(r#""NOTANIBAN""#);
assert!(result.is_err());
}
}