// IBAN (International Bank Account Number) validation, per ISO 13616.
// An IBAN is a 2-letter country code, 2 check digits, then a country-specific
// account part. The check digits are validated with the mod-97-10 scheme.
///|
/// Registered IBAN lengths by country code. An IBAN that starts with a code
/// missing from this table cannot be validated.
let iban_lengths : Array[(String, Int)] = [
("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),
("IQ", 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),
("SC", 31),
("SE", 24),
("SI", 19),
("SK", 24),
("SM", 27),
("ST", 25),
("SV", 28),
("TL", 23),
("TN", 24),
("TR", 26),
("UA", 29),
("VA", 22),
("VG", 24),
("XK", 20),
]
///|
/// The registered IBAN length for a country code, or None if unrecognised.
pub fn iban_expected_length(cc : String) -> Int? {
for entry in iban_lengths {
if entry.0 == cc {
return Some(entry.1)
}
}
None
}
///|
/// Strip spaces and uppercase so a human-formatted IBAN can be checked.
/// Only ASCII letters and digits survive.
pub fn iban_normalize(s : String) -> String {
let buf = StringBuilder()
for ch in s {
let u = upper_ascii(ch)
if (u >= 'A' && u <= 'Z') || (u >= '0' && u <= '9') {
buf.write_char(u)
}
}
buf.to_string()
}
///|
/// Numeric value of an IBAN character: digits keep their value, letters map
/// to 10..35 (so 'A' is 10 and 'Z' is 35) as required by ISO 13616.
fn iban_char_value(c : Char) -> Int {
if c >= '0' && c <= '9' {
c.to_int() - '0'.to_int()
} else {
10 + (c.to_int() - 'A'.to_int())
}
}
///|
/// Remainder modulo 97 of a character sequence, expanding each character to
/// its numeric value. Two digits are folded in for each letter.
fn mod97_of(cs : Array[Char]) -> Int {
let mut rem = 0
for c in cs {
let v = iban_char_value(c)
if v >= 10 {
rem = rem * 10 + v / 10
rem = rem % 97
}
rem = rem * 10 + v % 10
rem = rem % 97
}
rem
}
///|
/// Remainder of the rearranged IBAN modulo 97. The first four characters are
/// moved to the end first, as ISO 13616 prescribes.
fn iban_mod97(cs : Array[Char]) -> Int {
let n = cs.length()
let rotated : Array[Char] = []
for i = 0; i < n; i = i + 1 {
rotated.push(cs[(i + 4) % n])
}
mod97_of(rotated)
}
///|
/// Write a value below 100 as exactly two digits.
fn two_digits(v : Int) -> String {
if v < 10 {
"0" + v.to_string()
} else {
v.to_string()
}
}
///|
/// True when the first two characters are ASCII letters.
fn alpha_prefix(cs : Array[Char]) -> Bool {
cs.length() >= 2 &&
cs[0] >= 'A' &&
cs[0] <= 'Z' &&
cs[1] >= 'A' &&
cs[1] <= 'Z'
}
///|
/// True when characters 3 and 4 are digits.
fn digit_check_positions(cs : Array[Char]) -> Bool {
cs.length() >= 4 &&
cs[2] >= '0' &&
cs[2] <= '9' &&
cs[3] >= '0' &&
cs[3] <= '9'
}
///|
/// Validate an IBAN, ignoring spaces and case. Checks the country code, the
/// registered length for that country, and the mod-97 check digits.
pub fn iban_valid(s : String) -> Bool {
let canon = iban_normalize(s)
let cs = chars_of(canon)
let n = cs.length()
if n < 4 || n > 34 {
return false
}
if !alpha_prefix(cs) || !digit_check_positions(cs) {
return false
}
let cc = match substring(canon, 0, 2) {
Some(x) => x
None => return false
}
match iban_expected_length(cc) {
None => false
Some(expected) => n == expected && iban_mod97(cs) == 1
}
}
///|
/// The two-letter country code of an IBAN, or None when it is malformed.
pub fn iban_country(s : String) -> String? {
let canon = iban_normalize(s)
let cs = chars_of(canon)
if !alpha_prefix(cs) {
return None
}
substring(canon, 0, 2)
}
///|
/// Group a valid IBAN into blocks of four for display, e.g.
/// "GB82 WEST 1234 5698 7654 32". Returns None when the input is not valid.
pub fn iban_format(s : String) -> String? {
let canon = iban_normalize(s)
if !iban_valid(canon) {
return None
}
let cs = chars_of(canon)
let buf = StringBuilder()
for i = 0; i < cs.length(); i = i + 1 {
if i > 0 && i % 4 == 0 {
buf.write_char(' ')
}
buf.write_char(cs[i])
}
Some(buf.to_string())
}
///|
/// The country part of an IBAN: everything after the four-character header.
pub fn iban_bban(s : String) -> String? {
let canon = iban_normalize(s)
let cs = chars_of(canon)
if !iban_valid(canon) {
return None
}
substring(canon, 4, cs.length() - 4)
}
///|
/// Compute the two check digits that turn a country code and BBAN into a
/// complete IBAN. For example `iban_check_digits("GB", "WEST12345698765432")`
/// gives "82". The country must be registered and the total length must match
/// the registered length for that country.
pub fn iban_check_digits(country : String, bban : String) -> String? {
let cc = iban_normalize(country)
let body = iban_normalize(bban)
let ccs = chars_of(cc)
if !alpha_prefix(ccs) || ccs.length() != 2 {
return None
}
if body.length() == 0 {
return None
}
let expected = match iban_expected_length(cc) {
None => return None
Some(n) => n
}
let joined = chars_of(body)
if ccs.length() + joined.length() + 2 != expected {
return None
}
for c in ccs {
joined.push(c)
}
joined.push('0')
joined.push('0')
Some(two_digits(98 - mod97_of(joined)))
}
///|
/// Assemble a complete IBAN from a country code and a BBAN. The result is
/// always check-digit correct; it is not checked against any national BBAN
/// format, only against the ISO 13616 length and check digits.
pub fn iban_assemble(country : String, bban : String) -> String? {
match iban_check_digits(country, bban) {
None => None
Some(cd) => {
let s = iban_normalize(country) + cd + iban_normalize(bban)
if iban_valid(s) {
Some(s)
} else {
None
}
}
}
}