diff --git a/.changeset/twelve-chairs-drop.md b/.changeset/twelve-chairs-drop.md new file mode 100644 index 000000000..de9e807d9 --- /dev/null +++ b/.changeset/twelve-chairs-drop.md @@ -0,0 +1,5 @@ +--- +'graphql-scalars': minor +--- + +Add new Swedish Personal Number scalar diff --git a/src/index.ts b/src/index.ts index 0f9ae6c97..77806dac2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,6 +57,7 @@ import { GraphQLRoutingNumber, GraphQLSafeInt, GraphQLSemVer, + GraphQLSESSN, GraphQLTime, GraphQLTimestamp, GraphQLTimeZone, @@ -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, @@ -203,6 +205,7 @@ export { GraphQLAccountNumber as AccountNumberResolver, GraphQLCuid as CuidResolver, GraphQLSemVer as SemVerResolver, + GraphQLSESSN as SESSNResolver, GraphQLDeweyDecimal as GraphQLDeweyDecimalResolver, GraphQLIPCPatent as GraphQLIPCPatentResolver, }; @@ -271,6 +274,7 @@ export const resolvers: Record = { AccountNumber: GraphQLAccountNumber, Cuid: GraphQLCuid, SemVer: GraphQLSemVer, + SESSN: GraphQLSESSN, DeweyDecimal: GraphQLDeweyDecimal, LCCSubclass: GraphQLLCCSubclass, IPCPatent: GraphQLIPCPatent, @@ -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, @@ -417,6 +422,7 @@ export { GraphQLAccountNumber, GraphQLCuid, GraphQLSemVer, + GraphQLSESSN, GraphQLDeweyDecimal, GraphQLLCCSubclass, GraphQLIPCPatent, diff --git a/src/mocks.ts b/src/mocks.ts index 1b24236de..c898ef31d 100644 --- a/src/mocks.ts +++ b/src/mocks.ts @@ -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'; diff --git a/src/scalars/index.ts b/src/scalars/index.ts index b69274934..486e35b92 100644 --- a/src/scalars/index.ts +++ b/src/scalars/index.ts @@ -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'; diff --git a/src/scalars/ssn/SE.ts b/src/scalars/ssn/SE.ts new file mode 100644 index 000000000..3fbf6cd1a --- /dev/null +++ b/src/scalars/ssn/SE.ts @@ -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, + })), + }, + }, +}); diff --git a/src/typeDefs.ts b/src/typeDefs.ts index 0fcc6dd44..103e66495 100644 --- a/src/typeDefs.ts +++ b/src/typeDefs.ts @@ -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'; @@ -133,6 +134,7 @@ export const typeDefs = [ AccountNumber, Cuid, SemVer, + SESSN, DeweyDecimal, LCCSubclass, IPCPatent, diff --git a/tests/ssn/SE.test.ts b/tests/ssn/SE.test.ts new file mode 100644 index 000000000..06e187c0a --- /dev/null +++ b/tests/ssn/SE.test.ts @@ -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/, + ); + }); + }); + }); +}); diff --git a/website/src/pages/docs/scalars/ssn.mdx b/website/src/pages/docs/scalars/ssn.mdx new file mode 100644 index 000000000..0924e40ba --- /dev/null +++ b/website/src/pages/docs/scalars/ssn.mdx @@ -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