iban.rs (11055B)
1 /* 2 This file is part of TALER 3 Copyright (C) 2025, 2026 Taler Systems SA 4 5 TALER is free software; you can redistribute it and/or modify it under the 6 terms of the GNU Affero General Public License as published by the Free Software 7 Foundation; either version 3, or (at your option) any later version. 8 9 TALER is distributed in the hope that it will be useful, but WITHOUT ANY 10 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. 12 13 You should have received a copy of the GNU Affero General Public License along with 14 TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 use std::{ 18 fmt::{Debug, Display}, 19 ops::Deref, 20 str::FromStr, 21 }; 22 23 pub use registry::Country; 24 use registry::{IbanC, PatternErr, check_pattern, rng_pattern}; 25 use serde_with::{DeserializeFromStr, SerializeDisplay}; 26 27 use super::utils::InlineStr; 28 29 mod registry; 30 31 const MAX_IBAN_SIZE: usize = 34; 32 const MAX_BIC_SIZE: usize = 11; 33 34 /// Parse an IBAN, panic if malformed 35 pub fn iban(iban: impl AsRef<str>) -> IBAN { 36 iban.as_ref().parse().expect("invalid IBAN") 37 } 38 39 /// Parse an BIC, panic if malformed 40 pub fn bic(bic: impl AsRef<str>) -> BIC { 41 bic.as_ref().parse().expect("invalid BIC") 42 } 43 44 #[derive(Clone, Copy, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)] 45 /// International Bank Account Number (IBAN) 46 pub struct IBAN { 47 country: Country, 48 encoded: InlineStr<MAX_IBAN_SIZE>, 49 } 50 51 impl IBAN { 52 /// Compute IBAN checksum 53 fn iban_checksum(s: &[u8]) -> u8 { 54 (s.iter().cycle().skip(4).take(s.len()).fold(0u32, |sum, b| { 55 if b.is_ascii_digit() { 56 (sum * 10 + (b - b'0') as u32) % 97 57 } else { 58 (sum * 100 + (b - b'A' + 10) as u32) % 97 59 } 60 })) as u8 61 } 62 63 fn from_raw_parts(country: Country, bban: &[u8]) -> Self { 64 // Create an iban with an empty digit check 65 let mut encoded = InlineStr::try_from_iter( 66 country 67 .iso_bytes() 68 .iter() 69 .copied() 70 .chain([b'0', b'0']) 71 .chain(bban.iter().copied()), 72 ) 73 .unwrap(); 74 // Compute check digit 75 let checksum = 98 - Self::iban_checksum(encoded.deref()); 76 77 // And insert it 78 unsafe { 79 // SAFETY: we only insert ASCII digits 80 let buf = encoded.deref_mut(); 81 buf[3] = checksum % 10 + b'0'; 82 buf[2] = checksum / 10 + b'0'; 83 } 84 85 Self { country, encoded } 86 } 87 88 pub fn from_parts(country: Country, bban: &str) -> Self { 89 check_pattern(bban.as_bytes(), country.bban_pattern()).unwrap(); // TODO return Result 90 Self::from_raw_parts(country, bban.as_bytes()) 91 } 92 93 pub fn random(country: Country) -> Self { 94 let mut bban = [0u8; MAX_IBAN_SIZE - 4]; 95 rng_pattern(&mut bban, country.bban_pattern()); 96 Self::from_raw_parts(country, &bban[..country.bban_len()]) 97 } 98 99 pub fn country(&self) -> Country { 100 self.country 101 } 102 103 pub fn bban(&self) -> &str { 104 // SAFETY len >= 5 105 unsafe { self.as_ref().get_unchecked(4..) } 106 } 107 108 pub fn bank_id(&self) -> &str { 109 &self.bban()[self.country.bank_id()] 110 } 111 112 pub fn branch_id(&self) -> &str { 113 &self.bban()[self.country.branch_id()] 114 } 115 } 116 117 impl AsRef<str> for IBAN { 118 fn as_ref(&self) -> &str { 119 self.encoded.as_ref() 120 } 121 } 122 123 #[derive(Debug, PartialEq, Eq, thiserror::Error)] 124 pub enum IbanErrorKind { 125 #[error("contains illegal characters (only 0-9A-Z allowed)")] 126 Invalid, 127 #[error("contains invalid characters")] 128 Malformed, 129 #[error("unknown country {0}")] 130 UnknownCountry(String), 131 #[error("too long expected max {MAX_IBAN_SIZE} chars got {0}")] 132 Overflow(usize), 133 #[error("too short expected min 4 chars got {0}")] 134 Underflow(usize), 135 #[error("wrong size expected {0} chars got {1}")] 136 Size(u8, usize), 137 #[error("checksum expected 1 got {0}")] 138 Checksum(u8), 139 } 140 141 #[derive(Debug, thiserror::Error)] 142 #[error("iban '{iban}' {kind}")] 143 pub struct ParseIbanError { 144 iban: String, 145 pub kind: IbanErrorKind, 146 } 147 148 impl FromStr for IBAN { 149 type Err = ParseIbanError; 150 151 fn from_str(s: &str) -> Result<Self, Self::Err> { 152 let bytes: &[u8] = s.as_bytes(); 153 if !bytes 154 .iter() 155 .all(|b| b.is_ascii_whitespace() || b.is_ascii_alphanumeric()) 156 { 157 Err(IbanErrorKind::Invalid) 158 } else if let Some(encoded) = InlineStr::try_from_iter( 159 bytes 160 .iter() 161 .filter_map(|b| (!b.is_ascii_whitespace()).then_some(b.to_ascii_uppercase())), 162 ) { 163 if encoded.len() < 4 { 164 Err(IbanErrorKind::Underflow(encoded.len())) 165 } else if !IbanC::A.check(&encoded[0..2]) || !IbanC::N.check(&encoded[2..4]) { 166 Err(IbanErrorKind::Malformed) 167 } else if let Some(country) = Country::from_iso(&encoded.as_ref()[..2]) { 168 if let Err(e) = check_pattern(&encoded[4..], country.bban_pattern()) { 169 Err(match e { 170 PatternErr::Len(expected, got) => IbanErrorKind::Size(expected, got), 171 PatternErr::Malformed => IbanErrorKind::Malformed, 172 }) 173 } else { 174 let checksum = Self::iban_checksum(&encoded); 175 if checksum != 1 { 176 Err(IbanErrorKind::Checksum(checksum)) 177 } else { 178 Ok(Self { country, encoded }) 179 } 180 } 181 } else { 182 Err(IbanErrorKind::UnknownCountry( 183 encoded.as_ref()[..2].to_owned(), 184 )) 185 } 186 } else { 187 Err(IbanErrorKind::Overflow(bytes.len())) 188 } 189 .map_err(|kind| ParseIbanError { 190 iban: s.to_owned(), 191 kind, 192 }) 193 } 194 } 195 196 impl Display for IBAN { 197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 198 Display::fmt(&self.as_ref(), f) 199 } 200 } 201 202 impl Debug for IBAN { 203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 204 Display::fmt(&self, f) 205 } 206 } 207 208 /// Bank Identifier Code (BIC) 209 #[derive(Debug, Clone, Copy, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)] 210 pub struct BIC(InlineStr<MAX_BIC_SIZE>); 211 212 impl BIC { 213 pub fn bank_code(&self) -> &str { 214 // SAFETY len >= 8 215 unsafe { self.as_ref().get_unchecked(0..4) } 216 } 217 218 pub fn country_code(&self) -> &str { 219 // SAFETY len >= 8 220 unsafe { self.as_ref().get_unchecked(4..6) } 221 } 222 223 pub fn location_code(&self) -> &str { 224 // SAFETY len >= 8 225 unsafe { self.as_ref().get_unchecked(6..8) } 226 } 227 228 pub fn branch_code(&self) -> Option<&str> { 229 // SAFETY len >= 8 230 let s = unsafe { self.as_ref().get_unchecked(8..) }; 231 (!s.is_empty()).then_some(s) 232 } 233 } 234 235 impl AsRef<str> for BIC { 236 fn as_ref(&self) -> &str { 237 self.0.as_ref() 238 } 239 } 240 241 #[derive(Debug, PartialEq, Eq, thiserror::Error)] 242 pub enum BicErrorKind { 243 #[error("contains illegal characters (only 0-9A-Z allowed)")] 244 Invalid, 245 #[error("invalid check digit")] 246 BankCode, 247 #[error("invalid country code")] 248 CountryCode, 249 #[error("bad size expected 8 or {MAX_BIC_SIZE} chars for {0}")] 250 Size(usize), 251 } 252 253 #[derive(Debug, thiserror::Error)] 254 #[error("bic '{bic}' {kind}")] 255 pub struct ParseBicError { 256 bic: String, 257 pub kind: BicErrorKind, 258 } 259 260 impl FromStr for BIC { 261 type Err = ParseBicError; 262 263 fn from_str(s: &str) -> Result<Self, Self::Err> { 264 let bytes: &[u8] = s.as_bytes(); 265 let len = bytes.len(); 266 if len != 8 && len != MAX_BIC_SIZE { 267 Err(BicErrorKind::Size(len)) 268 } else if !bytes[0..4].iter().all(u8::is_ascii_alphabetic) { 269 Err(BicErrorKind::BankCode) 270 } else if !bytes[4..6].iter().all(u8::is_ascii_alphabetic) { 271 Err(BicErrorKind::CountryCode) 272 } else if !bytes[6..].iter().all(u8::is_ascii_alphanumeric) { 273 Err(BicErrorKind::Invalid) 274 } else { 275 Ok(Self( 276 InlineStr::try_from_iter(bytes.iter().copied().map(|b| b.to_ascii_uppercase())) 277 .unwrap(), 278 )) 279 } 280 .map_err(|kind| ParseBicError { 281 bic: s.to_owned(), 282 kind, 283 }) 284 } 285 } 286 287 impl Display for BIC { 288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 289 Display::fmt(&self.as_ref(), f) 290 } 291 } 292 293 #[test] 294 fn parse_iban() { 295 use registry::VALID_IBAN; 296 for (valid, bban) in VALID_IBAN { 297 // Parsing 298 let iban = IBAN::from_str(valid).unwrap(); 299 assert_eq!(iban.to_string(), valid); 300 // Roundtrip 301 let from_parts = IBAN::from_parts(iban.country(), iban.bban()); 302 assert_eq!(from_parts.to_string(), valid); 303 304 // BBAN 305 if let Some(bban) = bban { 306 assert_eq!(bban, iban.bban()); 307 } 308 309 // Random 310 let rand = IBAN::random(iban.country()); 311 let parsed = IBAN::from_str(rand.as_ref()).unwrap(); 312 assert_eq!(rand, parsed); 313 } 314 315 for (invalid, err) in [ 316 ("FR1420041@10050500013M02606", IbanErrorKind::Invalid), 317 ("", IbanErrorKind::Underflow(0)), 318 ("12345678901234567890123456", IbanErrorKind::Malformed), 319 ("FR", IbanErrorKind::Underflow(2)), 320 ("FRANCE123456", IbanErrorKind::Malformed), 321 ("DE44500105175407324932", IbanErrorKind::Checksum(28)), 322 ] { 323 let iban = IBAN::from_str(invalid).unwrap_err(); 324 assert_eq!(iban.kind, err); 325 } 326 } 327 328 #[test] 329 fn parse_bic() { 330 for (valid, parts) in [ 331 ("DEUTDEFF", ("DEUT", "DE", "FF", None)), // Deutsche Bank, Germany 332 ("NEDSZAJJ", ("NEDS", "ZA", "JJ", None)), // Nedbank, South Africa // codespell:ignore 333 ("BARCGB22", ("BARC", "GB", "22", None)), // Barclays, UK 334 ("CHASUS33XXX", ("CHAS", "US", "33", Some("XXX"))), // JP Morgan Chase, USA (branch) 335 ("BNPAFRPP", ("BNPA", "FR", "PP", None)), // BNP Paribas, France 336 ("INGBNL2A", ("INGB", "NL", "2A", None)), // ING Bank, Netherlands 337 ] { 338 let bic = BIC::from_str(valid).unwrap(); 339 assert_eq!( 340 ( 341 bic.bank_code(), 342 bic.country_code(), 343 bic.location_code(), 344 bic.branch_code() 345 ), 346 parts 347 ); 348 assert_eq!(bic.to_string(), valid); 349 } 350 351 for (invalid, err) in [ 352 ("DEU", BicErrorKind::Size(3)), 353 ("DEUTDEFFA1BC", BicErrorKind::Size(12)), 354 ("D3UTDEFF", BicErrorKind::BankCode), 355 ("DEUTD3FF", BicErrorKind::CountryCode), 356 ("DEUTDEFF@@1", BicErrorKind::Invalid), 357 ] { 358 let bic = BIC::from_str(invalid).unwrap_err(); 359 assert_eq!(bic.kind, err, "{invalid}"); 360 } 361 }