Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/shy-heads-cross.md
Original file line number Diff line number Diff line change
@@ -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
```
1 change: 1 addition & 0 deletions packages/validation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ validateOrganizationNumber('937 052 766', { allowFormatting: true }) // true;

## Methods

* validateNationalIdentityNumber
* validatePostalCode
* validatePhoneNumber
* supports mobileOnly option
Expand Down
3 changes: 3 additions & 0 deletions packages/validation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,8 @@
],
"scripts": {
"build": "bunchee"
},
"devDependencies": {
"nav-faker": "3.2.4"
}
}
63 changes: 63 additions & 0 deletions packages/validation/src/no.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
49 changes: 49 additions & 0 deletions packages/validation/src/validation.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
18 changes: 17 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.