use crate::error::{IdentifierError, LengthExpectation};
pub const IBAN_MAX_LEN: usize = 34;
pub const IBAN_MIN_LEN: usize = 15;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "validate", derive(garde::Validate))]
#[cfg_attr(feature = "validate", garde(allow_unvalidated))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(
feature = "schemars",
schemars(schema_with = "crate::schema_helpers::iban_schema")
)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
pub struct Iban(#[cfg_attr(feature = "validate", garde(custom(check_iban)))] Box<str>);
#[cfg(feature = "validate")]
fn check_iban(value: &str, _: &()) -> Result<(), garde::Error> {
validate_iban(value).map_err(garde::Error::from)
}
static IBAN_LENGTHS: &[(&str, usize)] = &[
("AD", 24),
("AE", 23),
("AL", 28),
("AT", 20),
("AZ", 28),
("BA", 20),
("BE", 16),
("BG", 22),
("BH", 22),
("BR", 29),
("BY", 28),
("CH", 21),
("CR", 22),
("CY", 28),
("CZ", 24),
("DE", 22),
("DK", 18),
("DO", 28),
("EE", 20),
("EG", 29),
("ES", 24),
("FI", 18),
("FO", 18),
("FR", 27),
("GB", 22),
("GE", 22),
("GI", 23),
("GL", 18),
("GR", 27),
("GT", 28),
("HR", 21),
("HU", 28),
("IE", 22),
("IL", 23),
("IS", 26),
("IT", 27),
("JO", 30),
("KW", 30),
("KZ", 20),
("LB", 28),
("LC", 32),
("LI", 21),
("LT", 20),
("LU", 20),
("LV", 21),
("MC", 27),
("MD", 24),
("ME", 22),
("MK", 19),
("MR", 27),
("MT", 31),
("MU", 30),
("NL", 18),
("NO", 15),
("PK", 24),
("PL", 28),
("PS", 29),
("PT", 25),
("QA", 29),
("RO", 24),
("RS", 22),
("SA", 24),
("SE", 24),
("SI", 19),
("SK", 24),
("SM", 27),
("TN", 24),
("TR", 26),
("UA", 29),
("VA", 22),
("VG", 24),
("XK", 20),
];
fn registered_iban_length(country: &str) -> Option<usize> {
IBAN_LENGTHS
.binary_search_by_key(&country, |&(c, _)| c)
.ok()
.map(|i| IBAN_LENGTHS[i].1)
}
fn normalise_iban(s: &str) -> String {
s.chars()
.filter(|c| !c.is_whitespace())
.map(|c| c.to_ascii_uppercase())
.collect()
}
fn validate_iban(s: &str) -> Result<(), IdentifierError> {
if !(IBAN_MIN_LEN..=IBAN_MAX_LEN).contains(&s.len()) {
return Err(IdentifierError::InvalidLength {
expected: LengthExpectation::RangeInclusive {
min: IBAN_MIN_LEN,
max: IBAN_MAX_LEN,
},
actual: s.len(),
});
}
let bytes = s.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
let ok = match i {
0 | 1 => b.is_ascii_uppercase(),
2 | 3 => b.is_ascii_digit(),
_ => b.is_ascii_uppercase() || b.is_ascii_digit(),
};
if !ok {
return Err(IdentifierError::InvalidCharacter {
position: i,
character: char_at(s, i),
});
}
}
let country = &s[..2];
if let Some(expected) = registered_iban_length(country) {
if s.len() != expected {
return Err(IdentifierError::InvalidLength {
expected: LengthExpectation::Exact(expected),
actual: s.len(),
});
}
}
if !mod97_is_one(s) {
return Err(IdentifierError::InvalidChecksum);
}
Ok(())
}
fn mod97_is_one(iban: &str) -> bool {
let bytes = iban.as_bytes();
let mut remainder: u32 = 0;
for &b in bytes[4..].iter().chain(&bytes[..4]) {
remainder = match b {
b'0'..=b'9' => remainder * 10 + u32::from(b - b'0'),
b'A'..=b'Z' => {
let v = u32::from(b - b'A') + 10;
remainder * 100 + v
}
_ => return false,
} % 97;
}
remainder == 1
}
impl Iban {
#[must_use = "the validated identifier is returned; ignoring it discards the result"]
pub fn new(s: &str) -> Result<Self, IdentifierError> {
let normalised = normalise_iban(s);
validate_iban(&normalised)?;
Ok(Self(Box::from(normalised.as_str())))
}
#[must_use]
pub fn country_code(&self) -> &str {
&self.0[..2]
}
#[must_use]
pub fn check_digits(&self) -> &str {
&self.0[2..4]
}
#[must_use]
pub fn bban(&self) -> &str {
&self.0[4..]
}
#[must_use]
pub fn is_german(&self) -> bool {
self.country_code() == "DE"
}
#[must_use]
pub fn bankleitzahl(&self) -> Option<&str> {
self.is_german().then(|| &self.0[4..12])
}
#[must_use]
pub fn kontonummer(&self) -> Option<&str> {
self.is_german().then(|| &self.0[12..])
}
#[must_use]
pub fn to_grouped_string(&self) -> String {
let mut out = String::with_capacity(self.0.len() + self.0.len() / 4);
for (i, c) in self.0.chars().enumerate() {
if i > 0 && i % 4 == 0 {
out.push(' ');
}
out.push(c);
}
out
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "validate", derive(garde::Validate))]
#[cfg_attr(feature = "validate", garde(allow_unvalidated))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(
feature = "schemars",
schemars(schema_with = "crate::schema_helpers::bic_schema")
)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
pub struct Bic(#[cfg_attr(feature = "validate", garde(custom(check_bic)))] Box<str>);
#[cfg(feature = "validate")]
fn check_bic(value: &str, _: &()) -> Result<(), garde::Error> {
validate_bic(value).map_err(garde::Error::from)
}
fn validate_bic(s: &str) -> Result<(), IdentifierError> {
if s.len() != 8 && s.len() != 11 {
return Err(IdentifierError::InvalidFormat {
description: format!("a BIC is 8 or 11 characters, got {}", s.len()).into(),
});
}
for (i, &b) in s.as_bytes().iter().enumerate() {
let ok = if i < 6 {
b.is_ascii_uppercase()
} else {
b.is_ascii_uppercase() || b.is_ascii_digit()
};
if !ok {
return Err(IdentifierError::InvalidCharacter {
position: i,
character: char_at(s, i),
});
}
}
Ok(())
}
impl Bic {
#[must_use = "the validated identifier is returned; ignoring it discards the result"]
pub fn new(s: &str) -> Result<Self, IdentifierError> {
let normalised = normalise_iban(s); validate_bic(&normalised)?;
Ok(Self(Box::from(normalised.as_str())))
}
#[must_use]
pub fn institution_code(&self) -> &str {
&self.0[..4]
}
#[must_use]
pub fn country_code(&self) -> &str {
&self.0[4..6]
}
#[must_use]
pub fn location_code(&self) -> &str {
&self.0[6..8]
}
#[must_use]
pub fn branch_code(&self) -> Option<&str> {
(self.0.len() == 11).then(|| &self.0[8..])
}
#[must_use]
pub fn is_head_office(&self) -> bool {
matches!(self.branch_code(), None | Some("XXX"))
}
#[must_use]
pub fn is_passive(&self) -> bool {
self.location_code().ends_with('1')
}
#[must_use]
pub fn is_german(&self) -> bool {
self.country_code() == "DE"
}
}
fn char_at(s: &str, i: usize) -> char {
s[i..].chars().next().unwrap_or('\u{FFFD}')
}
impl_identifier_traits!(Iban, "an IBAN (ISO 13616) with valid MOD-97 check digits");
impl_identifier_traits!(Bic, "a BIC (ISO 9362) of 8 or 11 characters");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_published_test_ibans() {
for iban in [
"DE89370400440532013000", "GB82WEST12345698765432", "FR1420041010050500013M02606", "AT611904300234573201", "CH9300762011623852957", "NL91ABNA0417164300", "BE68539007547034", "IT60X0542811101000000123456", "ES9121000418450200051332", "NO9386011117947", "MT84MALT011000012345MTLCAST001S", ] {
assert!(Iban::new(iban).is_ok(), "{iban} is a published valid IBAN");
}
}
#[test]
fn rejects_the_errors_mod97_is_designed_to_catch() {
let valid = "DE89370400440532013000";
assert!(Iban::new(valid).is_ok());
for pos in 4..valid.len() {
let mut bytes = valid.as_bytes().to_vec();
let orig = bytes[pos];
bytes[pos] = if orig == b'9' { b'8' } else { orig + 1 };
let mutated = String::from_utf8(bytes).expect("ascii");
assert!(
Iban::new(&mutated).is_err(),
"a single-digit error at {pos} slipped through: {mutated}"
);
}
for pos in 4..valid.len() - 1 {
let mut bytes = valid.as_bytes().to_vec();
if bytes[pos] == bytes[pos + 1] {
continue;
}
bytes.swap(pos, pos + 1);
let mutated = String::from_utf8(bytes).expect("ascii");
assert!(
Iban::new(&mutated).is_err(),
"a transposition at {pos} slipped through: {mutated}"
);
}
}
#[test]
fn normalises_grouping_and_case() {
let canonical = Iban::new("DE89370400440532013000").unwrap();
for written in [
"DE89 3704 0044 0532 0130 00",
"de89370400440532013000",
" DE89 3704 0044 0532 0130 00 ",
"DE89\t3704\n0044 0532 0130 00",
] {
assert_eq!(Iban::new(written).unwrap(), canonical, "{written:?}");
}
assert_eq!(canonical.as_ref(), "DE89370400440532013000");
assert_eq!(canonical.to_string(), "DE89370400440532013000");
}
#[test]
fn renders_the_print_form_in_groups_of_four() {
let iban = Iban::new("DE89370400440532013000").unwrap();
assert_eq!(iban.to_grouped_string(), "DE89 3704 0044 0532 0130 00");
assert_eq!(Iban::new(&iban.to_grouped_string()).unwrap(), iban);
}
#[test]
fn splits_a_german_iban_into_its_parts() {
let iban = Iban::new("DE89370400440532013000").unwrap();
assert_eq!(iban.country_code(), "DE");
assert_eq!(iban.check_digits(), "89");
assert_eq!(iban.bban(), "370400440532013000");
assert_eq!(iban.bankleitzahl(), Some("37040044"));
assert_eq!(iban.kontonummer(), Some("0532013000"));
assert!(iban.is_german());
}
#[test]
fn a_country_specific_length_is_enforced() {
let err = Iban::new("DE8937040044053201300").unwrap_err();
assert_eq!(
err,
IdentifierError::InvalidLength {
expected: LengthExpectation::Exact(22),
actual: 21,
}
);
}
#[test]
fn an_unregistered_country_is_left_to_the_checksum() {
assert_eq!(registered_iban_length("ZZ"), None);
let candidate = with_correct_check_digits("ZZ", "12345678901234");
assert!(
Iban::new(&candidate).is_ok(),
"{candidate} has valid check digits and an unknown country"
);
let mut bad = candidate.into_bytes();
bad[2] = if bad[2] == b'0' { b'1' } else { b'0' };
assert!(Iban::new(&String::from_utf8(bad).unwrap()).is_err());
}
fn with_correct_check_digits(country: &str, bban: &str) -> String {
for candidate in 2..=98u32 {
let iban = format!("{country}{candidate:02}{bban}");
if mod97_is_one(&iban) {
return iban;
}
}
unreachable!("some two-digit checksum always satisfies MOD-97")
}
#[test]
fn rejects_malformed_shapes() {
for (bad, why) in [
("", "empty"),
("DE89", "far too short"),
("D189370400440532013000", "digit in the country code"),
("DEX9370400440532013000", "letter in the check digits"),
("DE89370400440532013-00", "punctuation in the BBAN"),
("DE8937040044053201300012345678", "too long for DE"),
(
"DE89370400440532013000DE89370400440532013000",
"past the ISO ceiling",
),
] {
assert!(Iban::new(bad).is_err(), "{bad:?} must be rejected ({why})");
}
}
#[test]
fn rejects_non_ascii_without_panicking() {
for bad in [
"DE89370400440532013ÄÖÜ",
"DEß9370400440532013000",
"🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦",
] {
assert!(Iban::new(bad).is_err(), "{bad:?}");
}
}
#[test]
fn accepts_real_german_bics() {
for bic in [
"COBADEFFXXX", "MARKDEFF", "DEUTDEFF", "GENODEF1S04", "PBNKDEFFXXX",
] {
assert!(Bic::new(bic).is_ok(), "{bic} is a real BIC");
}
}
#[test]
fn splits_a_bic_into_its_parts() {
let bic = Bic::new("GENODEF1S04").unwrap();
assert_eq!(bic.institution_code(), "GENO");
assert_eq!(bic.country_code(), "DE");
assert_eq!(bic.location_code(), "F1");
assert_eq!(bic.branch_code(), Some("S04"));
assert!(bic.is_german());
assert!(!bic.is_head_office());
assert!(bic.is_passive(), "location code ending in 1 is passive");
}
#[test]
fn head_office_is_either_form() {
assert!(Bic::new("MARKDEFF").unwrap().is_head_office());
assert!(Bic::new("COBADEFFXXX").unwrap().is_head_office());
assert!(!Bic::new("COBADEFF100").unwrap().is_head_office());
}
#[test]
fn rejects_malformed_bics() {
for (bad, why) in [
("", "empty"),
("COBADEF", "7 characters"),
("COBADEFFX", "9 characters"),
("COBADEFFXX", "10 characters"),
("COBADEFFXXXX", "12 characters"),
("C0BADEFFXXX", "digit in the institution code"),
("COBAD1FFXXX", "digit in the country code"),
("COBA-EFFXXX", "punctuation"),
] {
assert!(Bic::new(bad).is_err(), "{bad:?} must be rejected ({why})");
}
}
#[test]
fn normalises_bic_case_and_spacing() {
assert_eq!(
Bic::new("coba deff xxx").unwrap(),
Bic::new("COBADEFFXXX").unwrap()
);
}
}