Skip to content

Commit a868ecc

Browse files
blacksrcardatan
andauthored
feat(scalars): add swedish personal number (#2181)
* feat(scalars): add swedish personal number * Changeset --------- Co-authored-by: Arda TANRIKULU <[email protected]>
1 parent 0924bef commit a868ecc

File tree

8 files changed

+269
-0
lines changed

8 files changed

+269
-0
lines changed

.changeset/twelve-chairs-drop.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'graphql-scalars': minor
3+
---
4+
5+
Add new Swedish Personal Number scalar

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
GraphQLRoutingNumber,
5858
GraphQLSafeInt,
5959
GraphQLSemVer,
60+
GraphQLSESSN,
6061
GraphQLTime,
6162
GraphQLTimestamp,
6263
GraphQLTimeZone,
@@ -132,6 +133,7 @@ export {
132133
AccountNumber as AccountNumberDefinition,
133134
Cuid as CuidDefinition,
134135
SemVer as SemVerDefinition,
136+
SemVer as SESSNDefinition,
135137
DeweyDecimal as DeweyDecimalDefinition,
136138
LCCSubclass as LCCSubclassDefinition,
137139
IPCPatent as IPCPatentDefinition,
@@ -203,6 +205,7 @@ export {
203205
GraphQLAccountNumber as AccountNumberResolver,
204206
GraphQLCuid as CuidResolver,
205207
GraphQLSemVer as SemVerResolver,
208+
GraphQLSESSN as SESSNResolver,
206209
GraphQLDeweyDecimal as GraphQLDeweyDecimalResolver,
207210
GraphQLIPCPatent as GraphQLIPCPatentResolver,
208211
};
@@ -271,6 +274,7 @@ export const resolvers: Record<string, GraphQLScalarType> = {
271274
AccountNumber: GraphQLAccountNumber,
272275
Cuid: GraphQLCuid,
273276
SemVer: GraphQLSemVer,
277+
SESSN: GraphQLSESSN,
274278
DeweyDecimal: GraphQLDeweyDecimal,
275279
LCCSubclass: GraphQLLCCSubclass,
276280
IPCPatent: GraphQLIPCPatent,
@@ -340,6 +344,7 @@ export {
340344
AccountNumber as AccountNumberMock,
341345
Cuid as CuidMock,
342346
SemVer as SemVerMock,
347+
SESSN as SESSNMock,
343348
DeweyDecimal as DeweyDecimalMock,
344349
LCCSubclass as LCCSubclassMock,
345350
IPCPatent as IPCPatentMock,
@@ -417,6 +422,7 @@ export {
417422
GraphQLAccountNumber,
418423
GraphQLCuid,
419424
GraphQLSemVer,
425+
GraphQLSESSN,
420426
GraphQLDeweyDecimal,
421427
GraphQLLCCSubclass,
422428
GraphQLIPCPatent,

src/mocks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export const RoutingNumber = () => '111000025';
111111
export const AccountNumber = () => '000000012345';
112112
export const Cuid = () => 'cjld2cyuq0000t3rmniod1foy';
113113
export const SemVer = () => '1.0.0-alpha.1';
114+
export const SESSN = () => '194907011813';
114115
export const DeweyDecimal = () => '435.4357';
115116
export const LCCSubclass = () => 'KBM';
116117
export const IPCPatent = () => 'G06F 12/803';

src/scalars/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export { GraphQLRoutingNumber } from './RoutingNumber.js';
6161
export { GraphQLAccountNumber } from './AccountNumber.js';
6262
export { GraphQLCuid } from './Cuid.js';
6363
export { GraphQLSemVer } from './SemVer.js';
64+
export { GraphQLSESSN } from './ssn/SE.js';
6465
export { GraphQLDeweyDecimal } from './library/DeweyDecimal.js';
6566
export { GraphQLLCCSubclass } from './library/LCCSubclass.js';
6667
export { GraphQLIPCPatent } from './patent/IPCPatent.js';

src/scalars/ssn/SE.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { GraphQLScalarType, Kind } from 'graphql';
2+
import { createGraphQLError } from '../../error.js';
3+
4+
// Swedish Personal Number also known as 'personnummer' in swedish:
5+
// https://www.skatteverket.se/privat/folkbokforing/personnummer.4.3810a01c150939e893f18c29.html
6+
// Algorithm:
7+
// https://swedish.identityinfo.net/personalidentitynumber
8+
9+
const SESSN_PATTERNS = ['YYYYMMDDXXXX', 'YYMMDDXXXX'];
10+
11+
function _isValidSwedishPersonalNumber(value: string): boolean {
12+
// Remove any non-digit characters
13+
const pno: string = value.replace(/\D/g, '');
14+
// Check if the cleaned number has the correct length (10 or 12 digits)
15+
if (pno.length !== 10 && pno.length !== 12) {
16+
return false;
17+
}
18+
19+
// Validate the birthdate
20+
if (!_isValidDate(pno)) {
21+
return false;
22+
}
23+
24+
// Check the checksum for numbers
25+
if (!_isValidChecksum(pno)) {
26+
return false;
27+
}
28+
29+
// If all checks pass, the personal number is valid
30+
return true;
31+
}
32+
33+
function _isValidDate(pno: string): boolean {
34+
let year: number;
35+
let month: number;
36+
let day: number;
37+
38+
if (pno.length === 10) {
39+
year = Number(pno.substring(0, 2));
40+
// 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),
41+
// while two-digit years less than or equal to the current year are in the current or upcoming century (2000s).
42+
year = year > Number(String(new Date().getFullYear()).substring(2)) ? 1900 + year : 2000 + year;
43+
month = Number(pno.substring(2, 4));
44+
day = Number(pno.substring(4, 6));
45+
} else {
46+
year = Number(pno.substring(0, 4));
47+
month = Number(pno.substring(4, 6));
48+
day = Number(pno.substring(6, 8));
49+
}
50+
51+
const date = new Date(year, month - 1, day);
52+
53+
return date.getFullYear() === year && date.getMonth() + 1 === month && date.getDate() === day;
54+
}
55+
56+
function _isValidChecksum(pno: string): boolean {
57+
const shortPno: string = pno.length === 12 ? pno.substring(2, 12) : pno;
58+
const digits: number[] = shortPno.split('').map(Number);
59+
let sum: number = 0;
60+
61+
for (let i: number = 0; i < digits.length; i++) {
62+
let digit = digits[i];
63+
64+
// Double every second digit from the right
65+
if (i % 2 === digits.length % 2) {
66+
digit *= 2;
67+
if (digit > 9) {
68+
digit -= 9;
69+
}
70+
}
71+
72+
sum += digit;
73+
}
74+
75+
// Check if the sum is a multiple of 10
76+
return sum % 10 === 0;
77+
}
78+
79+
function _checkString(value: any): void {
80+
if (typeof value !== 'string') {
81+
throw createGraphQLError(`Value is not string: ${value}`);
82+
}
83+
}
84+
85+
function _checkSSN(value: string): void {
86+
if (!_isValidSwedishPersonalNumber(value)) {
87+
throw createGraphQLError(`Value is not a valid swedish personal number: ${value}`);
88+
}
89+
}
90+
91+
export const GraphQLSESSN: GraphQLScalarType = /*#__PURE__*/ new GraphQLScalarType({
92+
name: 'SESSN',
93+
description:
94+
'A field whose value conforms to the standard personal number (personnummer) formats for Sweden',
95+
96+
serialize(value) {
97+
_checkString(value);
98+
_checkSSN(value as string);
99+
100+
return value;
101+
},
102+
103+
parseValue(value) {
104+
_checkString(value);
105+
_checkSSN(value as string);
106+
107+
return value;
108+
},
109+
110+
parseLiteral(ast) {
111+
if (ast.kind !== Kind.STRING) {
112+
throw createGraphQLError(
113+
`Can only validate strings as swedish personal number but got a: ${ast.kind}`,
114+
{ nodes: ast },
115+
);
116+
}
117+
118+
if (!_isValidSwedishPersonalNumber(ast.value)) {
119+
throw createGraphQLError(`Value is not a valid swedish personal number: ${ast.value}`, {
120+
nodes: ast,
121+
});
122+
}
123+
124+
return ast.value;
125+
},
126+
127+
extensions: {
128+
codegenScalarType: 'string',
129+
jsonSchema: {
130+
title: 'SESSN',
131+
oneOf: SESSN_PATTERNS.map((pattern: string) => ({
132+
type: 'string',
133+
length: pattern.length,
134+
pattern,
135+
})),
136+
},
137+
},
138+
});

src/typeDefs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const RoutingNumber = 'scalar RoutingNumber';
5252
export const AccountNumber = 'scalar AccountNumber';
5353
export const Cuid = 'scalar Cuid';
5454
export const SemVer = 'scalar SemVer';
55+
export const SESSN = 'scalar SESSN';
5556

5657
export const UnsignedFloat = 'scalar UnsignedFloat';
5758
export const UnsignedInt = 'scalar UnsignedInt';
@@ -133,6 +134,7 @@ export const typeDefs = [
133134
AccountNumber,
134135
Cuid,
135136
SemVer,
137+
SESSN,
136138
DeweyDecimal,
137139
LCCSubclass,
138140
IPCPatent,

tests/ssn/SE.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { Kind } from 'graphql/language';
2+
import { GraphQLSESSN } from '../../src/scalars/ssn/SE.js';
3+
4+
// List was taken from https://www.uc.se/developer/consumer-reports/getting-started/
5+
// and https://skatteverket.entryscape.net/catalog/9/datasets/147
6+
const SSNs = [
7+
'194907011813',
8+
'4907011813',
9+
'194006128989',
10+
'4006128989',
11+
'196512233666',
12+
'193303190718',
13+
'3303190718',
14+
'195207199398',
15+
'5207199398',
16+
];
17+
18+
describe(`SSN => SE`, () => {
19+
describe(`valid`, () => {
20+
it(`serialize`, () => {
21+
for (const value of SSNs) {
22+
expect(GraphQLSESSN.serialize(value)).toEqual(value);
23+
}
24+
});
25+
26+
it(`parseValue`, () => {
27+
for (const value of SSNs) {
28+
expect(GraphQLSESSN.parseValue(value)).toEqual(value);
29+
}
30+
});
31+
32+
it(`parseLiteral`, () => {
33+
for (const value of SSNs) {
34+
expect(
35+
GraphQLSESSN.parseLiteral(
36+
{
37+
value,
38+
kind: Kind.STRING,
39+
},
40+
{},
41+
),
42+
).toEqual(value);
43+
}
44+
});
45+
});
46+
47+
describe(`invalid`, () => {
48+
describe(`not a valid swedish personal number`, () => {
49+
it(`serialize`, () => {
50+
expect(() => GraphQLSESSN.serialize(123456789012)).toThrow(/Value is not string/);
51+
expect(() => GraphQLSESSN.serialize(`this is not a swedish personal number`)).toThrow(
52+
/Value is not a valid swedish personal number: this is not a swedish personal number/,
53+
);
54+
expect(() => GraphQLSESSN.serialize(`123456789012`)).toThrow(
55+
/Value is not a valid swedish personal number: 123456789012/,
56+
);
57+
expect(() => GraphQLSESSN.serialize(`194907011811`)).toThrow(
58+
/Value is not a valid swedish personal number: 194907011811/,
59+
);
60+
expect(() => GraphQLSESSN.serialize(`4907011811`)).toThrow(
61+
/Value is not a valid swedish personal number: 4907011811/,
62+
);
63+
});
64+
65+
it(`parseValue`, () => {
66+
expect(() => GraphQLSESSN.serialize(123456789012)).toThrow(/Value is not string/);
67+
expect(() => GraphQLSESSN.parseValue(`this is not a swedish personal number`)).toThrow(
68+
/Value is not a valid/,
69+
);
70+
expect(() => GraphQLSESSN.parseValue(`123456789012`)).toThrow(
71+
/Value is not a valid swedish personal number: 123456789012/,
72+
);
73+
expect(() => GraphQLSESSN.serialize(`194907011811`)).toThrow(
74+
/Value is not a valid swedish personal number: 194907011811/,
75+
);
76+
expect(() => GraphQLSESSN.serialize(`4907011811`)).toThrow(
77+
/Value is not a valid swedish personal number: 4907011811/,
78+
);
79+
});
80+
81+
it(`parseLiteral`, () => {
82+
expect(() =>
83+
GraphQLSESSN.parseLiteral({ value: 123456789012, kind: Kind.INT } as any, {}),
84+
).toThrow(/Can only validate strings as swedish personal number but got a: IntValue/);
85+
86+
expect(() =>
87+
GraphQLSESSN.parseLiteral({ value: `123456789012`, kind: Kind.INT } as any, {}),
88+
).toThrow(/Can only validate strings as swedish personal number but got a: IntValue/);
89+
90+
expect(() =>
91+
GraphQLSESSN.parseLiteral(
92+
{ value: `this is not a swedish personal number`, kind: Kind.STRING },
93+
{},
94+
),
95+
).toThrow(
96+
/Value is not a valid swedish personal number: this is not a swedish personal number/,
97+
);
98+
});
99+
});
100+
});
101+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# SSN
2+
3+
## SE (Swedish Personal Number `personnummer`)
4+
5+
Accepts the value in the following formats:
6+
7+
- YYYYMMDDXXXX
8+
- YYMMDDXXXX
9+
10+
In case of 10 digit format, it adjusts the 'year' to a four-digit year based on the assumption that
11+
two-digit years greater than the current year are in the past century (1900s), while two-digit years
12+
less than or equal to the current year are in the current or upcoming century (2000s).
13+
14+
Reference:
15+
https://www.skatteverket.se/privat/folkbokforing/personnummer.4.3810a01c150939e893f18c29.html

0 commit comments

Comments
 (0)