From f1ff3ae3d7eb8d9744af9415c8d2f431747297ba Mon Sep 17 00:00:00 2001 From: Alexander Bjerkan Date: Wed, 26 Feb 2025 06:27:37 +0100 Subject: [PATCH 01/10] add Swedish validateNationalIdenityNumber() --- packages/validation/package.json | 3 +- packages/validation/src/no.ts | 7 +-- packages/validation/src/se.ts | 51 +++++++++++++++++++++- packages/validation/src/utils.ts | 46 +++++++++++++++++++ packages/validation/src/validation.test.ts | 11 +++++ pnpm-lock.yaml | 10 ++++- 6 files changed, 120 insertions(+), 8 deletions(-) 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..13b3c36 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; @@ -161,8 +161,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..7357283 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,54 @@ export function validateOrganizationNumber( return /^\d{10}$/.test(value); } +type PersonalIdentityNumberOptions = ValidatorOptions; + +/** + * Validates that the input value is a Swedish national identity number (fødselsnummer or d-nummer). + * + * It validates the control digits and checks if the date of birth is valid. + * + * @example + * ``` + * // Fødselsnummer + * validatePersonalIdentityNumber('21075417753') // => true + * + * // D-nummer + * validatePersonalIdentityNumber('53097248016') // => true + * ``` + */ +export function validateNationalIdentityNumber( + value: string, + options: PersonalIdentityNumberOptions = {}, +): boolean { + if (options.allowFormatting) { + // biome-ignore lint/style/noParameterAssign: + value = stripFormatting(value); + } + + const controlDigitCheck = mod10(value); + if (!controlDigitCheck) { + return false; + } + + let add = 0; + if (value.length === 12) { + add = 2; + } + + // copy/inspiration from NAV https://github.com/navikt/fnrvalidator/blob/77e57f0bc8e3570ddc2f0a94558c58d0f7259fe0/src/validator.ts#L108 + let year = Number(value.substring(0, 2 + add)); + const month = Number(value.substring(2 + add, 4 + add)); + const day = Number(value.substring(4 + add, 6 + add)); + + // 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. + if (year === 0) { + year = 2000; + } + + return isValidDate(year, month, day); +} + // 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..1572434 100644 --- a/packages/validation/src/utils.ts +++ b/packages/validation/src/utils.ts @@ -26,3 +26,49 @@ 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. + * See https://no.wikipedia.org/wiki/MOD10 + */ +export function mod10(value: string): boolean { + let sum = 0; + + for (let i = 0; i < value.length; ++i) { + const weight = 2 - (i % 2); + + 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; + } + + return sum % 10 === 0; +} + +export function isValidDate(year: number, month: number, day: number): boolean { + // months are zero indexed 🤷‍♂️ + 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)); + + return ( + date && + // cannot do this for Norway + // maybe do it for Sweden? + // date.getUTCFullYear() === year && + date.getUTCMonth() === month && + date.getUTCDate() === day + ); +} diff --git a/packages/validation/src/validation.test.ts b/packages/validation/src/validation.test.ts index 60a728a..c035a79 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,14 @@ describe('se', () => { ])('validateObosMembershipNumber(%s) -> %s', (input, expected, options) => { expect(se.validateObosMembershipNumber(input, options)).toBe(expected); }); + + test('validateNationalIdentityNumber() - validates leap years', () => { + for (let i = 0; i < 1000; ++i) { + const pnr = swedishPersonNummer({ format: 'short' }); + expect( + se.validateNationalIdentityNumber(pnr, { allowFormatting: true }), + `${pnr} is valid`, + ).toBe(true); + } + }); }); 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) From ec549164c636186d5aabca6f2ef37edd2e58dee7 Mon Sep 17 00:00:00 2001 From: Alexander Bjerkan Date: Wed, 26 Feb 2025 08:27:42 +0100 Subject: [PATCH 02/10] shared date validation --- packages/validation/src/se.ts | 12 +++++++++--- packages/validation/src/utils.ts | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/validation/src/se.ts b/packages/validation/src/se.ts index 7357283..762c634 100644 --- a/packages/validation/src/se.ts +++ b/packages/validation/src/se.ts @@ -87,16 +87,16 @@ export function validateOrganizationNumber( type PersonalIdentityNumberOptions = ValidatorOptions; /** - * Validates that the input value is a Swedish national identity number (fødselsnummer or d-nummer). + * 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 * ``` - * // Fødselsnummer + * // Personnummer * validatePersonalIdentityNumber('21075417753') // => true * - * // D-nummer + * // Samordningsnummer * validatePersonalIdentityNumber('53097248016') // => true * ``` */ @@ -130,6 +130,12 @@ export function validateNationalIdentityNumber( year = 2000; } + // for a d-number the day is increased by 40. Eg the 31st of a month would be 71, or the 3rd would be 43. + // thus we need to subtract 40 to get the correct day of the month + if (day > 40) { + day = day - 40; + } + return isValidDate(year, month, day); } diff --git a/packages/validation/src/utils.ts b/packages/validation/src/utils.ts index 1572434..e610857 100644 --- a/packages/validation/src/utils.ts +++ b/packages/validation/src/utils.ts @@ -57,7 +57,7 @@ export function mod10(value: string): boolean { } export function isValidDate(year: number, month: number, day: number): boolean { - // months are zero indexed 🤷‍♂️ + // 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 From 10a166aee8df8d9b12bb71b09cb35cc9e3778fad Mon Sep 17 00:00:00 2001 From: Alexander Bjerkan Date: Wed, 26 Feb 2025 10:55:39 +0100 Subject: [PATCH 03/10] samordningsnummer --- packages/validation/src/se.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/validation/src/se.ts b/packages/validation/src/se.ts index 762c634..e2c77e9 100644 --- a/packages/validation/src/se.ts +++ b/packages/validation/src/se.ts @@ -122,7 +122,7 @@ export function validateNationalIdentityNumber( // copy/inspiration from NAV https://github.com/navikt/fnrvalidator/blob/77e57f0bc8e3570ddc2f0a94558c58d0f7259fe0/src/validator.ts#L108 let year = Number(value.substring(0, 2 + add)); const month = Number(value.substring(2 + add, 4 + add)); - const day = Number(value.substring(4 + add, 6 + add)); + let day = Number(value.substring(4 + add, 6 + add)); // 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. @@ -130,10 +130,10 @@ export function validateNationalIdentityNumber( year = 2000; } - // for a d-number the day is increased by 40. Eg the 31st of a month would be 71, or the 3rd would be 43. - // thus we need to subtract 40 to get the correct day of the month - if (day > 40) { - day = day - 40; + // 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); From d9d016b2b801618956d11c68db80f56f7a5267b4 Mon Sep 17 00:00:00 2001 From: Alexander Bjerkan Date: Fri, 28 Feb 2025 08:09:37 +0100 Subject: [PATCH 04/10] better mod10 --- packages/validation/src/no.ts | 4 +-- packages/validation/src/se.ts | 29 ++++++++++++---------- packages/validation/src/utils.ts | 16 +++++++----- packages/validation/src/validation.test.ts | 24 +++++++++++++++++- 4 files changed, 51 insertions(+), 22 deletions(-) diff --git a/packages/validation/src/no.ts b/packages/validation/src/no.ts index 13b3c36..9ff434a 100644 --- a/packages/validation/src/no.ts +++ b/packages/validation/src/no.ts @@ -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). @@ -122,7 +122,7 @@ type PersonalIdentityNumberOptions = ValidatorOptions; */ export function validateNationalIdentityNumber( value: string, - options: PersonalIdentityNumberOptions = {}, + options: NationalIdentityNumberOptions = {}, ): boolean { if (options.allowFormatting) { // biome-ignore lint/style/noParameterAssign: diff --git a/packages/validation/src/se.ts b/packages/validation/src/se.ts index e2c77e9..1165f59 100644 --- a/packages/validation/src/se.ts +++ b/packages/validation/src/se.ts @@ -84,7 +84,9 @@ export function validateOrganizationNumber( return /^\d{10}$/.test(value); } -type PersonalIdentityNumberOptions = ValidatorOptions; +type NationalIdenityNumberOptions = ValidatorOptions & { + format?: ['long', 'short']; +}; /** * Validates that the input value is a Swedish national identity number (personnummer or samordningsnummer). @@ -102,31 +104,32 @@ type PersonalIdentityNumberOptions = ValidatorOptions; */ export function validateNationalIdentityNumber( value: string, - options: PersonalIdentityNumberOptions = {}, + options: NationalIdenityNumberOptions = {}, ): boolean { if (options.allowFormatting) { // biome-ignore lint/style/noParameterAssign: value = stripFormatting(value); } - const controlDigitCheck = mod10(value); - if (!controlDigitCheck) { - return false; - } + // this allows us to handle both YYYYMMDD and YYMMDD when extracting the date + const offset = value.length === 12 ? 2 : 0; - let add = 0; - if (value.length === 12) { - add = 2; + // when verifying the value, we must always use the short format. + // because the long format would generate a different checksum + const isValid = mod10(offset ? value.substring(2) : value); + if (!isValid) { + return false; } // copy/inspiration from NAV https://github.com/navikt/fnrvalidator/blob/77e57f0bc8e3570ddc2f0a94558c58d0f7259fe0/src/validator.ts#L108 - let year = Number(value.substring(0, 2 + add)); - const month = Number(value.substring(2 + add, 4 + add)); - let day = Number(value.substring(4 + add, 6 + add)); + let year = Number(value.substring(0, 2 + offset)); + const month = Number(value.substring(2 + offset, 4 + offset)); + let day = Number(value.substring(4 + offset, 6 + offset)); // 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. - if (year === 0) { + // if (options.format === 'short' && year === 0) { + if (value.length === 10 && year === 0) { year = 2000; } diff --git a/packages/validation/src/utils.ts b/packages/validation/src/utils.ts index e610857..df6aea7 100644 --- a/packages/validation/src/utils.ts +++ b/packages/validation/src/utils.ts @@ -29,15 +29,17 @@ export function mod11(value: string, weights: number[]): boolean { /** * Also known as Luhn's algorithm. - * Used to validate Swedish national identity numbers. - * See https://no.wikipedia.org/wiki/MOD10 + * 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; - for (let i = 0; i < value.length; ++i) { - const weight = 2 - (i % 2); - + 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; @@ -51,6 +53,8 @@ export function mod10(value: string): boolean { } sum += number; + // alternate between 1 and 2 for the weight + weight = weight === 1 ? 2 : 1; } return sum % 10 === 0; @@ -66,7 +70,7 @@ export function isValidDate(year: number, month: number, day: number): boolean { return ( date && // cannot do this for Norway - // maybe do it for Sweden? + // maybe do it for Sweden for long format? // date.getUTCFullYear() === year && date.getUTCMonth() === month && date.getUTCDate() === day diff --git a/packages/validation/src/validation.test.ts b/packages/validation/src/validation.test.ts index c035a79..9a57653 100644 --- a/packages/validation/src/validation.test.ts +++ b/packages/validation/src/validation.test.ts @@ -180,7 +180,17 @@ describe('se', () => { expect(se.validateObosMembershipNumber(input, options)).toBe(expected); }); - test('validateNationalIdentityNumber() - validates leap years', () => { + test('test with leap years', () => { + expect( + se.validateOrganizationNumber('000229-3017', { allowFormatting: true }), + ).toBe(true); + + expect( + se.validateOrganizationNumber('000229-5855', { allowFormatting: true }), + ).toBe(true); + }); + + test('validateNationalIdentityNumber() - validates short format personnummer', () => { for (let i = 0; i < 1000; ++i) { const pnr = swedishPersonNummer({ format: 'short' }); expect( @@ -189,4 +199,16 @@ describe('se', () => { ).toBe(true); } }); + + // 204101052241 + // 211802018075 + // 196304076083 + test.only('validateNationalIdentityNumber() - validates long format personnummer', () => { + for (let i = 0; i < 1000; ++i) { + const pnr = swedishPersonNummer({ format: 'long' }); + expect(se.validateNationalIdentityNumber(pnr), `${pnr} is valid`).toBe( + true, + ); + } + }); }); From 57022e16ced0892dd6d59657014b9b41160a9530 Mon Sep 17 00:00:00 2001 From: Alexander Bjerkan Date: Fri, 28 Feb 2025 20:23:09 +0100 Subject: [PATCH 05/10] API getting there --- packages/validation/src/se.ts | 14 ++++++++------ packages/validation/src/validation.test.ts | 17 +++++++++-------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/validation/src/se.ts b/packages/validation/src/se.ts index 1165f59..af5c3f5 100644 --- a/packages/validation/src/se.ts +++ b/packages/validation/src/se.ts @@ -84,8 +84,10 @@ export function validateOrganizationNumber( return /^\d{10}$/.test(value); } +type NationalIdentityNumberFormat = 'short' | 'long'; type NationalIdenityNumberOptions = ValidatorOptions & { - format?: ['long', 'short']; + /** By default, both formats are allowed */ + format?: NationalIdentityNumberFormat; }; /** @@ -111,16 +113,17 @@ export function validateNationalIdentityNumber( value = stripFormatting(value); } - // this allows us to handle both YYYYMMDD and YYMMDD when extracting the date - const offset = value.length === 12 ? 2 : 0; + const isLongFormat = value.length === 12; // when verifying the value, we must always use the short format. // because the long format would generate a different checksum - const isValid = mod10(offset ? value.substring(2) : value); + const isValid = mod10(isLongFormat ? value.substring(2) : value); if (!isValid) { return false; } + // this allows us to handle both YYYYMMDD and YYMMDD when extracting the date + const offset = isLongFormat ? 2 : 0; // copy/inspiration from NAV https://github.com/navikt/fnrvalidator/blob/77e57f0bc8e3570ddc2f0a94558c58d0f7259fe0/src/validator.ts#L108 let year = Number(value.substring(0, 2 + offset)); const month = Number(value.substring(2 + offset, 4 + offset)); @@ -128,8 +131,7 @@ export function validateNationalIdentityNumber( // 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. - // if (options.format === 'short' && year === 0) { - if (value.length === 10 && year === 0) { + if (!isLongFormat && year === 0) { year = 2000; } diff --git a/packages/validation/src/validation.test.ts b/packages/validation/src/validation.test.ts index 9a57653..9bf9d71 100644 --- a/packages/validation/src/validation.test.ts +++ b/packages/validation/src/validation.test.ts @@ -194,21 +194,22 @@ describe('se', () => { for (let i = 0; i < 1000; ++i) { const pnr = swedishPersonNummer({ format: 'short' }); expect( - se.validateNationalIdentityNumber(pnr, { allowFormatting: true }), + se.validateNationalIdentityNumber(pnr, { + allowFormatting: true, + format: 'short', + }), `${pnr} is valid`, ).toBe(true); } }); - // 204101052241 - // 211802018075 - // 196304076083 - test.only('validateNationalIdentityNumber() - validates long format personnummer', () => { + test('validateNationalIdentityNumber() - validates long format personnummer', () => { for (let i = 0; i < 1000; ++i) { const pnr = swedishPersonNummer({ format: 'long' }); - expect(se.validateNationalIdentityNumber(pnr), `${pnr} is valid`).toBe( - true, - ); + expect( + se.validateNationalIdentityNumber(pnr, { format: 'long' }), + `${pnr} is valid`, + ).toBe(true); } }); }); From ac0cfbbcf42af8daa1db5fa763bf4e7a2fb07cc4 Mon Sep 17 00:00:00 2001 From: Alexander Bjerkan Date: Sun, 2 Mar 2025 07:21:35 +0100 Subject: [PATCH 06/10] getting there --- packages/validation/src/se.ts | 60 ++++++++++++++-------- packages/validation/src/validation.test.ts | 14 +++++ 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/packages/validation/src/se.ts b/packages/validation/src/se.ts index af5c3f5..30cdadf 100644 --- a/packages/validation/src/se.ts +++ b/packages/validation/src/se.ts @@ -90,6 +90,9 @@ type NationalIdenityNumberOptions = ValidatorOptions & { 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). * @@ -108,40 +111,55 @@ export function validateNationalIdentityNumber( value: string, options: NationalIdenityNumberOptions = {}, ): boolean { - if (options.allowFormatting) { - // biome-ignore lint/style/noParameterAssign: - value = stripFormatting(value); + const match = PERSONNUMMER_FORMAT.exec(value); + + if (!match) { + return false; } - const isLongFormat = value.length === 12; + const [_, century, year, month, day, separator, rest] = match; - // when verifying the value, we must always use the short format. - // because the long format would generate a different checksum - const isValid = mod10(isLongFormat ? value.substring(2) : value); - if (!isValid) { + if (century && options.format === 'short') { return false; } - // this allows us to handle both YYYYMMDD and YYMMDD when extracting the date - const offset = isLongFormat ? 2 : 0; - // copy/inspiration from NAV https://github.com/navikt/fnrvalidator/blob/77e57f0bc8e3570ddc2f0a94558c58d0f7259fe0/src/validator.ts#L108 - let year = Number(value.substring(0, 2 + offset)); - const month = Number(value.substring(2 + offset, 4 + offset)); - let day = Number(value.substring(4 + offset, 6 + offset)); + if (separator && !options.allowFormatting) { + return false; + } - // 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. - if (!isLongFormat && year === 0) { - year = 2000; + // when verifying the value, we must always use the short format, discaring the century + // if included it would generate a different checksum + const isValid = mod10(`${year}${month}${day}${rest}`); + if (!isValid) { + return false; } + // // this allows us to handle both YYYYMMDD and YYMMDD when extracting the date + // const offset = isLongFormat ? 2 : 0; + // // copy/inspiration from NAV https://github.com/navikt/fnrvalidator/blob/77e57f0bc8e3570ddc2f0a94558c58d0f7259fe0/src/validator.ts#L108 + // let year = Number(value.substring(0, 2 + offset)); + // const month = Number(value.substring(2 + offset, 4 + offset)); + // let day = Number(value.substring(4 + offset, 6 + offset)); + // + + const month2 = Number(month); + let day2 = Number(day); + let year2 = Number(century ? century + year : year); // 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; + if (day2 > 60) { + day2 = day2 - 60; + } + + // 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. + if (!separator && !century && year === '00') { + year2 = 2000; + } else if (century) { + year2 = Number(century + year); } - return isValidDate(year, month, day); + return isValidDate(year2, month2, day2); } // just reexport the no method for API feature parity diff --git a/packages/validation/src/validation.test.ts b/packages/validation/src/validation.test.ts index 9bf9d71..9ded023 100644 --- a/packages/validation/src/validation.test.ts +++ b/packages/validation/src/validation.test.ts @@ -212,4 +212,18 @@ describe('se', () => { ).toBe(true); } }); + + test('validateNationalIdentityNumber() - validates long format 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 leap years', () => { + expect(se.validateNationalIdentityNumber('0002297422')).toBe(true); + }); }); From 1ff91c73a30f296bf1678bfd351886451764db95 Mon Sep 17 00:00:00 2001 From: Alexander Bjerkan Date: Sun, 2 Mar 2025 08:00:38 +0100 Subject: [PATCH 07/10] finito --- packages/validation/src/se.ts | 63 +++++++++++++--------- packages/validation/src/validation.test.ts | 2 +- 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/packages/validation/src/se.ts b/packages/validation/src/se.ts index 30cdadf..e96f406 100644 --- a/packages/validation/src/se.ts +++ b/packages/validation/src/se.ts @@ -117,9 +117,9 @@ export function validateNationalIdentityNumber( return false; } - const [_, century, year, month, day, separator, rest] = match; + const [_, centuryStr, yearStr, monthStr, dayStr, separator, rest] = match; - if (century && options.format === 'short') { + if (centuryStr && options.format === 'short') { return false; } @@ -128,38 +128,49 @@ export function validateNationalIdentityNumber( } // when verifying the value, we must always use the short format, discaring the century - // if included it would generate a different checksum - const isValid = mod10(`${year}${month}${day}${rest}`); + // if we include the century it would generate a different checksum + const isValid = mod10(`${yearStr}${monthStr}${dayStr}${rest}`); if (!isValid) { return false; } - // // this allows us to handle both YYYYMMDD and YYMMDD when extracting the date - // const offset = isLongFormat ? 2 : 0; - // // copy/inspiration from NAV https://github.com/navikt/fnrvalidator/blob/77e57f0bc8e3570ddc2f0a94558c58d0f7259fe0/src/validator.ts#L108 - // let year = Number(value.substring(0, 2 + offset)); - // const month = Number(value.substring(2 + offset, 4 + offset)); - // let day = Number(value.substring(4 + offset, 6 + offset)); - // - - const month2 = Number(month); - let day2 = Number(day); - let year2 = Number(century ? century + year : year); - // 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 (day2 > 60) { - day2 = day2 - 60; + 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 '+', the person is over a 100 years old + // we can then get + case separator: { + const date = new Date(); + const baseYear = + separator === '+' ? date.getUTCFullYear() - 100 : date.getUTCFullYear(); + year = baseYear - ((baseYear - yearStr) % 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); } - // 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. - if (!separator && !century && year === '00') { - year2 = 2000; - } else if (century) { - year2 = Number(century + year); + 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(year2, month2, day2); + return isValidDate(year, month, day); } // just reexport the no method for API feature parity diff --git a/packages/validation/src/validation.test.ts b/packages/validation/src/validation.test.ts index 9ded023..0337559 100644 --- a/packages/validation/src/validation.test.ts +++ b/packages/validation/src/validation.test.ts @@ -223,7 +223,7 @@ describe('se', () => { } }); - test('validateNationalIdentityNumber() - handles leap years', () => { + test.only('validateNationalIdentityNumber() - handles leap years', () => { expect(se.validateNationalIdentityNumber('0002297422')).toBe(true); }); }); From f820d3724832d69c6ec7899dd852161d3e0c5d58 Mon Sep 17 00:00:00 2001 From: Alexander Bjerkan Date: Mon, 3 Mar 2025 06:09:10 +0100 Subject: [PATCH 08/10] more tests --- packages/validation/README.md | 1 - packages/validation/src/no.ts | 6 +- packages/validation/src/se.ts | 30 ++++---- packages/validation/src/utils.ts | 14 ++-- packages/validation/src/validation.test.ts | 82 ++++++++++++++++------ 5 files changed, 88 insertions(+), 45 deletions(-) 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/src/no.ts b/packages/validation/src/no.ts index 9ff434a..313958f 100644 --- a/packages/validation/src/no.ts +++ b/packages/validation/src/no.ts @@ -113,11 +113,7 @@ type NationalIdentityNumberOptions = ValidatorOptions; * * @example * ``` - * // Fødselsnummer - * validatePersonalIdentityNumber('21075417753') // => true - * - * // D-nummer - * validatePersonalIdentityNumber('53097248016') // => true + * validatePersonalIdentityNumber('DDMMYYXXXXX') // => true * ``` */ export function validateNationalIdentityNumber( diff --git a/packages/validation/src/se.ts b/packages/validation/src/se.ts index e96f406..f26ce2b 100644 --- a/packages/validation/src/se.ts +++ b/packages/validation/src/se.ts @@ -85,8 +85,8 @@ export function validateOrganizationNumber( } type NationalIdentityNumberFormat = 'short' | 'long'; -type NationalIdenityNumberOptions = ValidatorOptions & { - /** By default, both formats are allowed */ +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; }; @@ -100,16 +100,18 @@ const PERSONNUMMER_FORMAT = /^(\d{2}){0,1}(\d{2})(\d{2})(\d{2})([+-]?)(\d{4})$/; * * @example * ``` - * // Personnummer - * validatePersonalIdentityNumber('21075417753') // => true + * // Short format + * validatePersonalIdentityNumber('YYMMDDXXXX') // => true + * validatePersonalIdentityNumber('YYMMDD-XXXX', { allowFormatting: true }) // => true * - * // Samordningsnummer - * validatePersonalIdentityNumber('53097248016') // => true + * // Long format + * validatePersonalIdentityNumber('YYYYMMDDXXXX') // => true + * validatePersonalIdentityNumber('YYYYMMDD-XXXX', { allowFormatting: true }) // => true * ``` */ export function validateNationalIdentityNumber( value: string, - options: NationalIdenityNumberOptions = {}, + options: NationalIdentityNumberOptions = {}, ): boolean { const match = PERSONNUMMER_FORMAT.exec(value); @@ -123,6 +125,10 @@ export function validateNationalIdentityNumber( return false; } + if (!centuryStr && options.format === 'long') { + return false; + } + if (separator && !options.allowFormatting) { return false; } @@ -137,13 +143,13 @@ export function validateNationalIdentityNumber( let year = 0; switch (true) { // if we have the long format version, we already have the full year - case centuryStr: + case !!centuryStr: year = Number(centuryStr + yearStr); break; // otherwise, we can use the separator to determine the century of the personnummer - // if the separator is '+', the person is over a 100 years old - // we can then get - case separator: { + // 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(); @@ -170,7 +176,7 @@ export function validateNationalIdentityNumber( day = day - 60; } - return isValidDate(year, month, day); + return isValidDate(year, month, day, centuryStr || separator); } // just reexport the no method for API feature parity diff --git a/packages/validation/src/utils.ts b/packages/validation/src/utils.ts index df6aea7..79057f1 100644 --- a/packages/validation/src/utils.ts +++ b/packages/validation/src/utils.ts @@ -60,18 +60,24 @@ export function mod10(value: string): boolean { return sum % 10 === 0; } -export function isValidDate(year: number, month: number, day: number): boolean { +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 && - // cannot do this for Norway - // maybe do it for Sweden for long format? - // date.getUTCFullYear() === year && + validYear && date.getUTCMonth() === month && date.getUTCDate() === day ); diff --git a/packages/validation/src/validation.test.ts b/packages/validation/src/validation.test.ts index 0337559..ee8a8c5 100644 --- a/packages/validation/src/validation.test.ts +++ b/packages/validation/src/validation.test.ts @@ -180,42 +180,32 @@ describe('se', () => { expect(se.validateObosMembershipNumber(input, options)).toBe(expected); }); - test('test with leap years', () => { - expect( - se.validateOrganizationNumber('000229-3017', { allowFormatting: true }), - ).toBe(true); - - expect( - se.validateOrganizationNumber('000229-5855', { allowFormatting: true }), - ).toBe(true); - }); - - test('validateNationalIdentityNumber() - validates short format personnummer', () => { + test('validateNationalIdentityNumber() - validates short format (YYMMDDXXXX) personnummer', () => { for (let i = 0; i < 1000; ++i) { - const pnr = swedishPersonNummer({ format: 'short' }); + const pnrWithSeparator = swedishPersonNummer({ format: 'short' }); + const pnrWithoutSeparator = pnrWithSeparator.replace(/[-+]/, ''); + expect( - se.validateNationalIdentityNumber(pnr, { + se.validateNationalIdentityNumber(pnrWithSeparator, { allowFormatting: true, format: 'short', }), - `${pnr} is valid`, + `${pnrWithSeparator} is valid with separator`, ).toBe(true); - } - }); - test('validateNationalIdentityNumber() - validates long format personnummer', () => { - for (let i = 0; i < 1000; ++i) { - const pnr = swedishPersonNummer({ format: 'long' }); expect( - se.validateNationalIdentityNumber(pnr, { format: 'long' }), - `${pnr} is valid`, + se.validateNationalIdentityNumber(pnrWithoutSeparator, { + format: 'short', + }), + `${pnrWithSeparator} is valid without separator`, ).toBe(true); } }); - test('validateNationalIdentityNumber() - validates long format personnummer', () => { + 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`, @@ -223,7 +213,53 @@ describe('se', () => { } }); - test.only('validateNationalIdentityNumber() - handles leap years', () => { + 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); }); }); From b2630bf076be7514edd6cef5939f40405ad98665 Mon Sep 17 00:00:00 2001 From: Alexander Bjerkan Date: Tue, 4 Mar 2025 22:30:39 +0100 Subject: [PATCH 09/10] build fixes --- packages/validation/src/se.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/validation/src/se.ts b/packages/validation/src/se.ts index f26ce2b..486c892 100644 --- a/packages/validation/src/se.ts +++ b/packages/validation/src/se.ts @@ -153,7 +153,8 @@ export function validateNationalIdentityNumber( const date = new Date(); const baseYear = separator === '+' ? date.getUTCFullYear() - 100 : date.getUTCFullYear(); - year = baseYear - ((baseYear - yearStr) % 100); + 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. @@ -176,7 +177,7 @@ export function validateNationalIdentityNumber( day = day - 60; } - return isValidDate(year, month, day, centuryStr || separator); + return isValidDate(year, month, day, Boolean(centuryStr || separator)); } // just reexport the no method for API feature parity From c639846d8eb0f284cf973fd22e060507b8e467a9 Mon Sep 17 00:00:00 2001 From: Alexander Bjerkan Date: Wed, 5 Mar 2025 19:42:08 +0100 Subject: [PATCH 10/10] Update packages/validation/src/se.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Oscar Carlström --- packages/validation/src/se.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/validation/src/se.ts b/packages/validation/src/se.ts index 486c892..d3a3f14 100644 --- a/packages/validation/src/se.ts +++ b/packages/validation/src/se.ts @@ -133,7 +133,7 @@ export function validateNationalIdentityNumber( return false; } - // when verifying the value, we must always use the short format, discaring the century + // 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) {