Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(scalars): add swedish personal number #2181

Merged
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
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