7
\$\begingroup\$

I wrote a JavaScript function for validating bank account numbers in IBAN format for my own joy. Could you have a look if you don't see any obvious mistakes or places for improvement?

function isIbanValid(value) {

    var lengthMap = getLengthMap();

    //cleanup
    value = value.toString().toUpperCase().replace(/\s/g, '').replace(/[-]/g, '');
    //check if alphanumeric
    if (!/^[a-zA-Z0-9]+$/.test(value)) return false;
    //extract countrycode
    var countryCode = value.substring(0, 2);
    //check if letter
    if (!/([a-z]+[\s\-]?)*/i.test(countryCode)) return false;
    //check string length
    if (value.length != lengthMap[countryCode]) return false;

    value = value.concat(value.substring(0, 4)).substring(4);
    value = value.replace(countryCode, countryCodeToStringValue(countryCode));

    return modulo(value, 97) == 1;

    function countryCodeToStringValue(countryCode) {
        return "".concat(((countryCode.charCodeAt(0)) - 55).toString() + (countryCode.charCodeAt(1) - 55).toString());
    }

    function modulo(divident, divisor) {
        var quantization = 12;
        while (quantization < divident.length) {
            var part = divident.substring(0, quantization);
            divident = (part % divisor) + divident.substring(quantization);
        }
        return divident % divisor;
    }

    function getLengthMap() {
        var lengthMap = {};
        lengthMap["AD"] = 24;
        lengthMap["AT"] = 20;
        lengthMap["AZ"] = 28;
        lengthMap["BH"] = 22;
        lengthMap["BE"] = 16;
        lengthMap["BA"] = 20;
        lengthMap["BR"] = 29;
        lengthMap["BG"] = 22;
        lengthMap["CR"] = 21;
        lengthMap["HR"] = 21;
        lengthMap["CY"] = 28;
        lengthMap["CZ"] = 24;
        lengthMap["DK"] = 18;
        lengthMap["DO"] = 28;
        lengthMap["EE"] = 20;
        lengthMap["FO"] = 18;
        lengthMap["FI"] = 18;
        lengthMap["FR"] = 27;
        lengthMap["DE"] = 22;
        lengthMap["GR"] = 27;
        lengthMap["GI"] = 23;
        lengthMap["GL"] = 18;
        lengthMap["GT"] = 28;
        lengthMap["HU"] = 28;
        lengthMap["IS"] = 26;
        lengthMap["IE"] = 22;
        lengthMap["IL"] = 23;
        lengthMap["IT"] = 27;
        lengthMap["JO"] = 30;
        lengthMap["KZ"] = 20;
        lengthMap["KW"] = 30;
        lengthMap["LV"] = 21;
        lengthMap["LB"] = 28;
        lengthMap["LI"] = 21;
        lengthMap["LT"] = 20;
        lengthMap["LU"] = 20;
        lengthMap["MK"] = 19;
        lengthMap["MT"] = 31;
        lengthMap["MR"] = 27;
        lengthMap["MU"] = 30;
        lengthMap["MC"] = 27;
        lengthMap["MD"] = 24;
        lengthMap["ME"] = 22;
        lengthMap["NL"] = 18;
        lengthMap["NO"] = 15;
        lengthMap["PK"] = 24;
        lengthMap["PS"] = 29;
        lengthMap["PL"] = 28;
        lengthMap["PT"] = 25;
        lengthMap["QA"] = 29;
        lengthMap["RO"] = 24;
        lengthMap["SM"] = 27;
        lengthMap["SA"] = 24;
        lengthMap["RS"] = 22;
        lengthMap["SK"] = 24;
        lengthMap["SI"] = 19;
        lengthMap["ES"] = 24;
        lengthMap["SE"] = 24;
        lengthMap["CH"] = 21;
        lengthMap["TN"] = 24;
        lengthMap["TR"] = 26;
        lengthMap["AE"] = 23;
        lengthMap["GB"] = 22;

        return lengthMap;
    }
}
\$\endgroup\$
2
  • 2
    \$\begingroup\$ Could you include the rules against which you are validating? Some are familiar with them, some others aren't. \$\endgroup\$
    – skiwi
    Commented Jul 27, 2014 at 11:32
  • \$\begingroup\$ Here's a super minor point: it's dividend, not divident. Your meaning still comes across clearly, but it doesn't hurt to fix it. \$\endgroup\$
    – Schism
    Commented Jul 28, 2014 at 14:06

2 Answers 2

8
\$\begingroup\$
  1. I'm pretty sure this will fail for some IBANs. You convert the country code to digits, but I don't see you doing the same for any other letters in the IBAN - and there might well be others. The example Wikipedia gives is GB82WEST12345698765432. The "WEST" should be converted to numbers as well (same procedure as the country code; 'A' = 10, B = 11, etc.).

  2. Not sure why getLengthMap is a function - it's basically a constant value, and might as well be declared as a simple variable.

  3. The regexes have some redundancy.This one, /^[a-zA-Z0-9]+$/, checks for lowercase letters, though the line before has already called toUpperCase(). And the next regex accepts trailing whitespace and dashes, though those have been removed.

  4. And while you're using regexes, you might as well use that to pick the string apart, rather than substring plus magic numbers.

  5. Your modulo function seems fine, though there's little reason to have it accept a divisor argument. Since it's an internal helper function specifically for IBAN, it'll always be mod 97. So in this case, I might actually prefer that that was hard-coded (and the function was named mod97 or something equally explanatory).

In all, I think you can do a lot more with fewer regexes, which would help streamline the code.

To sanitize the input:

var iban = value.toUpperCase().replace(/[^A-Z0-9]/g, '');

To "parse" the IBAN:

var match = iban.match(/^([A-Z]{2})(\d{2})(.+)$/);

if match is null the code's automatically invalid. Otherwise, match[1] will be the 2-letter country code, match[2] will be the check digits, and match[3] will be the rest of the IBAN. Provided there's a match it should then rearranged as match[3] + match[1] + match[2]

Alternatively, you can check and rearrange in one go:

var code = iban.replace(/^([A-Z]{2})(\d{2})(.+)$/, "$3$1$2");

if code === iban, nothing's been rearranged, which means there wasn't a match, and the code's invalid. You'd still have to extract the country code, though, so not much is won by doing this.

Finally, to convert letters to numbers:

 var digits = iban.replace(/[A-Z]/g, function (letter) {
   return String(letter.charCodeAt(0) - 55);
 });

Here's my take

var validIBAN = (function () { // use an IIFE
  // A "constant" lookup table of IBAN lengths per country
  // (the funky formatting is just to make it fit better in the answer here on CR)
  var CODE_LENGTHS = {
    AD: 24, AE: 23, AT: 20, AZ: 28, BA: 20, BE: 16, BG: 22, BH: 22, BR: 29,
    CH: 21, CR: 21, CY: 28, CZ: 24, DE: 22, DK: 18, DO: 28, EE: 20, ES: 24,
    FI: 18, FO: 18, FR: 27, GB: 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,
    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
  };

  // piece-wise mod97 using 9 digit "chunks", as per Wikipedia's example:
  // http://en.wikipedia.org/wiki/International_Bank_Account_Number#Modulo_operation_on_IBAN
  function mod97(string) {
    var checksum = string.slice(0, 2),
        fragment;

    for(var offset = 2 ; offset < string.length ; offset += 7) {
      fragment = String(checksum) + string.substring(offset, offset + 7);
      checksum = parseInt(fragment, 10) % 97;
    }

    return checksum;
  }

  // return a function that does the actual work
  return function (input) {
    var iban = String(input).toUpperCase().replace(/[^A-Z0-9]/g, ''), // keep only alphanumeric characters
        code = iban.match(/^([A-Z]{2})(\d{2})([A-Z\d]+)$/),           // match and capture (1) the country code, (2) the check digits, and (3) the rest
        digits;

    // check syntax and length
    if(!code || iban.length !== CODE_LENGTHS[code[1]]) {
      return false;
    }

    // rearrange country code and check digits, and convert chars to ints
    digits = (code[3] + code[1] + code[2]).replace(/[A-Z]/g, function (letter) {
      return letter.charCodeAt(0) - 55;
    });

    // final check
    return mod97(digits) === 1;
  };
}());
\$\endgroup\$
2
\$\begingroup\$

A couple of things that came to me after playing around with this (given one is willing to use the new features of javascript):

  • Making a class for IBAN (ES2015)
  • One could get more fine-grained info using the new named groups on RegExps (ES2018)

As an example of the latter:

IBAN.formats = {
  AD: /^(?<bank>\d{4})(?<branch>\d{4})(?<account>\w{12})$/
, AE: /^(?<bank>\d{3})(?<account>\d{16})$/
, AL: /^(?<bank>\d{3})(?<branch>\d{4})(?<check>\d{1})(?<account>\w{16})$/
  // ...
}

(as you can see from the last entry, there can be further BBAN check digits)

then one could just count the lengths from those:

IBAN.lengths = Object.entries(IBAN.formats).reduce((acc, [country, re]) => {
  acc[country] = re.source.match(/\d+/g).reduce((total, digit) =>
    total + (digit === "0" ? 1 : Number(digit))
  , 0)
  return acc
}, {})

to test for the length for example in the constructor:

constructor(str) {
  this.normalized = str.toUpperCase().replace(/[^A-Z0-9]/g, "")
  this.country = this.normalized.slice(0, 2)
  this.checksum = this.normalized.slice(2, 4)
  this.bban = this.normalized.slice(4)

  if(this.bban.length !== IBAN.lengths[this.country])
    throw new Error(`Invalid length for ${this.country} BBAN:
      expected ${IBAN.lengths[this.country]}, got ${this.bban.length}.`)
  // ...
}

And to map the named group matches to properties (in the constructor, but note that would need more sanity checks):

Object.assign(this, this.bban.match(IBAN.formats[this.country]).groups)

Sidenote: I came up with another way to do the piecewise mod97 (inside the class):

static mod97(acc, digit, index) {
  return index == 9 || (index > 9 && index % 7 == 0) ?
    (Number(acc + digit) % 97).toString() :
    acc + digit
}

so that one could use it to reduce the string (e.g. inside the constructor)

const intStr = (this.bban + this.country + this.checksum)
  .replace(/[A-Z]/g, c => parseInt(c, 36))
this.valid = Number(Array.prototype.reduce.call(intStr, IBAN.mod97)) % 97 == 1

but granted, it's doing the modulo checks so often it hurts the performance compared to the for-loop in Flambino's answer.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.