Skip to content

Commit 7dcc35b

Browse files
committed
add Swedish validateNationalIdenityNumber()
1 parent 1dfc896 commit 7dcc35b

File tree

6 files changed

+120
-8
lines changed

6 files changed

+120
-8
lines changed

packages/validation/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"build": "bunchee"
3232
},
3333
"devDependencies": {
34-
"nav-faker": "3.2.4"
34+
"@personnummer/generate": "^1.0.3",
35+
"nav-faker": "^3.2.4"
3536
}
3637
}

packages/validation/src/no.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ValidatorOptions } from './types';
2-
import { mod11, stripFormatting } from './utils';
2+
import { isValidDate, mod11, stripFormatting } from './utils';
33

44
type PostalCodeOptions = ValidatorOptions;
55

@@ -161,8 +161,5 @@ export function validateNationalIdentityNumber(
161161
day = day - 40;
162162
}
163163

164-
// important to use UTC so the user's timezone doesn't affect the validation
165-
const date = new Date(Date.UTC(year, month - 1, day));
166-
167-
return date && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
164+
return isValidDate(year, month, day);
168165
}

packages/validation/src/se.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ValidatorOptions } from './types';
2-
import { stripFormatting } from './utils';
2+
import { isValidDate, mod10, stripFormatting } from './utils';
33

44
type PostalCodeOptions = ValidatorOptions;
55

@@ -84,5 +84,54 @@ export function validateOrganizationNumber(
8484
return /^\d{10}$/.test(value);
8585
}
8686

87+
type PersonalIdentityNumberOptions = ValidatorOptions;
88+
89+
/**
90+
* Validates that the input value is a Swedish national identity number (fødselsnummer or d-nummer).
91+
*
92+
* It validates the control digits and checks if the date of birth is valid.
93+
*
94+
* @example
95+
* ```
96+
* // Fødselsnummer
97+
* validatePersonalIdentityNumber('21075417753') // => true
98+
*
99+
* // D-nummer
100+
* validatePersonalIdentityNumber('53097248016') // => true
101+
* ```
102+
*/
103+
export function validateNationalIdentityNumber(
104+
value: string,
105+
options: PersonalIdentityNumberOptions = {},
106+
): boolean {
107+
if (options.allowFormatting) {
108+
// biome-ignore lint/style/noParameterAssign:
109+
value = stripFormatting(value);
110+
}
111+
112+
const controlDigitCheck = mod10(value);
113+
if (!controlDigitCheck) {
114+
return false;
115+
}
116+
117+
let add = 0;
118+
if (value.length === 12) {
119+
add = 2;
120+
}
121+
122+
// copy/inspiration from NAV https://github.com/navikt/fnrvalidator/blob/77e57f0bc8e3570ddc2f0a94558c58d0f7259fe0/src/validator.ts#L108
123+
let year = Number(value.substring(0, 2 + add));
124+
const month = Number(value.substring(2 + add, 4 + add));
125+
const day = Number(value.substring(4 + add, 6 + add));
126+
127+
// 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
128+
// we need to special handle that case. For other cases it doesn't really matter if the year is 1925 or 2025.
129+
if (year === 0) {
130+
year = 2000;
131+
}
132+
133+
return isValidDate(year, month, day);
134+
}
135+
87136
// just reexport the no method for API feature parity
88137
export { validateObosMembershipNumber } from './no';

packages/validation/src/utils.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,49 @@ export function mod11(value: string, weights: number[]): boolean {
2626

2727
return controlNumber === Number(value[value.length - 1]);
2828
}
29+
30+
/**
31+
* Also known as Luhn's algorithm.
32+
* Used to validate Swedish national identity numbers.
33+
* See https://no.wikipedia.org/wiki/MOD10
34+
*/
35+
export function mod10(value: string): boolean {
36+
let sum = 0;
37+
38+
for (let i = 0; i < value.length; ++i) {
39+
const weight = 2 - (i % 2);
40+
41+
let number = Number(value[i]);
42+
43+
number = weight * number;
44+
45+
// if the number is greater than 9, ie more than one digit, we reduce it to a single digit by adding the individual digits together
46+
// 7 * 2 => 14 => 1 + 4 => 5
47+
// instead of adding the digits together, we can subtract 9 for the same result
48+
// 7 * 2 => 14 => 14 - 9 => 5
49+
if (number > 9) {
50+
number = number - 9;
51+
}
52+
53+
sum += number;
54+
}
55+
56+
return sum % 10 === 0;
57+
}
58+
59+
export function isValidDate(year: number, month: number, day: number): boolean {
60+
// months are zero indexed 🤷‍♂️
61+
month -= 1;
62+
63+
// important to use UTC so the user's timezone doesn't affect the validation
64+
const date = new Date(Date.UTC(year, month, day));
65+
66+
return (
67+
date &&
68+
// cannot do this for Norway
69+
// maybe do it for Sweden?
70+
// date.getUTCFullYear() === year &&
71+
date.getUTCMonth() === month &&
72+
date.getUTCDate() === day
73+
);
74+
}

packages/validation/src/validation.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import swedishPersonNummer from '@personnummer/generate';
12
import navfaker from 'nav-faker/dist/index';
23
import { describe, expect, test } from 'vitest';
34
import * as no from './no';
@@ -178,4 +179,14 @@ describe('se', () => {
178179
])('validateObosMembershipNumber(%s) -> %s', (input, expected, options) => {
179180
expect(se.validateObosMembershipNumber(input, options)).toBe(expected);
180181
});
182+
183+
test('validateNationalIdentityNumber() - validates leap years', () => {
184+
for (let i = 0; i < 1000; ++i) {
185+
const pnr = swedishPersonNummer({ format: 'short' });
186+
expect(
187+
se.validateNationalIdentityNumber(pnr, { allowFormatting: true }),
188+
`${pnr} is valid`,
189+
).toBe(true);
190+
}
191+
});
181192
});

pnpm-lock.yaml

Lines changed: 9 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)