Skip to main content

use_iban/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Common IBAN primitives.
8pub mod prelude {
9    pub use crate::{Iban, IbanError};
10}
11
12const MAX_IBAN_LENGTH: usize = 34;
13
14const IBAN_COUNTRY_LENGTHS: &[(&str, usize)] = &[
15    ("AD", 24),
16    ("AE", 23),
17    ("AL", 28),
18    ("AT", 20),
19    ("AZ", 28),
20    ("BA", 20),
21    ("BE", 16),
22    ("BG", 22),
23    ("BH", 22),
24    ("BI", 16),
25    ("BR", 29),
26    ("BY", 28),
27    ("CH", 21),
28    ("CR", 22),
29    ("CY", 28),
30    ("CZ", 24),
31    ("DE", 22),
32    ("DK", 18),
33    ("DO", 28),
34    ("EE", 20),
35    ("EG", 29),
36    ("ES", 24),
37    ("FI", 18),
38    ("FO", 18),
39    ("FR", 27),
40    ("GB", 22),
41    ("GE", 22),
42    ("GI", 23),
43    ("GL", 18),
44    ("GR", 27),
45    ("GT", 28),
46    ("HR", 21),
47    ("HU", 28),
48    ("IE", 22),
49    ("IL", 23),
50    ("IQ", 23),
51    ("IS", 26),
52    ("IT", 27),
53    ("JO", 30),
54    ("KW", 30),
55    ("KZ", 20),
56    ("LB", 28),
57    ("LC", 32),
58    ("LI", 21),
59    ("LT", 20),
60    ("LU", 20),
61    ("LV", 21),
62    ("LY", 25),
63    ("MC", 27),
64    ("MD", 24),
65    ("ME", 22),
66    ("MK", 19),
67    ("MR", 27),
68    ("MT", 31),
69    ("MU", 30),
70    ("NL", 18),
71    ("NO", 15),
72    ("PK", 24),
73    ("PL", 28),
74    ("PS", 29),
75    ("PT", 25),
76    ("QA", 29),
77    ("RO", 24),
78    ("RS", 22),
79    ("SA", 24),
80    ("SC", 31),
81    ("SE", 24),
82    ("SI", 19),
83    ("SK", 24),
84    ("SM", 27),
85    ("SO", 23),
86    ("ST", 25),
87    ("SV", 28),
88    ("TL", 23),
89    ("TN", 24),
90    ("TR", 26),
91    ("UA", 29),
92    ("VA", 22),
93    ("VG", 24),
94    ("XK", 20),
95];
96
97/// A validated International Bank Account Number in compact uppercase form.
98#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
99pub struct Iban(String);
100
101impl Iban {
102    /// Creates an IBAN after normalization, length validation, and mod-97 validation.
103    ///
104    /// # Errors
105    ///
106    /// Returns an [`IbanError`] when the input is empty, malformed, uses an unsupported country
107    /// code, has the wrong country-specific length, or fails the mod-97 checksum.
108    pub fn new(value: impl AsRef<str>) -> Result<Self, IbanError> {
109        let compact = compact_iban(value.as_ref())?;
110
111        if compact.len() < 4 || compact.len() > MAX_IBAN_LENGTH {
112            return Err(IbanError::InvalidLength);
113        }
114
115        if !compact.as_bytes()[0].is_ascii_uppercase()
116            || !compact.as_bytes()[1].is_ascii_uppercase()
117        {
118            return Err(IbanError::InvalidCountryCode);
119        }
120
121        if !compact.as_bytes()[2].is_ascii_digit() || !compact.as_bytes()[3].is_ascii_digit() {
122            return Err(IbanError::InvalidCheckDigits);
123        }
124
125        let expected_length =
126            country_length(&compact[..2]).ok_or(IbanError::UnsupportedCountryCode)?;
127        if compact.len() != expected_length {
128            return Err(IbanError::InvalidCountryLength);
129        }
130
131        if !has_valid_checksum(&compact) {
132            return Err(IbanError::InvalidChecksum);
133        }
134
135        Ok(Self(compact))
136    }
137
138    /// Returns the compact uppercase IBAN.
139    #[must_use]
140    pub fn as_str(&self) -> &str {
141        &self.0
142    }
143
144    /// Returns the compact uppercase IBAN.
145    #[must_use]
146    pub fn compact(&self) -> &str {
147        self.as_str()
148    }
149
150    /// Returns the IBAN grouped with spaces every four characters.
151    #[must_use]
152    pub fn format_grouped(&self) -> String {
153        let space_count = self.0.len().saturating_sub(1) / 4;
154        let mut grouped = String::with_capacity(self.0.len() + space_count);
155
156        for (index, byte) in self.0.bytes().enumerate() {
157            if index > 0 && index % 4 == 0 {
158                grouped.push(' ');
159            }
160            grouped.push(char::from(byte));
161        }
162
163        grouped
164    }
165
166    /// Returns the two-letter country code.
167    #[must_use]
168    pub fn country_code(&self) -> &str {
169        &self.0[..2]
170    }
171
172    /// Returns the two numeric check digits.
173    #[must_use]
174    pub fn check_digits(&self) -> &str {
175        &self.0[2..4]
176    }
177
178    /// Returns the country-specific basic bank account number portion.
179    #[must_use]
180    pub fn bban(&self) -> &str {
181        &self.0[4..]
182    }
183}
184
185impl AsRef<str> for Iban {
186    fn as_ref(&self) -> &str {
187        self.as_str()
188    }
189}
190
191impl fmt::Display for Iban {
192    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
193        formatter.write_str(self.as_str())
194    }
195}
196
197impl FromStr for Iban {
198    type Err = IbanError;
199
200    fn from_str(value: &str) -> Result<Self, Self::Err> {
201        Self::new(value)
202    }
203}
204
205impl TryFrom<&str> for Iban {
206    type Error = IbanError;
207
208    fn try_from(value: &str) -> Result<Self, Self::Error> {
209        Self::new(value)
210    }
211}
212
213/// Errors returned while constructing IBAN values.
214#[derive(Clone, Copy, Debug, Eq, PartialEq)]
215pub enum IbanError {
216    /// The input was empty after trimming whitespace and spaces.
217    Empty,
218    /// The compact IBAN was shorter than four characters or longer than 34 characters.
219    InvalidLength,
220    /// The country code was not two uppercase ASCII letters.
221    InvalidCountryCode,
222    /// The check digits were not two ASCII digits.
223    InvalidCheckDigits,
224    /// The IBAN contained a character other than an ASCII letter, digit, or space.
225    InvalidCharacter,
226    /// The country code is not present in the static IBAN length table.
227    UnsupportedCountryCode,
228    /// The compact IBAN length did not match the country-specific IBAN length.
229    InvalidCountryLength,
230    /// The standard mod-97 checksum failed.
231    InvalidChecksum,
232}
233
234impl fmt::Display for IbanError {
235    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
236        match self {
237            Self::Empty => formatter.write_str("IBAN cannot be empty"),
238            Self::InvalidLength => formatter.write_str("IBAN length is invalid"),
239            Self::InvalidCountryCode => {
240                formatter.write_str("IBAN country code must be two letters")
241            },
242            Self::InvalidCheckDigits => formatter.write_str("IBAN check digits must be two digits"),
243            Self::InvalidCharacter => {
244                formatter.write_str("IBAN must contain only ASCII letters, digits, or spaces")
245            },
246            Self::UnsupportedCountryCode => formatter.write_str("IBAN country code is unsupported"),
247            Self::InvalidCountryLength => {
248                formatter.write_str("IBAN length does not match the country-specific length")
249            },
250            Self::InvalidChecksum => formatter.write_str("IBAN mod-97 checksum is invalid"),
251        }
252    }
253}
254
255impl Error for IbanError {}
256
257fn compact_iban(value: &str) -> Result<String, IbanError> {
258    let value = value.trim();
259    if value.is_empty() {
260        return Err(IbanError::Empty);
261    }
262
263    let mut compact = String::with_capacity(value.len());
264    for byte in value.bytes() {
265        match byte {
266            b' ' => {},
267            b'a'..=b'z' => compact.push(char::from(byte.to_ascii_uppercase())),
268            b'A'..=b'Z' | b'0'..=b'9' => compact.push(char::from(byte)),
269            _ => return Err(IbanError::InvalidCharacter),
270        }
271    }
272
273    if compact.is_empty() {
274        return Err(IbanError::Empty);
275    }
276
277    Ok(compact)
278}
279
280fn country_length(country_code: &str) -> Option<usize> {
281    IBAN_COUNTRY_LENGTHS
282        .iter()
283        .find_map(|(country, length)| (*country == country_code).then_some(*length))
284}
285
286fn has_valid_checksum(value: &str) -> bool {
287    let rearranged = value[4..].bytes().chain(value[..4].bytes());
288    let mut remainder = 0_u32;
289
290    for byte in rearranged {
291        if byte.is_ascii_digit() {
292            remainder = ((remainder * 10) + u32::from(byte - b'0')) % 97;
293        } else if byte.is_ascii_uppercase() {
294            let letter_value = u32::from(byte - b'A') + 10;
295            remainder = ((remainder * 10) + (letter_value / 10)) % 97;
296            remainder = ((remainder * 10) + (letter_value % 10)) % 97;
297        } else {
298            return false;
299        }
300    }
301
302    remainder == 1
303}
304
305#[cfg(test)]
306mod tests {
307    use super::{Iban, IbanError};
308
309    #[test]
310    fn accepts_valid_ibans() -> Result<(), IbanError> {
311        let cases = [
312            ("GB82 WEST 1234 5698 7654 32", "GB82WEST12345698765432"),
313            ("DE89 3704 0044 0532 0130 00", "DE89370400440532013000"),
314            (
315                "FR14 2004 1010 0505 0001 3M02 606",
316                "FR1420041010050500013M02606",
317            ),
318        ];
319
320        for (input, compact) in cases {
321            let iban = Iban::new(input)?;
322            assert_eq!(iban.as_str(), compact);
323            assert_eq!(iban.compact(), compact);
324        }
325
326        Ok(())
327    }
328
329    #[test]
330    fn normalizes_lowercase_and_formats_groups() -> Result<(), IbanError> {
331        let iban = Iban::new("gb82 west 1234 5698 7654 32")?;
332
333        assert_eq!(iban.as_str(), "GB82WEST12345698765432");
334        assert_eq!(iban.format_grouped(), "GB82 WEST 1234 5698 7654 32");
335        assert_eq!(iban.country_code(), "GB");
336        assert_eq!(iban.check_digits(), "82");
337        assert_eq!(iban.bban(), "WEST12345698765432");
338        Ok(())
339    }
340
341    #[test]
342    fn rejects_mod97_failures() {
343        assert_eq!(
344            Iban::new("GB82 WEST 1234 5698 7654 33"),
345            Err(IbanError::InvalidChecksum)
346        );
347    }
348
349    #[test]
350    fn rejects_invalid_characters_and_country_parts() {
351        assert_eq!(Iban::new(""), Err(IbanError::Empty));
352        assert_eq!(
353            Iban::new("1B82WEST12345698765432"),
354            Err(IbanError::InvalidCountryCode)
355        );
356        assert_eq!(
357            Iban::new("GBXXWEST12345698765432"),
358            Err(IbanError::InvalidCheckDigits)
359        );
360        assert_eq!(
361            Iban::new("GB82-WEST-1234"),
362            Err(IbanError::InvalidCharacter)
363        );
364    }
365
366    #[test]
367    fn rejects_unsupported_or_wrong_country_lengths() {
368        assert_eq!(
369            Iban::new("US82WEST12345698765432"),
370            Err(IbanError::UnsupportedCountryCode)
371        );
372        assert_eq!(
373            Iban::new("DE8937040044053201300"),
374            Err(IbanError::InvalidCountryLength)
375        );
376    }
377}