Skip to content

Commit

Permalink
feat(scalars): add swedish personal number (#2181)
Browse files Browse the repository at this point in the history
* feat(scalars): add swedish personal number

* Changeset

---------

Co-authored-by: Arda TANRIKULU <ardatanrikulu@gmail.com>
  • Loading branch information
blacksrc and ardatan authored Mar 18, 2024
1 parent 0924bef commit a868ecc
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/twelve-chairs-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'graphql-scalars': minor
---

Add new Swedish Personal Number scalar
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
GraphQLRoutingNumber,
GraphQLSafeInt,
GraphQLSemVer,
GraphQLSESSN,
GraphQLTime,
GraphQLTimestamp,
GraphQLTimeZone,
Expand Down Expand Up @@ -132,6 +133,7 @@ export {
AccountNumber as AccountNumberDefinition,
Cuid as CuidDefinition,
SemVer as SemVerDefinition,
SemVer as SESSNDefinition,
DeweyDecimal as DeweyDecimalDefinition,
LCCSubclass as LCCSubclassDefinition,
IPCPatent as IPCPatentDefinition,
Expand Down Expand Up @@ -203,6 +205,7 @@ export {
GraphQLAccountNumber as AccountNumberResolver,
GraphQLCuid as CuidResolver,
GraphQLSemVer as SemVerResolver,
GraphQLSESSN as SESSNResolver,
GraphQLDeweyDecimal as GraphQLDeweyDecimalResolver,
GraphQLIPCPatent as GraphQLIPCPatentResolver,
};
Expand Down Expand Up @@ -271,6 +274,7 @@ export const resolvers: Record<string, GraphQLScalarType> = {
AccountNumber: GraphQLAccountNumber,
Cuid: GraphQLCuid,
SemVer: GraphQLSemVer,
SESSN: GraphQLSESSN,
DeweyDecimal: GraphQLDeweyDecimal,
LCCSubclass: GraphQLLCCSubclass,
IPCPatent: GraphQLIPCPatent,
Expand Down Expand Up @@ -340,6 +344,7 @@ export {
AccountNumber as AccountNumberMock,
Cuid as CuidMock,
SemVer as SemVerMock,
SESSN as SESSNMock,
DeweyDecimal as DeweyDecimalMock,
LCCSubclass as LCCSubclassMock,
IPCPatent as IPCPatentMock,
Expand Down Expand Up @@ -417,6 +422,7 @@ export {
GraphQLAccountNumber,
GraphQLCuid,
GraphQLSemVer,
GraphQLSESSN,
GraphQLDeweyDecimal,
GraphQLLCCSubclass,
GraphQLIPCPatent,
Expand Down
1 change: 1 addition & 0 deletions src/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export const RoutingNumber = () => '111000025';
export const AccountNumber = () => '000000012345';
export const Cuid = () => 'cjld2cyuq0000t3rmniod1foy';
export const SemVer = () => '1.0.0-alpha.1';
export const SESSN = () => '194907011813';
export const DeweyDecimal = () => '435.4357';
export const LCCSubclass = () => 'KBM';
export const IPCPatent = () => 'G06F 12/803';
Expand Down
1 change: 1 addition & 0 deletions src/scalars/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export { GraphQLRoutingNumber } from './RoutingNumber.js';
export { GraphQLAccountNumber } from './AccountNumber.js';
export { GraphQLCuid } from './Cuid.js';
export { GraphQLSemVer } from './SemVer.js';
export { GraphQLSESSN } from './ssn/SE.js';
export { GraphQLDeweyDecimal } from './library/DeweyDecimal.js';
export { GraphQLLCCSubclass } from './library/LCCSubclass.js';
export { GraphQLIPCPatent } from './patent/IPCPatent.js';
138 changes: 138 additions & 0 deletions src/scalars/ssn/SE.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { GraphQLScalarType, Kind } from 'graphql';
import { createGraphQLError } from '../../error.js';

// Swedish Personal Number also known as 'personnummer' in swedish:
// https://www.skatteverket.se/privat/folkbokforing/personnummer.4.3810a01c150939e893f18c29.html
// Algorithm:
// https://swedish.identityinfo.net/personalidentitynumber

const SESSN_PATTERNS = ['YYYYMMDDXXXX', 'YYMMDDXXXX'];

function _isValidSwedishPersonalNumber(value: string): boolean {
// Remove any non-digit characters
const pno: string = value.replace(/\D/g, '');
// Check if the cleaned number has the correct length (10 or 12 digits)
if (pno.length !== 10 && pno.length !== 12) {
return false;
}

// Validate the birthdate
if (!_isValidDate(pno)) {
return false;
}

// Check the checksum for numbers
if (!_isValidChecksum(pno)) {
return false;
}

// If all checks pass, the personal number is valid
return true;
}

function _isValidDate(pno: string): boolean {
let year: number;
let month: number;
let day: number;

if (pno.length === 10) {
year = Number(pno.substring(0, 2));
// Adjust the input 'year' to a four-digit year based on the assumption that two-digit years greater than the current year are in the past century (1900s),
// while two-digit years less than or equal to the current year are in the current or upcoming century (2000s).
year = year > Number(String(new Date().getFullYear()).substring(2)) ? 1900 + year : 2000 + year;
month = Number(pno.substring(2, 4));
day = Number(pno.substring(4, 6));
} else {
year = Number(pno.substring(0, 4));
month = Number(pno.substring(4, 6));
day = Number(pno.substring(6, 8));
}

const date = new Date(year, month - 1, day);

return date.getFullYear() === year && date.getMonth() + 1 === month && date.getDate() === day;
}

function _isValidChecksum(pno: string): boolean {
const shortPno: string = pno.length === 12 ? pno.substring(2, 12) : pno;
const digits: number[] = shortPno.split('').map(Number);
let sum: number = 0;

for (let i: number = 0; i < digits.length; i++) {
let digit = digits[i];

// Double every second digit from the right
if (i % 2 === digits.length % 2) {
digit *= 2;
if (digit > 9) {
digit -= 9;
}
}

sum += digit;
}

// Check if the sum is a multiple of 10
return sum % 10 === 0;
}

function _checkString(value: any): void {
if (typeof value !== 'string') {
throw createGraphQLError(`Value is not string: ${value}`);
}
}

function _checkSSN(value: string): void {
if (!_isValidSwedishPersonalNumber(value)) {
throw createGraphQLError(`Value is not a valid swedish personal number: ${value}`);
}
}

export const GraphQLSESSN: GraphQLScalarType = /*#__PURE__*/ new GraphQLScalarType({
name: 'SESSN',
description:
'A field whose value conforms to the standard personal number (personnummer) formats for Sweden',

serialize(value) {
_checkString(value);
_checkSSN(value as string);

return value;
},

parseValue(value) {
_checkString(value);
_checkSSN(value as string);

return value;
},

parseLiteral(ast) {
if (ast.kind !== Kind.STRING) {
throw createGraphQLError(
`Can only validate strings as swedish personal number but got a: ${ast.kind}`,
{ nodes: ast },
);
}

if (!_isValidSwedishPersonalNumber(ast.value)) {
throw createGraphQLError(`Value is not a valid swedish personal number: ${ast.value}`, {
nodes: ast,
});
}

return ast.value;
},

extensions: {
codegenScalarType: 'string',
jsonSchema: {
title: 'SESSN',
oneOf: SESSN_PATTERNS.map((pattern: string) => ({
type: 'string',
length: pattern.length,
pattern,
})),
},
},
});
2 changes: 2 additions & 0 deletions src/typeDefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const RoutingNumber = 'scalar RoutingNumber';
export const AccountNumber = 'scalar AccountNumber';
export const Cuid = 'scalar Cuid';
export const SemVer = 'scalar SemVer';
export const SESSN = 'scalar SESSN';

export const UnsignedFloat = 'scalar UnsignedFloat';
export const UnsignedInt = 'scalar UnsignedInt';
Expand Down Expand Up @@ -133,6 +134,7 @@ export const typeDefs = [
AccountNumber,
Cuid,
SemVer,
SESSN,
DeweyDecimal,
LCCSubclass,
IPCPatent,
Expand Down
101 changes: 101 additions & 0 deletions tests/ssn/SE.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Kind } from 'graphql/language';
import { GraphQLSESSN } from '../../src/scalars/ssn/SE.js';

// List was taken from https://www.uc.se/developer/consumer-reports/getting-started/
// and https://skatteverket.entryscape.net/catalog/9/datasets/147
const SSNs = [
'194907011813',
'4907011813',
'194006128989',
'4006128989',
'196512233666',
'193303190718',
'3303190718',
'195207199398',
'5207199398',
];

describe(`SSN => SE`, () => {
describe(`valid`, () => {
it(`serialize`, () => {
for (const value of SSNs) {
expect(GraphQLSESSN.serialize(value)).toEqual(value);
}
});

it(`parseValue`, () => {
for (const value of SSNs) {
expect(GraphQLSESSN.parseValue(value)).toEqual(value);
}
});

it(`parseLiteral`, () => {
for (const value of SSNs) {
expect(
GraphQLSESSN.parseLiteral(
{
value,
kind: Kind.STRING,
},
{},
),
).toEqual(value);
}
});
});

describe(`invalid`, () => {
describe(`not a valid swedish personal number`, () => {
it(`serialize`, () => {
expect(() => GraphQLSESSN.serialize(123456789012)).toThrow(/Value is not string/);
expect(() => GraphQLSESSN.serialize(`this is not a swedish personal number`)).toThrow(
/Value is not a valid swedish personal number: this is not a swedish personal number/,
);
expect(() => GraphQLSESSN.serialize(`123456789012`)).toThrow(
/Value is not a valid swedish personal number: 123456789012/,
);
expect(() => GraphQLSESSN.serialize(`194907011811`)).toThrow(
/Value is not a valid swedish personal number: 194907011811/,
);
expect(() => GraphQLSESSN.serialize(`4907011811`)).toThrow(
/Value is not a valid swedish personal number: 4907011811/,
);
});

it(`parseValue`, () => {
expect(() => GraphQLSESSN.serialize(123456789012)).toThrow(/Value is not string/);
expect(() => GraphQLSESSN.parseValue(`this is not a swedish personal number`)).toThrow(
/Value is not a valid/,
);
expect(() => GraphQLSESSN.parseValue(`123456789012`)).toThrow(
/Value is not a valid swedish personal number: 123456789012/,
);
expect(() => GraphQLSESSN.serialize(`194907011811`)).toThrow(
/Value is not a valid swedish personal number: 194907011811/,
);
expect(() => GraphQLSESSN.serialize(`4907011811`)).toThrow(
/Value is not a valid swedish personal number: 4907011811/,
);
});

it(`parseLiteral`, () => {
expect(() =>
GraphQLSESSN.parseLiteral({ value: 123456789012, kind: Kind.INT } as any, {}),
).toThrow(/Can only validate strings as swedish personal number but got a: IntValue/);

expect(() =>
GraphQLSESSN.parseLiteral({ value: `123456789012`, kind: Kind.INT } as any, {}),
).toThrow(/Can only validate strings as swedish personal number but got a: IntValue/);

expect(() =>
GraphQLSESSN.parseLiteral(
{ value: `this is not a swedish personal number`, kind: Kind.STRING },
{},
),
).toThrow(
/Value is not a valid swedish personal number: this is not a swedish personal number/,
);
});
});
});
});
15 changes: 15 additions & 0 deletions website/src/pages/docs/scalars/ssn.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# SSN

## SE (Swedish Personal Number `personnummer`)

Accepts the value in the following formats:

- YYYYMMDDXXXX
- YYMMDDXXXX

In case of 10 digit format, it adjusts the 'year' to a four-digit year based on the assumption that
two-digit years greater than the current year are in the past century (1900s), while two-digit years
less than or equal to the current year are in the current or upcoming century (2000s).

Reference:
https://www.skatteverket.se/privat/folkbokforing/personnummer.4.3810a01c150939e893f18c29.html

0 comments on commit a868ecc

Please sign in to comment.