diff --git a/packages/validation/README.md b/packages/validation/README.md index 1579caf..70e44ce 100644 --- a/packages/validation/README.md +++ b/packages/validation/README.md @@ -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 diff --git a/packages/validation/package.json b/packages/validation/package.json index 9149987..9dc70a0 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -31,6 +31,7 @@ "build": "bunchee" }, "devDependencies": { - "nav-faker": "3.2.4" + "@personnummer/generate": "^1.0.3", + "nav-faker": "^3.2.4" } } diff --git a/packages/validation/src/no.ts b/packages/validation/src/no.ts index 5e949b5..313958f 100644 --- a/packages/validation/src/no.ts +++ b/packages/validation/src/no.ts @@ -1,5 +1,5 @@ import type { ValidatorOptions } from './types'; -import { mod11, stripFormatting } from './utils'; +import { isValidDate, mod11, stripFormatting } from './utils'; type PostalCodeOptions = ValidatorOptions; @@ -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). @@ -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: @@ -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); } diff --git a/packages/validation/src/se.ts b/packages/validation/src/se.ts index 1c1313a..d3a3f14 100644 --- a/packages/validation/src/se.ts +++ b/packages/validation/src/se.ts @@ -1,5 +1,5 @@ import type { ValidatorOptions } from './types'; -import { stripFormatting } from './utils'; +import { isValidDate, mod10, stripFormatting } from './utils'; type PostalCodeOptions = ValidatorOptions; @@ -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'; diff --git a/packages/validation/src/utils.ts b/packages/validation/src/utils.ts index a6296f4..79057f1 100644 --- a/packages/validation/src/utils.ts +++ b/packages/validation/src/utils.ts @@ -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 + ); +} diff --git a/packages/validation/src/validation.test.ts b/packages/validation/src/validation.test.ts index 60a728a..ee8a8c5 100644 --- a/packages/validation/src/validation.test.ts +++ b/packages/validation/src/validation.test.ts @@ -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'; @@ -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); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dac794f..329c81b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,8 +31,11 @@ importers: packages/validation: devDependencies: + '@personnummer/generate': + specifier: ^1.0.3 + version: 1.0.3 nav-faker: - specifier: 3.2.4 + specifier: ^3.2.4 version: 3.2.4 packages: @@ -353,6 +356,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@personnummer/generate@1.0.3': + resolution: {integrity: sha512-2wnl6FLM+ImyqjnIvyDXTwj5Nk2oDT+WLqJO3kYhzbu/ZDh2F/mfkJQwCpmyEUCZJuwGI3agF2zRD6H9ZS3g9A==} + '@rollup/plugin-commonjs@28.0.2': resolution: {integrity: sha512-BEFI2EDqzl+vA1rl97IDRZ61AIwGH093d9nz8+dThxJNH8oSoB7MjWvPCX3dkaK1/RCJ/1v/R1XB15FuSs0fQw==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -1794,6 +1800,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.0 + '@personnummer/generate@1.0.3': {} + '@rollup/plugin-commonjs@28.0.2(rollup@4.34.8)': dependencies: '@rollup/pluginutils': 5.1.4(rollup@4.34.8)