diff --git a/.changeset/shy-heads-cross.md b/.changeset/shy-heads-cross.md new file mode 100644 index 0000000..7e94b0d --- /dev/null +++ b/.changeset/shy-heads-cross.md @@ -0,0 +1,18 @@ +--- +"@obosbbl/validation": minor +--- + +add method `validateNationalIdentityNumber()` + +Validates that the input is a valid Norwegian national identity number (either a fødselsnummer or a D-nummer). +It validates the checksum and checks if the date of birth is valid. + +``` +import { validateNationalIdentityNumber } from "@obosbbl/validation/no"; + +// Fødselsnummer +validatePersonalIdentityNumber('21075417753') // => true + +// D-nummer +validatePersonalIdentityNumber('53097248016') // => true +``` diff --git a/packages/validation/README.md b/packages/validation/README.md index 99e6340..1579caf 100644 --- a/packages/validation/README.md +++ b/packages/validation/README.md @@ -57,6 +57,7 @@ validateOrganizationNumber('937 052 766', { allowFormatting: true }) // true; ## Methods +* validateNationalIdentityNumber * validatePostalCode * validatePhoneNumber * supports mobileOnly option diff --git a/packages/validation/package.json b/packages/validation/package.json index 0d477cf..9149987 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -29,5 +29,8 @@ ], "scripts": { "build": "bunchee" + }, + "devDependencies": { + "nav-faker": "3.2.4" } } diff --git a/packages/validation/src/no.ts b/packages/validation/src/no.ts index e4135b7..5e949b5 100644 --- a/packages/validation/src/no.ts +++ b/packages/validation/src/no.ts @@ -103,3 +103,66 @@ export function validateObosMembershipNumber( return /^\d{7}$/.test(value); } + +type PersonalIdentityNumberOptions = ValidatorOptions; + +/** + * Validates that the input value is a Norwegian national identity number (fødselsnummer or d-nummer). + * + * It validates the control digits and checks if the date of birth is valid. + * + * @example + * ``` + * // Fødselsnummer + * validatePersonalIdentityNumber('21075417753') // => true + * + * // D-nummer + * validatePersonalIdentityNumber('53097248016') // => true + * ``` + */ +export function validateNationalIdentityNumber( + value: string, + options: PersonalIdentityNumberOptions = {}, +): boolean { + if (options.allowFormatting) { + // biome-ignore lint/style/noParameterAssign: + value = stripFormatting(value); + } + + // Norwegian national identity numbers use mod 11 with two control digits. + // The first one is calculated for the first 10d digits + // while the last one uses all 11 digits + const valueForControlDigit1 = value.slice(0, -1); + const controlDigit1 = mod11( + valueForControlDigit1, + [3, 7, 6, 1, 8, 9, 4, 5, 2], + ); + + const controlDigit2 = mod11(value, [5, 4, 3, 2, 7, 6, 5, 4, 3, 2]); + + if (!controlDigit1 || !controlDigit2) { + return false; + } + + // copy/inspiration from NAV https://github.com/navikt/fnrvalidator/blob/77e57f0bc8e3570ddc2f0a94558c58d0f7259fe0/src/validator.ts#L108 + let day = Number(value.substring(0, 2)); + const month = Number(value.substring(2, 4)); + let year = Number(value.substring(4, 6)); + + // 1900 isn't a leap year, but 2000 is. Since JS two digits years to the Date constructor is an offset from the year 1900 + // we need to special handle that case. For other cases it doesn't really matter if the year is 1925 or 2025. + if (year === 0) { + year = 2000; + } + + // for a d-number the day is increased by 40. Eg the 31st of a month would be 71, or the 3rd would be 43. + // thus we need to subtract 40 to get the correct day of the month + if (day > 40) { + day = day - 40; + } + + // important to use UTC so the user's timezone doesn't affect the validation + const date = new Date(Date.UTC(year, month - 1, day)); + + return date && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; +} diff --git a/packages/validation/src/validation.test.ts b/packages/validation/src/validation.test.ts index bf7d7c6..60a728a 100644 --- a/packages/validation/src/validation.test.ts +++ b/packages/validation/src/validation.test.ts @@ -1,3 +1,4 @@ +import navfaker from 'nav-faker/dist/index'; import { describe, expect, test } from 'vitest'; import * as no from './no'; import * as se from './se'; @@ -58,6 +59,54 @@ describe('no', () => { ])('validateObosMembershipNumber(%s) -> %s', (input, expected, options) => { expect(no.validateObosMembershipNumber(input, options)).toBe(expected); }); + + test('validateNationalIdentityNumber() - validates fødselsnummer', () => { + for (let i = 0; i < 1000; ++i) { + const fnr = navfaker.personIdentifikator.fødselsnummer(); + expect(no.validateNationalIdentityNumber(fnr), `${fnr} is valid`).toBe( + true, + ); + } + }); + + test('validateNationalIdentityNumber() - validates d-nummer', () => { + for (let i = 0; i < 1000; ++i) { + const dnr = navfaker.personIdentifikator.dnummer(); + expect(no.validateNationalIdentityNumber(dnr), `${dnr} is valid`).toBe( + true, + ); + } + }); + + test('validateNationalIdentityNumber() - validates leap years', () => { + expect(no.validateNationalIdentityNumber('29029648784')).toBe(true); + }); + + test('validateNationalIdentityNumber() - validates 00 as a leap year', () => { + expect(no.validateNationalIdentityNumber('29020075838')).toBe(true); + }); + + test('validateNationalIdentityNumber() - returns false for invalid identity numbers', () => { + expect( + no.validateNationalIdentityNumber('13097248032'), + '1st control digit is invalid', + ).toBe(false); + + expect( + no.validateNationalIdentityNumber('13097248023'), + '2nd control digit is invalid', + ).toBe(false); + + expect( + no.validateNationalIdentityNumber('32127248022'), + 'day is invalid', + ).toBe(false); + + expect( + no.validateNationalIdentityNumber('13137248022'), + 'month is invalid', + ).toBe(false); + }); }); describe('se', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3a9361..ed8d46b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,11 @@ importers: packages/format: {} - packages/validation: {} + packages/validation: + devDependencies: + nav-faker: + specifier: 3.2.4 + version: 3.2.4 packages: @@ -1030,6 +1034,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nav-faker@3.2.4: + resolution: {integrity: sha512-XnpyF0iPMgDaFToussR8AyRSj1lrnQvsswdeG9tCstR2cqj2FhBO9udKyhXnbuxTbG9lXzGswpVexQhtl0H0Ag==} + node-emoji@2.2.0: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} @@ -1199,6 +1206,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + seedrandom@2.4.4: + resolution: {integrity: sha512-9A+PDmgm+2du77B5i0Ip2cxOqqHjgNxnBgglxLcX78A2D6c2rTo61z4jnVABpF4cKeDMDG+cmXXvdnqse2VqMA==} + semver@7.7.1: resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} engines: {node: '>=10'} @@ -2391,6 +2401,10 @@ snapshots: nanoid@3.3.8: {} + nav-faker@3.2.4: + dependencies: + seedrandom: 2.4.4 + node-emoji@2.2.0: dependencies: '@sindresorhus/is': 4.6.0 @@ -2559,6 +2573,8 @@ snapshots: safer-buffer@2.1.2: {} + seedrandom@2.4.4: {} + semver@7.7.1: {} shebang-command@2.0.0: