Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
1 change: 0 additions & 1 deletion packages/validation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ The methods are "strict" by default, meaning no formatting characters in the inp
This is preferrable, for instance when doing server-side validation, where the input is often expected to be a "clean" value.

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


```js
Expand Down
3 changes: 2 additions & 1 deletion packages/validation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"build": "bunchee"
},
"devDependencies": {
"nav-faker": "3.2.4"
"@personnummer/generate": "^1.0.3",
"nav-faker": "^3.2.4"
}
}
17 changes: 5 additions & 12 deletions packages/validation/src/no.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ValidatorOptions } from './types';
import { mod11, stripFormatting } from './utils';
import { isValidDate, mod11, stripFormatting } from './utils';

type PostalCodeOptions = ValidatorOptions;

Expand Down Expand Up @@ -104,7 +104,7 @@ export function validateObosMembershipNumber(
return /^\d{7}$/.test(value);
}

type PersonalIdentityNumberOptions = ValidatorOptions;
type NationalIdentityNumberOptions = ValidatorOptions;

/**
* Validates that the input value is a Norwegian national identity number (fødselsnummer or d-nummer).
Expand All @@ -113,16 +113,12 @@ type PersonalIdentityNumberOptions = ValidatorOptions;
*
* @example
* ```
* // Fødselsnummer
* validatePersonalIdentityNumber('21075417753') // => true
*
* // D-nummer
* validatePersonalIdentityNumber('53097248016') // => true
* validatePersonalIdentityNumber('DDMMYYXXXXX') // => true
* ```
*/
export function validateNationalIdentityNumber(
value: string,
options: PersonalIdentityNumberOptions = {},
options: NationalIdentityNumberOptions = {},
): boolean {
if (options.allowFormatting) {
// biome-ignore lint/style/noParameterAssign:
Expand Down Expand Up @@ -161,8 +157,5 @@ export function validateNationalIdentityNumber(
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;
return isValidDate(year, month, day);
}
98 changes: 97 additions & 1 deletion packages/validation/src/se.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ValidatorOptions } from './types';
import { stripFormatting } from './utils';
import { isValidDate, mod10, stripFormatting } from './utils';

type PostalCodeOptions = ValidatorOptions;

Expand Down Expand Up @@ -84,5 +84,101 @@ export function validateOrganizationNumber(
return /^\d{10}$/.test(value);
}

type NationalIdentityNumberFormat = 'short' | 'long';
type NationalIdentityNumberOptions = ValidatorOptions & {
/** Specify this if you want to format to be only long (12 digits) or short (10 digits). By default, both formats are allowed */
format?: NationalIdentityNumberFormat;
};

// the first two digts are optional, as they're the century in the long format version
const PERSONNUMMER_FORMAT = /^(\d{2}){0,1}(\d{2})(\d{2})(\d{2})([+-]?)(\d{4})$/;

/**
* Validates that the input value is a Swedish national identity number (personnummer or samordningsnummer).
*
* It validates the control digits and checks if the date of birth is valid.
*
* @example
* ```
* // Short format
* validatePersonalIdentityNumber('YYMMDDXXXX') // => true
* validatePersonalIdentityNumber('YYMMDD-XXXX', { allowFormatting: true }) // => true
*
* // Long format
* validatePersonalIdentityNumber('YYYYMMDDXXXX') // => true
* validatePersonalIdentityNumber('YYYYMMDD-XXXX', { allowFormatting: true }) // => true
* ```
*/
export function validateNationalIdentityNumber(
value: string,
options: NationalIdentityNumberOptions = {},
): boolean {
const match = PERSONNUMMER_FORMAT.exec(value);

if (!match) {
return false;
}

const [_, centuryStr, yearStr, monthStr, dayStr, separator, rest] = match;

if (centuryStr && options.format === 'short') {
return false;
}

if (!centuryStr && options.format === 'long') {
return false;
}

if (separator && !options.allowFormatting) {
return false;
}

// when verifying the value, we must always use the short format, discaring the century
// if we include the century it would generate a different checksum
const isValid = mod10(`${yearStr}${monthStr}${dayStr}${rest}`);
if (!isValid) {
return false;
}

let year = 0;
switch (true) {
// if we have the long format version, we already have the full year
case !!centuryStr:
year = Number(centuryStr + yearStr);
break;
// otherwise, we can use the separator to determine the century of the personnummer
// if the separator is '+', we know person is over a 100 years old
// we can then calculate the full year
case !!separator: {
const date = new Date();
const baseYear =
separator === '+' ? date.getUTCFullYear() - 100 : date.getUTCFullYear();
year =
baseYear - ((baseYear - Number.parseInt(yearStr as string, 10)) % 100);
break;
}
// if it's the short format, without a separator, we need to special handle the year for the date validation.
// 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.
case yearStr === '00':
year = 2000;
break;
// short version without separator
default:
year = Number(yearStr);
}

const month = Number(monthStr);

let day = Number(dayStr);
// for a samordningsnummer the day is increased by 60. Eg the 31st of a month would be 91, or the 3rd would be 63.
// thus we need to subtract 60 to get the correct day of the month
if (day > 60) {
day = day - 60;
}

return isValidDate(year, month, day, Boolean(centuryStr || separator));
}

// just reexport the no method for API feature parity
export { validateObosMembershipNumber } from './no';
56 changes: 56 additions & 0 deletions packages/validation/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,59 @@ export function mod11(value: string, weights: number[]): boolean {

return controlNumber === Number(value[value.length - 1]);
}

/**
* Also known as Luhn's algorithm.
* Used to validate Swedish national identity numbers and Norwegian KID numbers
*
* See https://no.wikipedia.org/wiki/MOD10 and https://sv.wikipedia.org/wiki/Luhn-algoritmen#Kontroll_av_nummer
*/
export function mod10(value: string): boolean {
let sum = 0;

let weight = 1;
// loop in reverse, starting with 1 as the weight for the last digit
// which is control digit
for (let i = value.length - 1; i >= 0; --i) {
let number = Number(value[i]);

number = weight * number;

// 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
// 7 * 2 => 14 => 1 + 4 => 5
// instead of adding the digits together, we can subtract 9 for the same result
// 7 * 2 => 14 => 14 - 9 => 5
if (number > 9) {
number = number - 9;
}

sum += number;
// alternate between 1 and 2 for the weight
weight = weight === 1 ? 2 : 1;
}

return sum % 10 === 0;
}

export function isValidDate(
year: number,
month: number,
day: number,
/** Whether to check the year as part of the date validation. */
validateYear = false,
): boolean {
// biome-ignore lint/style/noParameterAssign: months are zero index 🤷‍♂️
month -= 1;

// important to use UTC so the user's timezone doesn't affect the validation
const date = new Date(Date.UTC(year, month, day));

const validYear = validateYear ? date.getUTCFullYear() === year : true;

return (
date &&
validYear &&
date.getUTCMonth() === month &&
date.getUTCDate() === day
);
}
84 changes: 84 additions & 0 deletions packages/validation/src/validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import swedishPersonNummer from '@personnummer/generate';
import navfaker from 'nav-faker/dist/index';
import { describe, expect, test } from 'vitest';
import * as no from './no';
Expand Down Expand Up @@ -178,4 +179,87 @@ describe('se', () => {
])('validateObosMembershipNumber(%s) -> %s', (input, expected, options) => {
expect(se.validateObosMembershipNumber(input, options)).toBe(expected);
});

test('validateNationalIdentityNumber() - validates short format (YYMMDDXXXX) personnummer', () => {
for (let i = 0; i < 1000; ++i) {
const pnrWithSeparator = swedishPersonNummer({ format: 'short' });
const pnrWithoutSeparator = pnrWithSeparator.replace(/[-+]/, '');

expect(
se.validateNationalIdentityNumber(pnrWithSeparator, {
allowFormatting: true,
format: 'short',
}),
`${pnrWithSeparator} is valid with separator`,
).toBe(true);

expect(
se.validateNationalIdentityNumber(pnrWithoutSeparator, {
format: 'short',
}),
`${pnrWithSeparator} is valid without separator`,
).toBe(true);
}
});

test('validateNationalIdentityNumber() - validates long format (YYYYMMDDXXXX) personnummer', () => {
for (let i = 0; i < 1000; ++i) {
const pnr = swedishPersonNummer({ format: 'long' });

expect(
se.validateNationalIdentityNumber(pnr, { format: 'long' }),
`${pnr} is valid`,
).toBe(true);
}
});

test('validateNationalIdentityNumber() - handles separator/leap years', () => {
// 29th of February is the best way to test whether the separator and long/short handling works correctly.
// The 29th of February year 2000 is valid a valid date, while the 29th of February year 1900 is not.
// That means we get different results based on the separator.
expect(se.validateNationalIdentityNumber('0002297422')).toBe(true);
expect(
se.validateNationalIdentityNumber('000229-7422', {
allowFormatting: true,
}),
).toBe(true);

expect(
se.validateNationalIdentityNumber('000229+7422', {
allowFormatting: true,
}),
).toBe(false);

expect(se.validateNationalIdentityNumber('190002297422')).toBe(false);
});

test('validateNationalIdentityNumber() - validates samordningsnummer', () => {
expect(
se.validateNationalIdentityNumber('701063-2391', {
allowFormatting: true,
}),
).toBe(true);
});

test('validateNationalIdentityNumber() - respects format modifier', () => {
expect(
se.validateNationalIdentityNumber(
swedishPersonNummer({ format: 'short' }),
{
allowFormatting: true,
format: 'long',
},
),
).toBe(false);

expect(
se.validateNationalIdentityNumber(
swedishPersonNummer({ format: 'long' }),
{
allowFormatting: true,
format: 'short',
},
),
).toBe(false);
});
});
10 changes: 9 additions & 1 deletion pnpm-lock.yaml

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