├── .editorconfig ├── .eslintrc ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── errors.ts ├── index.ts └── utils.ts ├── test.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{js,ts}] 15 | quote_type = single -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [10.x, 12.x, 13.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: npm install and test 21 | run: | 22 | npm install 23 | npm test 24 | env: 25 | CI: true 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules 3 | *.log 4 | /index.js 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2014-2020 - Personnummer and Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the "Software"), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # personnummer [![Build Status](https://github.com/personnummer/js/workflows/build/badge.svg)](https://github.com/personnummer/js/actions) 2 | 3 | Validate Swedish personal identity numbers. Follows version 3 of the [specification](https://github.com/personnummer/meta#package-specification-v3). 4 | 5 | Install the module with npm: 6 | 7 | ``` 8 | npm install --save personnummer 9 | ``` 10 | 11 | ## Example 12 | 13 | ```javascript 14 | const Personnummer = require('personnummer'); 15 | 16 | Personnummer.valid('198507099805') 17 | //=> true 18 | ``` 19 | 20 | See [test.ts](test.ts) for more examples. 21 | 22 | ## License 23 | 24 | MIT 25 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const isProd = process.env.NODE_ENV === 'production'; 2 | 3 | module.exports = { 4 | presets: [ 5 | ['@jitesoft/main', { exclude: isProd ? ['transform-runtime'] : [] }], 6 | '@babel/preset-typescript', 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "personnummer", 3 | "description": "Validate Swedish personal identity numbers", 4 | "version": "3.1.0", 5 | "license": "MIT", 6 | "homepage": "https://github.com/personnummer/js", 7 | "author": { 8 | "name": "Fredrik Forsmo", 9 | "email": "fredrik.forsmo@gmail.com", 10 | "url": "https://frozzare.com" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git://github.com/personnummer/js.git" 15 | }, 16 | "files": [ 17 | "dist/**/*" 18 | ], 19 | "scripts": { 20 | "build:types": "tsc --emitDeclarationOnly", 21 | "build:js": "NODE_ENV=production rollup -c", 22 | "build": "rimraf dist && npm run build:types && npm run build:js", 23 | "format": "prettier --write 'src/**/*.ts' test.ts", 24 | "lint": "eslint src --ext .ts", 25 | "prepublishOnly": "npm run build", 26 | "test": "jest" 27 | }, 28 | "main": "dist/cjs/index.js", 29 | "module": "dist/esm/index.js", 30 | "types": "dist/types", 31 | "devDependencies": { 32 | "@babel/cli": "^7.10.1", 33 | "@babel/core": "^7.10.2", 34 | "@babel/preset-typescript": "^7.10.1", 35 | "@jitesoft/babel-preset-main": "^2.3.1", 36 | "@rollup/plugin-babel": "^5.0.3", 37 | "@rollup/plugin-commonjs": "^13.0.0", 38 | "@rollup/plugin-node-resolve": "^8.0.1", 39 | "@types/jest": "^26.0.0", 40 | "@typescript-eslint/eslint-plugin": "^3.3.0", 41 | "@typescript-eslint/parser": "^3.3.0", 42 | "babel-eslint": "^10.1.0", 43 | "babel-loader": "^8.1.0", 44 | "core-js": "^3.6.5", 45 | "eslint": "^7.2.0", 46 | "jest": "^26.0.1", 47 | "node-fetch": "^2.6.0", 48 | "prettier": "^2.0.5", 49 | "rimraf": "^3.0.2", 50 | "rollup": "^2.16.1", 51 | "tslib": "^2.0.0", 52 | "typescript": "^3.9.5" 53 | }, 54 | "keywords": [ 55 | "personnummer", 56 | "personal", 57 | "identity", 58 | "social", 59 | "security", 60 | "numbers" 61 | ], 62 | "semistandard": { 63 | "parser": "babel-eslint" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import babel from '@rollup/plugin-babel'; 4 | import pkg from './package.json'; 5 | 6 | const extensions = ['.js', '.jsx', '.ts', '.tsx']; 7 | 8 | export default { 9 | input: './src/index.ts', 10 | external: [], 11 | plugins: [ 12 | resolve({ extensions }), 13 | commonjs(), 14 | babel({ 15 | extensions, 16 | babelHelpers: 'bundled', 17 | include: ['src/**/*'], 18 | }), 19 | ], 20 | output: [ 21 | { 22 | file: pkg.main, 23 | format: 'cjs', 24 | }, 25 | { 26 | file: pkg.module, 27 | format: 'es', 28 | }, 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export class PersonnummerError extends Error { 2 | constructor() { 3 | super('Invalid swedish personal identity number'); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { PersonnummerError } from './errors'; 2 | import { diffInYears, luhn, testDate } from './utils'; 3 | 4 | type OptionsType = { 5 | [key: string]: boolean | number | string; 6 | }; 7 | 8 | class Personnummer { 9 | /** 10 | * Personnummer century. 11 | * 12 | * @var {string} 13 | */ 14 | private _century = ''; 15 | 16 | /** 17 | * Get century. 18 | * 19 | * @return {string} 20 | */ 21 | get century(): string { 22 | return this._century; 23 | } 24 | 25 | /** 26 | * Personnummer full year. 27 | * 28 | * @var {string} 29 | */ 30 | private _fullYear = ''; 31 | 32 | /** 33 | * Get age. 34 | * 35 | * @return {string} 36 | */ 37 | get fullYear(): string { 38 | return this._fullYear; 39 | } 40 | 41 | /** 42 | * Personnummer year. 43 | * 44 | * @var {string} 45 | */ 46 | private _year = ''; 47 | 48 | /** 49 | * Get age. 50 | * 51 | * @return {string} 52 | */ 53 | get year(): string { 54 | return this._year; 55 | } 56 | 57 | /** 58 | * Personnummer month. 59 | * 60 | * @var {string} 61 | */ 62 | private _month = ''; 63 | 64 | /** 65 | * Get month. 66 | * 67 | * @return {string} 68 | */ 69 | get month(): string { 70 | return this._month; 71 | } 72 | 73 | /** 74 | * Personnummer day. 75 | * 76 | * @var {string} 77 | */ 78 | private _day = ''; 79 | 80 | /** 81 | * Get day. 82 | * 83 | * @return {string} 84 | */ 85 | get day(): string { 86 | return this._day; 87 | } 88 | 89 | /** 90 | * Personnummer seperator. 91 | * 92 | * @var {string} 93 | */ 94 | private _sep = ''; 95 | 96 | /** 97 | * Get sep. 98 | * 99 | * @return {string} 100 | */ 101 | get sep(): string { 102 | return this._sep; 103 | } 104 | 105 | /** 106 | * Personnumer first three of the last four numbers. 107 | * 108 | * @var {string} 109 | */ 110 | private _num = ''; 111 | 112 | /** 113 | * Get num. 114 | * 115 | * @return {string} 116 | */ 117 | get num(): string { 118 | return this._num; 119 | } 120 | 121 | /** 122 | * The last number of the personnummer. 123 | * 124 | * @var {string} 125 | */ 126 | private _check = ''; 127 | 128 | /** 129 | * Get check. 130 | * 131 | * @return {string} 132 | */ 133 | get check(): string { 134 | return this._check; 135 | } 136 | 137 | /** 138 | * Personnummer constructor. 139 | * 140 | * @param {string} ssn 141 | * @param {object} options 142 | */ 143 | constructor(ssn: string, options?: OptionsType) { 144 | this.parse(ssn, options); 145 | } 146 | 147 | /** 148 | * Parse personnummer. 149 | * 150 | * @param {string} ssn 151 | * @param {object} options 152 | * 153 | * @return {Personnummer} 154 | */ 155 | static parse(ssn: string, options?: OptionsType): Personnummer { 156 | return new Personnummer(ssn, options); 157 | } 158 | 159 | /** 160 | * Validate a Swedish personal identity number. 161 | * 162 | * @param {string} str 163 | * @param {object} options 164 | * 165 | * @return {boolean} 166 | */ 167 | static valid(ssn: string, options?: OptionsType): boolean { 168 | try { 169 | Personnummer.parse(ssn, options); 170 | return true; 171 | } catch (e) { 172 | return false; 173 | } 174 | } 175 | 176 | /** 177 | * Parse personnummer and set class properties. 178 | * 179 | * @param {string} ssn 180 | * @param {object} options 181 | */ 182 | // eslint-disable-next-line 183 | private parse(ssn: string, options?: OptionsType) { 184 | const reg = /^(\d{2}){0,1}(\d{2})(\d{2})(\d{2})([\+\-\s]?)((?!000)\d{3})(\d)$/; 185 | const match = reg.exec(ssn); 186 | 187 | if (!match) { 188 | throw new PersonnummerError(); 189 | } 190 | 191 | const century = match[1]; 192 | const year = match[2]; 193 | const month = match[3]; 194 | const day = match[4]; 195 | const sep = match[5]; 196 | const num = match[6]; 197 | const check = match[7]; 198 | 199 | if (typeof century === 'undefined' || !century.length) { 200 | const d = new Date(); 201 | let baseYear = 0; 202 | 203 | if (sep === '+') { 204 | baseYear = d.getFullYear() - 100; 205 | } else { 206 | this._sep = '-'; 207 | baseYear = d.getFullYear(); 208 | } 209 | 210 | this._century = ( 211 | '' + 212 | (baseYear - ((baseYear - parseInt(year)) % 100)) 213 | ).substr(0, 2); 214 | } else { 215 | this._century = century; 216 | 217 | if (new Date().getFullYear() - parseInt(century + year, 10) < 100) { 218 | this._sep = '-'; 219 | } else { 220 | this._sep = '+'; 221 | } 222 | } 223 | 224 | this._year = year; 225 | this._fullYear = century + year; 226 | this._month = month; 227 | this._day = day; 228 | this._num = num; 229 | this._check = check; 230 | 231 | if (!this.valid()) { 232 | throw new PersonnummerError(); 233 | } 234 | } 235 | 236 | /** 237 | * Validate a Swedish personal identity number. 238 | * 239 | * @return {boolean} 240 | */ 241 | private valid(): boolean { 242 | const valid = 243 | luhn(this.year + this.month + this.day + this.num) === +this.check && 244 | !!this.check; 245 | 246 | if ( 247 | valid && 248 | testDate(parseInt(this.century + this.year), +this.month, +this.day) 249 | ) { 250 | return valid; 251 | } 252 | 253 | return ( 254 | valid && 255 | testDate(parseInt(this.century + this.year), +this.month, +this.day - 60) 256 | ); 257 | } 258 | 259 | /** 260 | * Format a Swedish personal identity number as one of the official formats, 261 | * A long format or a short format. 262 | * 263 | * If the input number could not be parsed a empty string will be returned. 264 | * 265 | * @param {boolean} longFormat 266 | * 267 | * @return {string} 268 | */ 269 | format(longFormat = false): string { 270 | if (longFormat) { 271 | return `${this.century}${this.year}${this.month}${this.day}${this.num}${this.check}`; 272 | } 273 | 274 | return `${this.year}${this.month}${this.day}${this.sep}${this.num}${this.check}`; 275 | } 276 | 277 | /** 278 | * Get age from a Swedish personal identity number. 279 | * 280 | * @return {number} 281 | */ 282 | getAge(): number { 283 | let ageDay = +this.day; 284 | if (this.isCoordinationNumber()) { 285 | ageDay -= 60; 286 | } 287 | 288 | const ageDate = this.century + this.year + '-' + this.month + '-' + ageDay; 289 | return diffInYears(new Date(Date.now()), new Date(ageDate)); 290 | } 291 | 292 | /** 293 | * Check if a Swedish personal identity number is a coordination number or not. 294 | * 295 | * @return {boolean} 296 | */ 297 | isCoordinationNumber(): boolean { 298 | return testDate( 299 | parseInt(this.century + this.year), 300 | +this.month, 301 | +this.day - 60 302 | ); 303 | } 304 | 305 | /** 306 | * Check if a Swedish personal identity number is for a female. 307 | * 308 | * @return {boolean} 309 | */ 310 | isFemale(): boolean { 311 | return !this.isMale(); 312 | } 313 | 314 | /** 315 | * Check if a Swedish personal identity number is for a male. 316 | * 317 | * @return {boolean} 318 | */ 319 | isMale(): boolean { 320 | const sexDigit = parseInt(this.num.substr(-1)); 321 | 322 | return sexDigit % 2 === 1; 323 | } 324 | } 325 | 326 | export default Personnummer; 327 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Compare the two dates and return -1, 0 or 1. 3 | * 4 | * @param {Date} dateLeft 5 | * @param {Date} dateRight 6 | * 7 | * @return {number} 8 | */ 9 | const compareAsc = (dateLeft: Date, dateRight: Date): number => { 10 | const diff = dateLeft.getTime() - dateRight.getTime(); 11 | return diff < 0 ? -1 : diff > 0 ? 1 : diff; 12 | }; 13 | 14 | /** 15 | * Get the number of full years between the given dates. 16 | * 17 | * @param {Date} dateLeft 18 | * @param {Date} dateRight 19 | * 20 | * @return {number} 21 | */ 22 | export const diffInYears = (dateLeft: Date, dateRight: Date): number => { 23 | const sign = compareAsc(dateLeft, dateRight); 24 | const yearDiff = Math.abs(dateLeft.getFullYear() - dateRight.getFullYear()); 25 | 26 | dateLeft.setFullYear(dateLeft.getFullYear() - sign * yearDiff); 27 | 28 | const isLastYearNotFull = compareAsc(dateLeft, dateRight) === -sign; 29 | const result = sign * (yearDiff - +isLastYearNotFull); 30 | 31 | return result === 0 ? 0 : result; 32 | }; 33 | 34 | /** 35 | * Calculates the Luhn checksum of a string of digits. 36 | * 37 | * @param {string|number} str 38 | * 39 | * @return {number} 40 | */ 41 | export const luhn = (str: string): number => { 42 | let sum = 0; 43 | 44 | str += ''; 45 | 46 | for (let i = 0, l = str.length; i < l; i++) { 47 | let v = parseInt(str[i]); 48 | v *= 2 - (i % 2); 49 | if (v > 9) { 50 | v -= 9; 51 | } 52 | sum += v; 53 | } 54 | 55 | return Math.ceil(sum / 10) * 10 - sum; 56 | }; 57 | 58 | /** 59 | * Test if the input parameters are a valid date or not. 60 | * 61 | * @param {int} year 62 | * @param {int} month 63 | * @param {int} day 64 | * 65 | * @return {boolean} 66 | */ 67 | export const testDate = (year: number, month: number, day: number): boolean => { 68 | month -= 1; 69 | const date = new Date(year, month, day); 70 | return !( 71 | date.getFullYear() !== year || 72 | date.getMonth() !== month || 73 | date.getDate() !== day 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /test.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import Personnummer from './src'; 3 | import { diffInYears } from './src/utils'; 4 | 5 | const availableListFormats = [ 6 | 'long_format', 7 | 'short_format', 8 | 'separated_format', 9 | 'separated_long', 10 | ]; 11 | 12 | let _testList = []; 13 | const testList = (): Promise => { 14 | if (_testList.length) { 15 | return new Promise((resolve) => { 16 | resolve(_testList.length); 17 | }); 18 | } 19 | 20 | return fetch( 21 | 'https://raw.githubusercontent.com/personnummer/meta/master/testdata/list.json', 22 | {} 23 | ).then((p) => p.json()); 24 | }; 25 | 26 | test('should validate personnummer with control digit', async () => { 27 | const list = await testList(); 28 | 29 | list.forEach((item) => { 30 | availableListFormats.forEach((format) => { 31 | expect(Personnummer.valid(item[format])).toBe(item.valid); 32 | }); 33 | }); 34 | }); 35 | 36 | test('should format personnummer', async () => { 37 | const list = await testList(); 38 | 39 | list.forEach((item) => { 40 | if (!item.valid) { 41 | return; 42 | } 43 | 44 | availableListFormats.forEach((format) => { 45 | if ( 46 | format !== 'short_format' && 47 | item.separated_format.indexOf('+') === -1 48 | ) { 49 | expect(Personnummer.parse(item[format]).format()).toBe( 50 | item.separated_format 51 | ); 52 | expect(Personnummer.parse(item[format]).format(true)).toBe( 53 | item.long_format 54 | ); 55 | } 56 | }); 57 | }); 58 | }); 59 | 60 | test('should throw personnummer error', async () => { 61 | const list = await testList(); 62 | 63 | list.forEach((item) => { 64 | if (item.valid) { 65 | return; 66 | } 67 | 68 | availableListFormats.forEach((format) => { 69 | try { 70 | Personnummer.parse(item[format]); 71 | expect(false).toBe(true); 72 | } catch (e) { 73 | expect(true).toBe(true); 74 | } 75 | }); 76 | }); 77 | }); 78 | 79 | test('should test personnummer sex', async () => { 80 | const list = await testList(); 81 | 82 | list.forEach((item) => { 83 | if (!item.valid) { 84 | return; 85 | } 86 | 87 | availableListFormats.forEach((format) => { 88 | expect(Personnummer.parse(item[format]).isMale()).toBe(item.isMale); 89 | expect(Personnummer.parse(item[format]).isFemale()).toBe(item.isFemale); 90 | }); 91 | }); 92 | }); 93 | 94 | test('should test personnummer age', async () => { 95 | const list = await testList(); 96 | 97 | list.forEach((item) => { 98 | if (!item.valid) { 99 | return; 100 | } 101 | 102 | availableListFormats.forEach((format) => { 103 | if ( 104 | format !== 'short_format' && 105 | item.separated_format.indexOf('+') === -1 106 | ) { 107 | const pin = item.separated_long; 108 | const year = pin.slice(0, 4); 109 | const month = pin.slice(4, 6); 110 | let day = pin.slice(6, 8); 111 | if (item.type == 'con') { 112 | day = '' + (parseInt(day) - 60); 113 | } 114 | 115 | const date = new Date(year, month, day); 116 | const now = new Date(); 117 | const expected = diffInYears(now, date); 118 | 119 | expect(Personnummer.parse(item[format]).getAge()).toBe(expected); 120 | } 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "lib": ["es2018", "dom"], 5 | "allowJs": false, 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "outDir": "dist/types", 9 | "rootDirs": ["src"], 10 | "noEmit": false, 11 | "strict": true, 12 | "moduleResolution": "node", 13 | "types": ["node"], 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "experimentalDecorators": true, 17 | "emitDecoratorMetadata": true, 18 | "skipLibCheck": true, 19 | "baseUrl": "src", 20 | "forceConsistentCasingInFileNames": true, 21 | "noImplicitReturns": true, 22 | "suppressImplicitAnyIndexErrors": true, 23 | "noUnusedLocals": true 24 | }, 25 | "include": ["src"] 26 | } 27 | --------------------------------------------------------------------------------