diff --git a/README.md b/README.md index 8277c57..027bf3e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ How you can help! This library currently support about half the countries in the | Country | Code | Name | Group | Meaning | | ---------------------- | ---- | --------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | | Andorra | AD | NRT | Tax | Tax Register Identifier (Número de Registre Tributari) | +| Angola | AO | BI | Person | Angolan Identity Card (Bilhete de Identidade) | +| Angola | AO | NIF | Tax | Angolan Tax Identification Number (Número de Identificação Fiscal) | | Albania | AL | NIPT | Vat | Albanian Vat Identifier (Numri i Identifikimit për Personin e Tatueshëm) | | Anguilla | AI | TIN | Tax | Tax Identification Number | | Argentina | AR | CBU | Bank | Single Banking Code (Clave Bancaria Uniforme) | @@ -152,6 +154,8 @@ How you can help! This library currently support about half the countries in the | Mexico | MX | CLABE | Bank | Bank Account (Clave Bancaria Estandarizada) | | Montenegro | ME | JMBG | Person | Unique Master Citizen Number | | Montenegro | ME | PIB | Tax/Vat | Poreski Identifikacioni Broj, Montenegro tax number | +| Mozambique | MZ | BI | Person | Bilhete de Identidade, Mozambican national identification number | +| Mozambique | MZ | NUIT | Tax/Vat | Número Único de Identificação Tributária, Mozambican tax identifier number | | Malaysia | MY | NRIC | Person | Malaysian National Registration Identity Card Number | | Netherlands | NL | BSN | Person | Burgerservicenummer, the Dutch citizen identification number | | Netherlands | NL | BTW | Vat | Btw-identificatienummer (Omzetbelastingnummer, the Dutch VAT number) | diff --git a/src/ao/bi.spec.ts b/src/ao/bi.spec.ts new file mode 100644 index 0000000..51682fb --- /dev/null +++ b/src/ao/bi.spec.ts @@ -0,0 +1,55 @@ +import { validate, format, compact } from './bi'; +import { InvalidLength, InvalidFormat } from '../exceptions'; + +describe('ao/bi', () => { + it('format:000204688CA010', () => { + const result = format('000204688CA010'); + + expect(result).toEqual('000204688CA010'); + }); + + it('compact:000204688CA010', () => { + const result = compact('000204688CA010'); + + expect(result).toEqual('000204688CA010'); + }); + + it('validate:007128828LA043', () => { + const result = validate('007128828LA043'); + + expect(result.isValid && result.compact).toEqual('007128828LA043'); + }); + + it('validate:0000100441CA037 (invalid length)', () => { + const result = validate('0000100441CA037'); + + expect(result.error).toBeInstanceOf(InvalidLength); + }); + + it('validate:123456789 (invalid length)', () => { + const result = validate('123456789'); + + expect(result.error).toBeInstanceOf(InvalidLength); + }); + + it('validate:123456789CA12 (invalid length)', () => { + const result = validate('123456789CA12'); + + expect(result.error).toBeInstanceOf(InvalidLength); + }); + + it('validate:000204688cA010 (case normalization)', () => { + const result = validate('000204688cA010'); + + if (!result.isValid) { + throw new Error('Expected valid'); + } + expect(result.compact).toEqual('000204688CA010'); + }); + + it('validate:00020468800010 (invalid pattern - no letters)', () => { + const result = validate('00020468800010'); + + expect(result.error).toBeInstanceOf(InvalidFormat); + }); +}); diff --git a/src/ao/bi.ts b/src/ao/bi.ts new file mode 100644 index 0000000..6165583 --- /dev/null +++ b/src/ao/bi.ts @@ -0,0 +1,67 @@ +/** + * BI (Bilhete de Identidade, Angola Identity Card). + * + * The Angolan BI is a national identification document. + * The number consists of 14 characters: + * - 9 digits + * - 2 letters + * - 3 digits + * + * Example: 000204688CA010 + */ + +import * as exceptions from '../exceptions'; +import { strings } from '../util'; +import { Validator, ValidateReturn } from '../types'; + +function clean(input: string): ReturnType { + return strings.cleanUnicode(input.toUpperCase(), ' -.'); +} + +const BIO_REGEX = /^\d{9}[A-Z]{2}\d{3}$/; + +const impl: Validator = { + name: 'Angola Identity Card', + localName: 'Bilhete de Identidade', + abbreviation: 'BI', + + compact(input: string): string { + const [value, err] = clean(input); + + if (err) { + throw err; + } + + return value; + }, + + format(input: string): string { + const [value] = clean(input); + + return value; + }, + + validate(input: string): ValidateReturn { + const [value, error] = clean(input); + + if (error) { + return { isValid: false, error }; + } + if (value.length !== 14) { + return { isValid: false, error: new exceptions.InvalidLength() }; + } + if (!BIO_REGEX.test(value)) { + return { isValid: false, error: new exceptions.InvalidFormat() }; + } + + return { + isValid: true, + compact: value, + isIndividual: true, + isCompany: false, + }; + }, +}; + +export const { name, localName, abbreviation, validate, format, compact } = + impl; diff --git a/src/ao/index.ts b/src/ao/index.ts new file mode 100644 index 0000000..0f2689a --- /dev/null +++ b/src/ao/index.ts @@ -0,0 +1,2 @@ +export * as nif from './nif'; +export * as bi from './bi'; diff --git a/src/ao/nif.spec.ts b/src/ao/nif.spec.ts new file mode 100644 index 0000000..8310d36 --- /dev/null +++ b/src/ao/nif.spec.ts @@ -0,0 +1,52 @@ +import { validate, format, compact } from './nif'; +import { InvalidLength, InvalidFormat } from '../exceptions'; + +describe('ao/nif', () => { + it('format:0000000001', () => { + const result = format('0000000001'); + + expect(result).toEqual('0000000001'); + }); + + it('compact:0000000001', () => { + const result = compact('0000000001'); + + expect(result).toEqual('0000000001'); + }); + + it('validate:5001191020 (company)', () => { + const result = validate('5001191020'); + + if (!result.isValid) throw new Error('Expected valid'); + expect(result.compact).toEqual('5001191020'); + expect(result.isCompany).toEqual(true); + expect(result.isIndividual).toEqual(false); + }); + + it('validate:003534962LA033 (individual)', () => { + const result = validate('003534962LA033'); + + if (!result.isValid) throw new Error('Expected valid'); + expect(result.compact).toEqual('003534962LA033'); + expect(result.isCompany).toEqual(false); + expect(result.isIndividual).toEqual(true); + }); + + it('validate:123456789 (invalid length)', () => { + const result = validate('123456789'); + + expect(result.error).toBeInstanceOf(InvalidLength); + }); + + it('validate:12345678901 (invalid length)', () => { + const result = validate('12345678901'); + + expect(result.error).toBeInstanceOf(InvalidLength); + }); + + it('validate:123456789A (invalid format)', () => { + const result = validate('123456789A'); + + expect(result.error).toBeInstanceOf(InvalidFormat); + }); +}); diff --git a/src/ao/nif.ts b/src/ao/nif.ts new file mode 100644 index 0000000..3f4b444 --- /dev/null +++ b/src/ao/nif.ts @@ -0,0 +1,72 @@ +/** + * NIF (Número de Identificação Fiscal, Angola Tax Identification Number). + * + * The Angolan NIF is a 10-digit or 14-character number used for tax purposes. + * + * Source: + * https://validarnif.pt/pt/validar-nif-angola/ + */ + +import * as exceptions from '../exceptions'; +import { strings } from '../util'; +import { Validator, ValidateReturn } from '../types'; + +function clean(input: string): ReturnType { + return strings.cleanUnicode(input.toUpperCase(), ' -.'); +} + +const impl: Validator = { + name: 'Angola Tax Identification Number', + localName: 'Número de Identificação Fiscal', + abbreviation: 'NIF', + + compact(input: string): string { + const [value, err] = clean(input); + + if (err) { + throw err; + } + + return value; + }, + + format(input: string): string { + const [value] = clean(input); + + return value; + }, + + validate(input: string): ValidateReturn { + const [value, error] = clean(input); + + if (error) { + return { isValid: false, error }; + } + if (value.length === 10 && strings.isdigits(value)) { + return { + isValid: true, + compact: value, + isIndividual: false, + isCompany: true, + }; + } + + if (value.length === 14 && /^\d{9}[A-Z]{2}\d{3}$/.test(value)) { + return { + isValid: true, + compact: value, + isIndividual: true, + isCompany: false, + }; + } + + if (value.length !== 10 && value.length !== 14) { + return { isValid: false, error: new exceptions.InvalidLength() }; + } + + return { isValid: false, error: new exceptions.InvalidFormat() }; + }, +}; + +export const { name, localName, abbreviation, validate, format, compact } = + impl; diff --git a/src/index.ts b/src/index.ts index b52050b..78f8632 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import * as AD from './ad'; import * as AI from './ai'; import * as AL from './al'; +import * as AO from './ao'; import * as AR from './ar'; import * as AT from './at'; import * as AU from './au'; @@ -61,6 +62,7 @@ import * as MT from './mt'; import * as MU from './mu'; import * as MX from './mx'; import * as MY from './my'; +import * as MZ from './mz'; import * as NL from './nl'; import * as NO from './no'; import * as NZ from './nz'; @@ -88,14 +90,15 @@ import * as UY from './uy'; import * as VE from './ve'; import * as VN from './vn'; import * as ZA from './za'; -import { Validator } from './types'; +import type { Validator } from './types'; -export { Validator } from './types'; +export type { Validator } from './types'; // Live an uppercase world, to prevent keyword collisions export const stdnum: Record> = { AD, AL, + AO, AR, AT, AU, @@ -156,6 +159,7 @@ export const stdnum: Record> = { MU, MX, MY, + MZ, NL, NO, NZ, @@ -189,6 +193,7 @@ export const personValidators: Record = { AD: [AD.nrt], AI: [AI.tin], AL: [AL.nipt], + AO: [AO.nif, AO.bi], AR: [AR.cuit, AR.dni], AT: [AT.vnr], AU: [AU.tfn], @@ -237,6 +242,7 @@ export const personValidators: Record = { MU: [MU.nid], MX: [MX.curp, MX.rfc], MY: [MY.nric], + MZ: [MZ.bi, MZ.nuit], NL: [NL.onderwijsnummer, NL.bsn], NO: [NO.fodselsnummer], NZ: [NZ.ird], @@ -266,6 +272,7 @@ export const entityValidators: Record = { AD: [AD.nrt], AI: [AI.tin], AL: [AL.nipt], + AO: [AO.nif], AR: [AR.cuit], AT: [AT.businessid, AT.tin, AT.uid], AU: [AU.abn, AU.acn, AU.tfn], @@ -312,6 +319,7 @@ export const entityValidators: Record = { MA: [MA.ice, MA.ice9], MT: [MT.vat], MX: [MX.rfc], + MZ: [MZ.nuit], NL: [NL.btw], NO: [NO.mva, NO.orgnr], NZ: [NZ.ird], diff --git a/src/mz/bi.spec.ts b/src/mz/bi.spec.ts new file mode 100644 index 0000000..386007d --- /dev/null +++ b/src/mz/bi.spec.ts @@ -0,0 +1,76 @@ +import { validate, format } from './bi'; +import { InvalidLength, InvalidFormat } from '../exceptions'; + +describe('mz/bi', () => { + it('format:110504162779A', () => { + const result = format('110504162779A'); + + expect(result).toEqual('110504162779A'); + }); + + it('format:1105 0416 2779A', () => { + const result = format('1105 0416 2779A'); + + expect(result).toEqual('110504162779A'); + }); + + it('validate:110504162779A', () => { + const result = validate('110504162779A'); + + expect(result.isValid && result.compact).toEqual('110504162779A'); + }); + + it('validate:100101028277A', () => { + const result = validate('100101028277A'); + + expect(result.isValid && result.compact).toEqual('100101028277A'); + }); + + it('validate:100104675207I', () => { + const result = validate('100104675207I'); + + expect(result.isValid && result.compact).toEqual('100104675207I'); + }); + + it('validate:100106727603C', () => { + const result = validate('100106727603C'); + + expect(result.isValid && result.compact).toEqual('100106727603C'); + }); + + it('validate:100104946669B', () => { + const result = validate('100104946669B'); + + expect(result.isValid && result.compact).toEqual('100104946669B'); + }); + + it('validate:110102526188J', () => { + const result = validate('110102526188J'); + + expect(result.isValid && result.compact).toEqual('110102526188J'); + }); + + it('validate:1105 0416 2779A', () => { + const result = validate('1105 0416 2779A'); + + expect(result.isValid && result.compact).toEqual('110504162779A'); + }); + + it('validate:12345678', () => { + const result = validate('12345678'); + + expect(result.error).toBeInstanceOf(InvalidLength); + }); + + it('validate:12345678901234', () => { + const result = validate('12345678901234'); + + expect(result.error).toBeInstanceOf(InvalidLength); + }); + + it('validate:1234567890123', () => { + const result = validate('1234567890123'); + + expect(result.error).toBeInstanceOf(InvalidFormat); + }); +}); diff --git a/src/mz/bi.ts b/src/mz/bi.ts new file mode 100644 index 0000000..d7644eb --- /dev/null +++ b/src/mz/bi.ts @@ -0,0 +1,66 @@ +/** + * BI (Bilhete de Identidade, Mozambican national identity document). + * + * The Bilhete de Identidade is the official national identity document issued + * to Mozambican citizens aged 12 and above. The traditional paper-based BI + * consists of 13 digits, first 12 numeric and last a letter. The newer biometric BI uses an alphanumeric + * format, but public validation rules (including checksums) are not officially + * documented. + * + * This validator supports only the 13-character alphanumeric format. + * + * Source: + * Serviço de Migração e Estrangeiros (SME), República de Moçambique + * + * PERSON + */ + +import * as exceptions from '../exceptions'; +import { strings } from '../util'; +import { Validator, ValidateReturn } from '../types'; + +function clean(input: string): ReturnType { + return strings.cleanUnicode(input.toUpperCase(), ' -.'); +} + +const impl: Validator = { + name: 'Mozambican National Identity Document', + localName: 'Bilhete de Identidade', + abbreviation: 'BI', + + compact(input: string): string { + const [value, err] = clean(input); + if (err) { + throw err; + } + return value; + }, + + format(input: string): string { + const [value] = clean(input); + return value; + }, + + validate(input: string): ValidateReturn { + const [value, error] = clean(input); + if (error) { + return { isValid: false, error }; + } + if (value.length !== 13) { + return { isValid: false, error: new exceptions.InvalidLength() }; + } + if (!/^\d{12}[A-Z]$/.test(value)) { + return { isValid: false, error: new exceptions.InvalidFormat() }; + } + + return { + isValid: true, + compact: value, + isIndividual: true, + isCompany: false, + }; + }, +}; + +export const { name, localName, abbreviation, validate, format, compact } = + impl; diff --git a/src/mz/index.ts b/src/mz/index.ts new file mode 100644 index 0000000..9eb75a5 --- /dev/null +++ b/src/mz/index.ts @@ -0,0 +1,2 @@ +export * as nuit from './nuit'; +export * as bi from './bi'; diff --git a/src/mz/nuit.spec.ts b/src/mz/nuit.spec.ts new file mode 100644 index 0000000..7f4a7fc --- /dev/null +++ b/src/mz/nuit.spec.ts @@ -0,0 +1,58 @@ +import { validate, format } from './nuit'; +import { InvalidLength, InvalidFormat, InvalidChecksum } from '../exceptions'; + +describe('mz/nuit', () => { + it('format:400012345', () => { + const result = format('400012345'); + + expect(result).toEqual('400 012 345'); + }); + + it('format:40.001.234-5', () => { + const result = format('40.001.234-5'); + + expect(result).toEqual('400 012 345'); + }); + + it('validate:107705694', () => { + const result = validate('107705694'); + + expect(result.isValid && result.compact).toEqual('107705694'); + }); + + it('validate:40001234-2', () => { + const result = validate('40001234-2'); + + expect(result.isValid && result.compact).toEqual('400012342'); + }); + + it('validate:12345678', () => { + const result = validate('12345678'); + + expect(result.error).toBeInstanceOf(InvalidLength); + }); + + it('validate:1234567890', () => { + const result = validate('1234567890'); + + expect(result.error).toBeInstanceOf(InvalidLength); + }); + + it('validate:4000123A5', () => { + const result = validate('4000123A5'); + + expect(result.error).toBeInstanceOf(InvalidFormat); + }); + + it('validate:00000000-0', () => { + const result = validate('00000000-0'); + + expect(result.isValid && result.compact).toEqual('000000000'); + }); + + it('validate:40001234-5', () => { + const result = validate('40001234-5'); + + expect(result.error).toBeInstanceOf(InvalidChecksum); + }); +}); diff --git a/src/mz/nuit.ts b/src/mz/nuit.ts new file mode 100644 index 0000000..96a839f --- /dev/null +++ b/src/mz/nuit.ts @@ -0,0 +1,86 @@ +/** + * NUIT (Número Único de Identificação Tributária, Mozambican tax identifier). + * + * The Número Único de Identificação Tributária is the Mozambican tax + * identification number assigned to individuals and legal entities for + * fiscal purposes. It consists of 9 digits: 8 base digits and 1 check digit. + * + * The check digit is calculated using a modulo 11 algorithm with weights + * 8, 9, 4, 5, 6, 7, 8, 9 (left to right). The result is mapped as: + * check = sum % 11 + * digit = '01234567891'[check] (so 10 -> 1) + * + * Source: + * https://www.at.gov.mz/ (Autoridade Tributária de Moçambique) + * + * PERSON / COMPANY + */ + +import * as exceptions from '../exceptions'; +import { strings } from '../util'; +import { Validator, ValidateReturn } from '../types'; + +function clean(input: string): ReturnType { + return strings.cleanUnicode(input, ' -.'); +} + +function computeCheckDigit(input: string): string { + // input must be 8 digits + const weights = [8, 9, 4, 5, 6, 7, 8, 9]; + const sum = input + .split('') + .map((v, i) => parseInt(v, 10) * weights[i]) + .reduce((acc, v) => acc + v, 0); + + const remainder = sum % 11; + return '01234567891'[remainder]; +} + +const impl: Validator = { + name: 'Mozambican Tax Identification Number', + localName: 'Número Único de Identificação Tributária', + abbreviation: 'NUIT', + + compact(input: string): string { + const [value, err] = clean(input); + if (err) { + throw err; + } + return value; + }, + + format(input: string): string { + const [value] = clean(input); + return strings.splitAt(value, 3, 6).join(' '); + }, + + validate(input: string): ValidateReturn { + const [value, error] = clean(input); + if (error) { + return { isValid: false, error }; + } + if (value.length !== 9) { + return { isValid: false, error: new exceptions.InvalidLength() }; + } + if (!strings.isdigits(value)) { + return { isValid: false, error: new exceptions.InvalidFormat() }; + } + + const [base, dv] = strings.splitAt(value, 8); + const expectedDv = computeCheckDigit(base); + + if (dv !== expectedDv) { + return { isValid: false, error: new exceptions.InvalidChecksum() }; + } + + return { + isValid: true, + compact: value, + isIndividual: true, // NUIT can belong to individuals or companies + isCompany: true, // so both flags are true + }; + }, +}; + +export const { name, localName, abbreviation, validate, format, compact } = + impl;