Skip to content

Commit af9ac11

Browse files
authored
add validation of Swedish national identity number (#33)
1 parent 8df4f34 commit af9ac11

File tree

7 files changed

+253
-16
lines changed

7 files changed

+253
-16
lines changed

packages/validation/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ The methods are "strict" by default, meaning no formatting characters in the inp
4040
This is preferrable, for instance when doing server-side validation, where the input is often expected to be a "clean" value.
4141

4242
If you want to allow formatting characters in the input, you can pass `allowFormatting: true` in the options object to the method.
43-
Note that this currently allows any formatting characters, not just the just the "expected" ones for the input type.
4443

4544

4645
```js

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: 5 additions & 12 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

@@ -104,7 +104,7 @@ export function validateObosMembershipNumber(
104104
return /^\d{7}$/.test(value);
105105
}
106106

107-
type PersonalIdentityNumberOptions = ValidatorOptions;
107+
type NationalIdentityNumberOptions = ValidatorOptions;
108108

109109
/**
110110
* Validates that the input value is a Norwegian national identity number (fødselsnummer or d-nummer).
@@ -113,16 +113,12 @@ type PersonalIdentityNumberOptions = ValidatorOptions;
113113
*
114114
* @example
115115
* ```
116-
* // Fødselsnummer
117-
* validatePersonalIdentityNumber('21075417753') // => true
118-
*
119-
* // D-nummer
120-
* validatePersonalIdentityNumber('53097248016') // => true
116+
* validatePersonalIdentityNumber('DDMMYYXXXXX') // => true
121117
* ```
122118
*/
123119
export function validateNationalIdentityNumber(
124120
value: string,
125-
options: PersonalIdentityNumberOptions = {},
121+
options: NationalIdentityNumberOptions = {},
126122
): boolean {
127123
if (options.allowFormatting) {
128124
// biome-ignore lint/style/noParameterAssign:
@@ -161,8 +157,5 @@ export function validateNationalIdentityNumber(
161157
day = day - 40;
162158
}
163159

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;
160+
return isValidDate(year, month, day);
168161
}

packages/validation/src/se.ts

Lines changed: 97 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,101 @@ export function validateOrganizationNumber(
8484
return /^\d{10}$/.test(value);
8585
}
8686

87+
type NationalIdentityNumberFormat = 'short' | 'long';
88+
type NationalIdentityNumberOptions = ValidatorOptions & {
89+
/** Specify this if you want to format to be only long (12 digits) or short (10 digits). By default, both formats are allowed */
90+
format?: NationalIdentityNumberFormat;
91+
};
92+
93+
// the first two digts are optional, as they're the century in the long format version
94+
const PERSONNUMMER_FORMAT = /^(\d{2}){0,1}(\d{2})(\d{2})(\d{2})([+-]?)(\d{4})$/;
95+
96+
/**
97+
* Validates that the input value is a Swedish national identity number (personnummer or samordningsnummer).
98+
*
99+
* It validates the control digits and checks if the date of birth is valid.
100+
*
101+
* @example
102+
* ```
103+
* // Short format
104+
* validatePersonalIdentityNumber('YYMMDDXXXX') // => true
105+
* validatePersonalIdentityNumber('YYMMDD-XXXX', { allowFormatting: true }) // => true
106+
*
107+
* // Long format
108+
* validatePersonalIdentityNumber('YYYYMMDDXXXX') // => true
109+
* validatePersonalIdentityNumber('YYYYMMDD-XXXX', { allowFormatting: true }) // => true
110+
* ```
111+
*/
112+
export function validateNationalIdentityNumber(
113+
value: string,
114+
options: NationalIdentityNumberOptions = {},
115+
): boolean {
116+
const match = PERSONNUMMER_FORMAT.exec(value);
117+
118+
if (!match) {
119+
return false;
120+
}
121+
122+
const [_, centuryStr, yearStr, monthStr, dayStr, separator, rest] = match;
123+
124+
if (centuryStr && options.format === 'short') {
125+
return false;
126+
}
127+
128+
if (!centuryStr && options.format === 'long') {
129+
return false;
130+
}
131+
132+
if (separator && !options.allowFormatting) {
133+
return false;
134+
}
135+
136+
// when verifying the value, we must always use the short format, discarding the century
137+
// if we include the century it would generate a different checksum
138+
const isValid = mod10(`${yearStr}${monthStr}${dayStr}${rest}`);
139+
if (!isValid) {
140+
return false;
141+
}
142+
143+
let year = 0;
144+
switch (true) {
145+
// if we have the long format version, we already have the full year
146+
case !!centuryStr:
147+
year = Number(centuryStr + yearStr);
148+
break;
149+
// otherwise, we can use the separator to determine the century of the personnummer
150+
// if the separator is '+', we know person is over a 100 years old
151+
// we can then calculate the full year
152+
case !!separator: {
153+
const date = new Date();
154+
const baseYear =
155+
separator === '+' ? date.getUTCFullYear() - 100 : date.getUTCFullYear();
156+
year =
157+
baseYear - ((baseYear - Number.parseInt(yearStr as string, 10)) % 100);
158+
break;
159+
}
160+
// if it's the short format, without a separator, we need to special handle the year for the date validation.
161+
// 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
162+
// we need to special handle that case. For other cases it doesn't really matter if the year is 1925 or 2025.
163+
case yearStr === '00':
164+
year = 2000;
165+
break;
166+
// short version without separator
167+
default:
168+
year = Number(yearStr);
169+
}
170+
171+
const month = Number(monthStr);
172+
173+
let day = Number(dayStr);
174+
// for a samordningsnummer the day is increased by 60. Eg the 31st of a month would be 91, or the 3rd would be 63.
175+
// thus we need to subtract 60 to get the correct day of the month
176+
if (day > 60) {
177+
day = day - 60;
178+
}
179+
180+
return isValidDate(year, month, day, Boolean(centuryStr || separator));
181+
}
182+
87183
// just reexport the no method for API feature parity
88184
export { validateObosMembershipNumber } from './no';

packages/validation/src/utils.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,59 @@ 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 and Norwegian KID numbers
33+
*
34+
* See https://no.wikipedia.org/wiki/MOD10 and https://sv.wikipedia.org/wiki/Luhn-algoritmen#Kontroll_av_nummer
35+
*/
36+
export function mod10(value: string): boolean {
37+
let sum = 0;
38+
39+
let weight = 1;
40+
// loop in reverse, starting with 1 as the weight for the last digit
41+
// which is control digit
42+
for (let i = value.length - 1; i >= 0; --i) {
43+
let number = Number(value[i]);
44+
45+
number = weight * number;
46+
47+
// 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
48+
// 7 * 2 => 14 => 1 + 4 => 5
49+
// instead of adding the digits together, we can subtract 9 for the same result
50+
// 7 * 2 => 14 => 14 - 9 => 5
51+
if (number > 9) {
52+
number = number - 9;
53+
}
54+
55+
sum += number;
56+
// alternate between 1 and 2 for the weight
57+
weight = weight === 1 ? 2 : 1;
58+
}
59+
60+
return sum % 10 === 0;
61+
}
62+
63+
export function isValidDate(
64+
year: number,
65+
month: number,
66+
day: number,
67+
/** Whether to check the year as part of the date validation. */
68+
validateYear = false,
69+
): boolean {
70+
// biome-ignore lint/style/noParameterAssign: months are zero index 🤷‍♂️
71+
month -= 1;
72+
73+
// important to use UTC so the user's timezone doesn't affect the validation
74+
const date = new Date(Date.UTC(year, month, day));
75+
76+
const validYear = validateYear ? date.getUTCFullYear() === year : true;
77+
78+
return (
79+
date &&
80+
validYear &&
81+
date.getUTCMonth() === month &&
82+
date.getUTCDate() === day
83+
);
84+
}

packages/validation/src/validation.test.ts

Lines changed: 84 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,87 @@ describe('se', () => {
178179
])('validateObosMembershipNumber(%s) -> %s', (input, expected, options) => {
179180
expect(se.validateObosMembershipNumber(input, options)).toBe(expected);
180181
});
182+
183+
test('validateNationalIdentityNumber() - validates short format (YYMMDDXXXX) personnummer', () => {
184+
for (let i = 0; i < 1000; ++i) {
185+
const pnrWithSeparator = swedishPersonNummer({ format: 'short' });
186+
const pnrWithoutSeparator = pnrWithSeparator.replace(/[-+]/, '');
187+
188+
expect(
189+
se.validateNationalIdentityNumber(pnrWithSeparator, {
190+
allowFormatting: true,
191+
format: 'short',
192+
}),
193+
`${pnrWithSeparator} is valid with separator`,
194+
).toBe(true);
195+
196+
expect(
197+
se.validateNationalIdentityNumber(pnrWithoutSeparator, {
198+
format: 'short',
199+
}),
200+
`${pnrWithSeparator} is valid without separator`,
201+
).toBe(true);
202+
}
203+
});
204+
205+
test('validateNationalIdentityNumber() - validates long format (YYYYMMDDXXXX) personnummer', () => {
206+
for (let i = 0; i < 1000; ++i) {
207+
const pnr = swedishPersonNummer({ format: 'long' });
208+
209+
expect(
210+
se.validateNationalIdentityNumber(pnr, { format: 'long' }),
211+
`${pnr} is valid`,
212+
).toBe(true);
213+
}
214+
});
215+
216+
test('validateNationalIdentityNumber() - handles separator/leap years', () => {
217+
// 29th of February is the best way to test whether the separator and long/short handling works correctly.
218+
// The 29th of February year 2000 is valid a valid date, while the 29th of February year 1900 is not.
219+
// That means we get different results based on the separator.
220+
expect(se.validateNationalIdentityNumber('0002297422')).toBe(true);
221+
expect(
222+
se.validateNationalIdentityNumber('000229-7422', {
223+
allowFormatting: true,
224+
}),
225+
).toBe(true);
226+
227+
expect(
228+
se.validateNationalIdentityNumber('000229+7422', {
229+
allowFormatting: true,
230+
}),
231+
).toBe(false);
232+
233+
expect(se.validateNationalIdentityNumber('190002297422')).toBe(false);
234+
});
235+
236+
test('validateNationalIdentityNumber() - validates samordningsnummer', () => {
237+
expect(
238+
se.validateNationalIdentityNumber('701063-2391', {
239+
allowFormatting: true,
240+
}),
241+
).toBe(true);
242+
});
243+
244+
test('validateNationalIdentityNumber() - respects format modifier', () => {
245+
expect(
246+
se.validateNationalIdentityNumber(
247+
swedishPersonNummer({ format: 'short' }),
248+
{
249+
allowFormatting: true,
250+
format: 'long',
251+
},
252+
),
253+
).toBe(false);
254+
255+
expect(
256+
se.validateNationalIdentityNumber(
257+
swedishPersonNummer({ format: 'long' }),
258+
{
259+
allowFormatting: true,
260+
format: 'short',
261+
},
262+
),
263+
).toBe(false);
264+
});
181265
});

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)