diff --git a/README.md b/README.md index dc0a3ec..0f5c983 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,64 @@ # uk-modulus-checking -UK modulus checking. +Modulus checking allows payment originators to confirm that customer codes and account numbers are compatible before submitting a Bacs Direct Credit of Direct Debit. + +## Status +[![npm version][npm-image]][npm-url] [![build status][travis-image]][travis-url] + +## Installation +Install the package via `npm`: + +```sh +npm install uk-modulus-checking --save +``` + +## Usage + +### `new UkModulusChecking({ accountNumber, sortCode }).isValid()` + +This method validates if the given accountNumber and sortCode represent a valid `Faster Payment Account`. + +#### Arguments + +1. `accountNumber` *(string)*: The account number to validate. +2. `sortCode` *(string)*: The sort code to validate. + +#### Returns +*(boolean)*: Returns `true` if the account is valid. + +#### Example +```js +new UkModulusChecking({ accountNumber: '15764273', sortCode: '938063' }).isValid(); +// => false + +new UkModulusChecking({ accountNumber: '66374958', sortCode: '089999' }).isValid(); +// => true + +new UkModulusChecking({ accountNumber: '66374958', sortCode: '08-99-99' }).isValid(); +// => true + +new UkModulusChecking({ accountNumber: '66374958', sortCode: '08-9999' }).isValid(); +// => true +``` + +## Tests + +```sh +npm test +``` + +## Release + +```sh +npm version [ | major | minor | patch] -m "Release %s" +``` + +## License +MIT + +## Credits +Many thanks to [bazerk/uk-modulus-checking](https://github.com/bazerk/uk-modulus-checking) for the original inspiration. + +[npm-image]: https://img.shields.io/npm/v/uk-modulus-checking.svg?style=flat-square +[npm-url]: https://npmjs.org/package/uk-modulus-checking +[travis-image]: https://img.shields.io/travis/uphold/uk-modulus-checking.svg?style=flat-square +[travis-url]: https://img.shields.io/travis/uphold/uk-modulus-checking.svg?style=flat-square diff --git a/package.json b/package.json index 2498779..deedfe0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "uk-modulus-checking", "version": "0.0.1", - "description": "Validating account numbers", + "description": "Validate a UK bank account number against a sort code using the VocaLink modulus check", "author": "Uphold", "license": "MIT", "homepage": "https://github.com/uphold/uk-modulus-checking", diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..f05556d --- /dev/null +++ b/src/constants.js @@ -0,0 +1,19 @@ + +/* jscs:disable validateOrderInObjectKeys */ +export const positions = { + u: 0, + v: 1, + w: 2, + x: 3, + y: 4, + z: 5, + a: 6, + b: 7, + c: 8, + d: 9, + e: 10, + f: 11, + g: 12, + h: 13 +}; +/* jscs:enable validateOrderInObjectKeys */ diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..5a097ba --- /dev/null +++ b/src/index.js @@ -0,0 +1,315 @@ + +/** + * Module dependencies. + */ + +import { positions } from './constants'; +import fs from 'fs'; + +/** + * Export UkModulusChecking. + */ + +export default class UkModulusChecking { + + /** + * Constructor. + */ + + constructor({ accountNumber = '', sortCode = '' }) { + this.accountNumber = this.sanitize(accountNumber); + this.sortCode = this.sanitize(sortCode); + this.sortCodeSubstitutes = this.loadScsubtab(); + this.weightTable = this.loadValacdos(); + } + + /** + * Get check weight. + */ + + getCheckWeight(check, number) { + if (check.exception === 2) { + if (this.pickPosition(number, 'a') !== 0 && this.pickPosition(number, 'g') !== 9) { + return [0, 0, 1, 2, 5, 3, 6, 4, 8, 7, 10, 9, 3, 1]; + } + + if (this.pickPosition(number, 'a') !== 0 && this.pickPosition(number, 'g') === 9) { + return [0, 0, 0, 0, 0, 0, 0, 0, 8, 7, 10, 9, 3, 1]; + } + } + + if (check.exception === 7) { + if (this.pickPosition(number, 'g') === 9) { + return [0, 0, 0, 0, 0, 0, 0, 0, check.c, check.d, check.e, check.f, check.g, check.h]; + } + } + + if (check.exception === 10) { + const ab = number.charAt(positions.a) + number.charAt(positions.b); + + if (ab === '09' || ab === '99' && this.pickPosition(number, 'b') === 9) { + return [0, 0, 0, 0, 0, 0, 0, 0, check.c, check.d, check.e, check.f, check.g, check.h]; + } + } + + return [check.u, check.v, check.w, check.x, check.y, check.z, check.a, check.b, check.c, check.d, check.e, check.f, check.g, check.h]; + } + + /** + * Get number to be used in validation process. (sorting code + account number). + */ + + getNumber(check, number) { + let sortCode = this.sortCode; + + number = number || this.accountNumber; + + if (check.exception === 5) { + sortCode = this.getSubstitute(sortCode) || sortCode; + } else if (check.exception === 8) { + sortCode = '090126'; + } else if (check.exception === 9) { + sortCode = '309634'; + } + + return `${sortCode}${number}`; + } + + /** + * Get sorting code checks. + */ + + getSortCodeChecks() { + const checks = []; + const sortCode = parseInt(this.sortCode, 10); + + for (const check of this.weightTable) { + // All checks containing the sort code in the `weight range` can/must be performed. + if (sortCode >= check.start && sortCode <= check.end) { + checks.push(check); + } + + // There may be one or two entries in the table for the sorting code, + // depending on whether one or two modulus checks must be carried out. + if (checks.length === 2) { + return checks; + } + } + + return checks; + } + + /** + * Sorting code substitution. + */ + + getSubstitute(sortCode) { + for (const substitute of this.sortCodeSubstitutes) { + if (substitute.original === parseInt(sortCode, 10)) { + return parseInt(substitute.substitute, 10); + } + } + + return parseInt(sortCode, 10); + } + + /** + * Is check skippable. + */ + + isCheckSkippable(check, number) { + if (check.exception === 3 && (this.pickPosition(number, 'c') === 6 || this.pickPosition(number, 'c') === 9)) { + return true; + } + + if (check.exception === 6 && this.pickPosition(number, 'a') >= 4 && this.pickPosition(number, 'a') <= 8 && this.pickPosition(number, 'g') === this.pickPosition(number, 'h')) { + return true; + } + + return false; + } + + /** + * Is check valid. + */ + + isCheckValid(check, number) { + number = this.getNumber(check, number); + + if (this.isCheckSkippable(check, number)) { + return true; + } + + const module = check.mod === 'MOD11' ? 11 : 10; + const weight = this.getCheckWeight(check, number); + + // Multiply each number in the sorting code and account number with the corresponding number in the weight. + let weightedAccount = []; + + for (let i = 0; i < 14; i++) { + weightedAccount[i] = parseInt(number.charAt(i), 10) * parseInt(weight[i], 10); + } + + // Add all the results together. + if (check.mod === 'DBLAL') { + weightedAccount = weightedAccount.join('').split(''); + } + + let total = weightedAccount.reduce((previous, current) => parseInt(previous, 10) + parseInt(current, 10)); + + // This effectively places a financial institution number (580149) before the sorting code and account + // number which is subject to the alternate doubling as well. + if (check.exception === 1) { + total += 27; + } + + // Calculate remainder. + const remainder = total % module; + + // Exception handling. + if (check.exception === 4) { + return remainder === this.pickPosition(number, 'g') + this.pickPosition(number, 'h'); + } + + if (check.exception === 5) { + if (check.mod === 'DBLAL') { + if (remainder === 0 && this.pickPosition(number, 'h') === 0) { + return true; + } + + return this.pickPosition(number, 'h') === 10 - remainder; + } + + if (remainder === 1) { + return false; + } + + if (remainder === 0 && this.pickPosition(number, 'g') === 0) { + return true; + } + + return this.pickPosition(number, 'g') === 11 - remainder; + } + + return remainder === 0; + } + + /** + * Is valid. + */ + + isValid() { + const checks = this.getSortCodeChecks(); + + // If no range is found that contains the sorting code, there is no modulus check that can be performed. + // The sorting code and account number should be presumed valid unless other evidence implies otherwise. + if (checks.length === 0) { + return true; + } + + const firstCheck = checks[0]; + + if (this.isCheckValid(firstCheck)) { + if (checks.length === 1 || [2, 9, 10, 11, 12, 13, 14].indexOf(firstCheck.exception) !== -1) { + return true; + } + + // Verify second check. + return this.isCheckValid(checks[1]); + } + + if (firstCheck.exception === 14) { + if ([0, 1, 9].indexOf(parseInt(this.accountNumber.charAt(7), 10)) === -1) { + return false; + } + + // If the 8th digit is 0, 1 or 9, then remove the digit from the account number and insert a 0 as the 1st digit for check purposes only + return this.isCheckValid(checks[0], `0${this.accountNumber.substring(7, 0)}`); + } + + if (checks.length === 1 || [2, 9, 10, 11, 12, 13, 14].indexOf(firstCheck.exception) === -1) { + return false; + } + + // Verify second check. + return this.isCheckValid(checks[1]); + } + + /** + * Load scsubtab file. + */ + + loadScsubtab() { + const content = fs.readFileSync(`${__dirname}/data/scsubtab.txt`, 'utf8'); + const scsubtab = []; + + content.split('\r\n').forEach((line) => { + const data = line.split(/\s+/); + + scsubtab.push({ + original: parseInt(data[0], 10), + substitute: parseInt(data[1], 10) + }); + }); + + return scsubtab; + } + + /** + * Load valacdos file. + */ + + loadValacdos() { + const content = fs.readFileSync(`${__dirname}/data/valacdos-v370.txt`, 'utf8'); + const valacdos = []; + + content.split('\r\n').forEach((line) => { + const data = line.split(/\s+/); + + /* jscs:disable validateOrderInObjectKeys */ + valacdos.push({ + start: parseInt(data[0], 10), + end: parseInt(data[1], 10), + mod: data[2], + u: parseInt(data[3], 10), + v: parseInt(data[4], 10), + w: parseInt(data[5], 10), + x: parseInt(data[6], 10), + y: parseInt(data[7], 10), + z: parseInt(data[8], 10), + a: parseInt(data[9], 10), + b: parseInt(data[10], 10), + c: parseInt(data[11], 10), + d: parseInt(data[12], 10), + e: parseInt(data[13], 10), + f: parseInt(data[14], 10), + g: parseInt(data[15], 10), + h: parseInt(data[16], 10), + exception: parseInt(data[17], 10) || null + }); + /* jscs:enable validateOrderInObjectKeys */ + }); + + return valacdos; + } + + /** + * Pick position in number. + */ + + pickPosition(number, position) { + return parseInt(number.charAt(positions[position]), 10); + } + + /** + * Sanitize. + */ + + sanitize(value) { + if (typeof value === 'string' || value instanceof String) { + return value.replace(/-/g, ''); + } + + throw new Error('Invalid value'); + } +} diff --git a/test/index_test.js b/test/index_test.js new file mode 100644 index 0000000..773c93c --- /dev/null +++ b/test/index_test.js @@ -0,0 +1,67 @@ + +/** + * Module dependencies. + */ + +import UkModulusChecking from './../src'; + +const accounts = { + invalid: [ + { accountNumber: '15763217', sortCode: '938063' }, + { accountNumber: '15764264', sortCode: '938063' }, + { accountNumber: '15764273', sortCode: '938063' }, + { accountNumber: '58716970', sortCode: '203099' }, + { accountNumber: '64371388', sortCode: '118765' }, + { accountNumber: '66374959', sortCode: '089999' }, + { accountNumber: '66831036', sortCode: '203099' }, + { accountNumber: '88837493', sortCode: '107999' } + ], + valid: [ + { accountNumber: '00000190', sortCode: '180002' }, + { accountNumber: '02355688', sortCode: '309070' }, + { accountNumber: '06774744', sortCode: '086090' }, + { accountNumber: '07806039', sortCode: '938611' }, + { accountNumber: '09123496', sortCode: '871427' }, + { accountNumber: '11104102', sortCode: '074456' }, + { accountNumber: '12345112', sortCode: '074456' }, + { accountNumber: '12345668', sortCode: '309070' }, + { accountNumber: '12345677', sortCode: '309070' }, + { accountNumber: '28748352', sortCode: '827101' }, + { accountNumber: '34012583', sortCode: '070116' }, + { accountNumber: '41011166', sortCode: '200915' }, + { accountNumber: '42368003', sortCode: '938600' }, + { accountNumber: '46238510', sortCode: '871427' }, + { accountNumber: '46238510', sortCode: '872427' }, + { accountNumber: '55065200', sortCode: '938063' }, + { accountNumber: '63748472', sortCode: '202959' }, + { accountNumber: '63849203', sortCode: '134020' }, + { accountNumber: '64371389', sortCode: '118765' }, + { accountNumber: '66374958', sortCode: '089999' }, + { accountNumber: '73688637', sortCode: '820000' }, + { accountNumber: '73988638', sortCode: '827999' }, + { accountNumber: '88837491', sortCode: '107999' }, + { accountNumber: '99123496', sortCode: '871427' }, + { accountNumber: '99345694', sortCode: '309070' }, + { accountNumber: '99345694', sortCode: '772798' } + ] +}; + +/** + * Test `UkModulusChecking`. + */ + +describe('UkModulusChecking', () => { + describe('isValid()', () => { + accounts.invalid.forEach((account) => { + it(`should return false if sort code is ${account.sortCode} and account number is ${account.accountNumber}`, () => { + new UkModulusChecking({ accountNumber: account.accountNumber, sortCode: account.sortCode }).isValid().should.be.false(); + }); + }); + + accounts.valid.forEach((account) => { + it(`should return true if sort code is ${account.sortCode} and account number is ${account.accountNumber}`, () => { + new UkModulusChecking({ accountNumber: account.accountNumber, sortCode: account.sortCode }).isValid().should.be.true(); + }); + }); + }); +});