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
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, discarding 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.