diff --git a/.changeset/famous-humans-tell.md b/.changeset/famous-humans-tell.md new file mode 100644 index 0000000..1a19f0e --- /dev/null +++ b/.changeset/famous-humans-tell.md @@ -0,0 +1,5 @@ +--- +"@obosbbl/validation": minor +--- + +initial release diff --git a/README.md b/README.md index ec19974..d0eab94 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ This a monorepo of OBOS' open source frontend modules. ## Packages * [format](./packages/format) +* [validation](./packages/validation) ## Contributing diff --git a/packages/validation/README.md b/packages/validation/README.md new file mode 100644 index 0000000..af96695 --- /dev/null +++ b/packages/validation/README.md @@ -0,0 +1,93 @@ +# @obosbbl/validation + +[![NPM Version](https://img.shields.io/npm/v/%40obosbbl%2Fvalidation)](https://www.npmjs.com/package/@obosbbl/validation) + + +A collection of validation methods for both πŸ‡³πŸ‡΄ and πŸ‡ΈπŸ‡ͺ with zero dependencies. + +## Install + +```sh +# npm +npm install @obosbbl/validation + +# pnpm +pnpm add @obosbbl/validation +``` + +## Usage + +The package has two entrypoints, one for `no` and one for `se`. That allows you to import for only the locale you need. + + +```js +// πŸ‡³πŸ‡΄ example +import { validateOrganizationNumber } from '@obosbbl/validation/no'; +validateOrganizationNumber('937052766') // => true + +validateOrganizationNumber('000') // => false + +// πŸ‡ΈπŸ‡ͺ example +import { validateOrganizationNumer } from '@obosbbl/validation/se'; +validateOrganizationNumber('5592221054') // => true + +validateOrganizationNumber('000') // => false +``` + +## Strictness and formatting characters + +The methods are "strict" by default, meaning no formatting characters in the input is allowed. +This is preferrable, for instance when doing server-side validation, where the input is often expected to be a "clean" value. + +If you want to allow formatting characters in the input, you can pass `allowFormatting: true` in the options object to the method. +Note that this currently allows any formatting characters, not just the just the "expected" ones for the input type. + + +```js +import { validateOrganizationNumber } from '@obosbbl/validation/no'; + +validateOrganizationNumber('937052766') // true +// formatting characters disallowed by default +validateOrganizationNumber('937 052 766') // false; +// allow formatting characters +validateOrganizationNumber('937 052 766', { allowFormatting: true }) // true; +``` + +## Methods + +* validatePostalCode +* validatePhoneNumber + * supports mobileOnly option +* validateOrganizationNumber + * Check digit verification is currently only implemented for Norwegian organization numbers. For Swedish organiation numbers, we only check the length of the input. PRs are welcome to fix this. + +## Example usage with Zod + +```js +import { z } from 'zod'; +import { validatePhoneNumber } from '@obosbbl/validation/no'; + +const mobileOnlySchema = z.object({ + name: z.string(), + phoneNumber: z + .string() + .refine( + (val) => validatePhoneNumber(val, { mobileOnly: true }), + 'Telefonnummeret er ikke et gyldig mobilnummer', + ), +}); + +const validData = { + name: 'Kari Nordmann', + phoneNumber: '92345678', +}; + +mobileOnlySchema.parse(validData); // => { name: 'Kari Nordmann', phoneNumber: '92345678' } + +const invalidData = { + name: 'Ola Nordmann', + phoneNumber: '22865500', +} + +mobileOnlySchema.parse(invalidData); // => throws ZodError +``` diff --git a/packages/validation/package.json b/packages/validation/package.json new file mode 100644 index 0000000..4416cd9 --- /dev/null +++ b/packages/validation/package.json @@ -0,0 +1,27 @@ +{ + "name": "@obosbbl/validation", + "version": "0.0.0", + "description": "A collection of validation methods for OBOS", + "repository": { + "url": "https://github.com/code-obos/public-frontend-modules" + }, + "license": "MIT", + "sideEffects": false, + "type": "module", + "exports": { + "./no": { + "types": "./dist/no.d.mts", + "default": "./dist/no.mjs" + }, + "./se": { + "types": "./dist/se.d.mts", + "default": "./dist/se.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "bunchee" + } +} diff --git a/packages/validation/src/no.ts b/packages/validation/src/no.ts new file mode 100644 index 0000000..ccf200c --- /dev/null +++ b/packages/validation/src/no.ts @@ -0,0 +1,86 @@ +import type { ValidatorOptions } from './types'; +import { mod11, stripFormatting } from './utils'; + +type PostalCodeOptions = ValidatorOptions; + +/** + * Validates that the input value is a Norwegian postal (zip) code. + * @example + * ``` + * validatePostalCode('0000') // => true + * ``` + */ +export function validatePostalCode( + value: string, + options: PostalCodeOptions = {}, +): boolean { + if (options.allowFormatting) { + // biome-ignore lint/style/noParameterAssign: + value = stripFormatting(value); + } + + return /^\d{4}$/.test(value); +} + +type PhoneNumberOptions = ValidatorOptions & { + /** + * Whether it should be a mobile number + * @default false + */ + mobileOnly?: boolean; +}; + +/** + * Validates that the input value is a Norwegian phone number. + * + * Supports mobile only validation. + * @example + * ``` + * validatePhoneNumber('00000000') // => true + * validatePhoneNumber('90000000', { mobileOnly: true }) // => true + * ``` + */ +export function validatePhoneNumber( + value: string, + options: PhoneNumberOptions = {}, +): boolean { + if (options.allowFormatting) { + // biome-ignore lint/style/noParameterAssign: + value = stripFormatting(value); + } + + const isPhoneNumber = /^\d{8}$/.test(value); + + if (options.mobileOnly) { + // Norwegian mobile phone numbers start with 4 or 9 + // See https://nkom.no/telefoni-og-telefonnummer/telefonnummer-og-den-norske-nummerplan/alle-nummerserier-for-norske-telefonnumre + return isPhoneNumber && ['4', '9'].includes(value.charAt(0)); + } + + return isPhoneNumber; +} + +type OrganizationNumberOptions = ValidatorOptions; + +/** + * Validates that the input value is a {@link https://www.brreg.no/om-oss/registrene-vare/om-enhetsregisteret/organisasjonsnummeret/ Norwegian organization number}. + * @example + * ``` + * validateOrganizationNumber('000000000') // => true + * ``` + */ +export function validateOrganizationNumber( + value: string, + options: PhoneNumberOptions = {}, +): boolean { + if (options.allowFormatting) { + // biome-ignore lint/style/noParameterAssign: + value = stripFormatting(value); + } + + /** References: + * https://www.brreg.no/om-oss/registrene-vare/om-enhetsregisteret/organisasjonsnummeret/ + * https://no.wikipedia.org/wiki/Organisasjonsnummer + */ + return mod11(value, [3, 2, 7, 6, 5, 4, 3, 2]); +} diff --git a/packages/validation/src/se.ts b/packages/validation/src/se.ts new file mode 100644 index 0000000..fdf41d0 --- /dev/null +++ b/packages/validation/src/se.ts @@ -0,0 +1,85 @@ +import type { ValidatorOptions } from './types'; +import { stripFormatting } from './utils'; + +type PostalCodeOptions = ValidatorOptions; + +/** + * Validates that the input value is a Swedish postal (zip) code. + * @example + * ``` + * validatePostalCode('00000') // => true + * ``` + */ +export function validatePostalCode( + value: string, + options: PostalCodeOptions = {}, +): boolean { + if (options.allowFormatting) { + // biome-ignore lint/style/noParameterAssign: + value = stripFormatting(value); + } + + return /^\d{5}$/.test(value); +} + +type PhoneNumberOptions = ValidatorOptions & { + /** + * Whether it should be a mobile number + * @default false + */ + mobileOnly?: boolean; +}; + +/** + * Validates that the input value is a Swedish phone number. + * + * Supports mobile only validation. + * @example + * ``` + * validatePhoneNumber('00000000') // => true + * validatePhoneNumber('000000000') // => true + * validatePhoneNumber('0000000000') // => true + * validatePhoneNumber('0700000000', { mobileOnly: true }) // => true + * ``` + */ +export function validatePhoneNumber( + value: string, + options: PhoneNumberOptions = {}, +): boolean { + if (options.allowFormatting) { + // biome-ignore lint/style/noParameterAssign: + value = stripFormatting(value); + } + + if (options.mobileOnly) { + // Mobile numbers start with 07, followed by 0/2/3/6/9 and is 10 digits long + const isMobileNumber = /^07[02369]\d{7}$/.test(value); + return isMobileNumber; + } + + const isPhoneNumber = /^0\d{7,9}$/.test(value); + + return isPhoneNumber; +} + +type OrganizationNumberOptions = ValidatorOptions; + +/** + * Validates that the input value is a {@link https://www.skatteverket.se/foretagochorganisationer/foretagare/startaochregistrera/organisationsnummer.4.361dc8c15312eff6fd235d1.html Swedish organization number}. + * @example + * ``` + * validateOrganizationNumber('000000000') // => true + * ``` + */ +export function validateOrganizationNumber( + value: string, + options: OrganizationNumberOptions = {}, +): boolean { + // TODO: Implement checksum validation. For now it only checks the number of digits. + if (options.allowFormatting) { + // biome-ignore lint/style/noParameterAssign: + value = stripFormatting(value); + } + + return /^\d{10}$/.test(value); +} diff --git a/packages/validation/src/types.ts b/packages/validation/src/types.ts new file mode 100644 index 0000000..19aa395 --- /dev/null +++ b/packages/validation/src/types.ts @@ -0,0 +1,7 @@ +export type ValidatorOptions = { + /** + * Allow formatting characters + * @default false + */ + allowFormatting?: boolean; +}; diff --git a/packages/validation/src/utils.ts b/packages/validation/src/utils.ts new file mode 100644 index 0000000..a6296f4 --- /dev/null +++ b/packages/validation/src/utils.ts @@ -0,0 +1,28 @@ +/** Strip all formatting, leaving only numbers and letters */ +export function stripFormatting(value: string): string { + return value.replace(/[^a-zA-Z0-9]/g, ''); +} + +/** + * Used to validate Norwegian bank account numbers, organization numbers and national identity numbers. + * See https://no.wikipedia.org/wiki/MOD11 + */ +export function mod11(value: string, weights: number[]): boolean { + // Since the control digit is a single value, the lengths should be equal + if (weights.length + 1 !== value.length) { + return false; + } + + let sum = 0; + weights.forEach((weight, index) => { + sum += weight * Number(value[index]); + }); + + let controlNumber = 11 - (sum % 11); + + if (controlNumber === 11) { + controlNumber = 0; + } + + return controlNumber === Number(value[value.length - 1]); +} diff --git a/packages/validation/src/validation.test.ts b/packages/validation/src/validation.test.ts new file mode 100644 index 0000000..325512d --- /dev/null +++ b/packages/validation/src/validation.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'vitest'; +import * as no from './no'; +import * as se from './se'; + +describe('no', () => { + test.each([ + ['0179', true], + ['01790', false], + ['not a number', false], + ])('validatePostalCode(%s) -> %s', (input, expected) => { + expect(no.validatePostalCode(input)).toBe(expected); + }); + + test.each([ + ['22865500', true, undefined], + ['90000000', true, undefined], + ['40000000', true, undefined], + ['22 86 55 00', false, undefined], + ['000', false, undefined], + ['000000000', false, undefined], + + // formatting + ['22 86 55 00', false, { allowFormatting: false }], + ['22 86 55 00', true, { allowFormatting: true }], + + // mobile only + ['22865500', false, { mobileOnly: true }], + ['90000000', true, { mobileOnly: true }], + ['40000000', true, { mobileOnly: true }], + ])('validatePhoneNumber(%s) -> %s', (input, expected, options) => { + expect(no.validatePhoneNumber(input, options)).toBe(expected); + }); + + test.each([ + ['937052766', true, undefined], + ['937 052 766', false, undefined], + ['435256151', false, undefined], + ['not a number', false, undefined], + ['A37 052 766', false, undefined], + + // formatting + ['937 052 766', false, { allowFormatting: false }], + ['937 052 766', true, { allowFormatting: true }], + ])('validateOrganizationNumber(%s) -> %s', (input, expected, options) => { + expect(no.validateOrganizationNumber(input, options)).toBe(expected); + }); +}); + +describe('se', () => { + test.each([ + ['00000', true, undefined], + ['000 00', false, undefined], + ['not a number', false, undefined], + + // formatting + ['000 00', false, { allowFormatting: false }], + ['000 00', true, { allowFormatting: true }], + ])('validatePostalCode(%s) -> %s', (input, expected, options) => { + expect(se.validatePostalCode(input, options)).toBe(expected); + }); + + test.each([ + ['08123456', true, undefined], + ['0812345', false, undefined], + ['031123456', true, undefined], + ['030312345678', false, undefined], + + // formatting + ['031-123 45', true, { allowFormatting: true }], + ['031-123 45', false, { allowFormatting: false }], + + // mobile only + // test all valid area codes (070, 072, 073, 076, 079) + ['0701234567', true, { mobileOnly: true }], + ['0711234567', false, { mobileOnly: true }], + ['0721234567', true, { mobileOnly: true }], + ['0731234567', true, { mobileOnly: true }], + ['0741234567', false, { mobileOnly: true }], + ['0751234567', false, { mobileOnly: true }], + ['0761234567', true, { mobileOnly: true }], + ['0771234567', false, { mobileOnly: true }], + ['0781234567', false, { mobileOnly: true }], + ['0791234567', true, { mobileOnly: true }], + // landline + ['031123456', false, { mobileOnly: true }], + // too long + ['07012345678', false, { mobileOnly: true }], + ])('validatePhoneNumber(%s) -> %s', (input, expected, options) => { + expect(se.validatePhoneNumber(input, options)).toBe(expected); + }); + + test.each([ + ['5592221054', true, undefined], + ['559222-1054', false, undefined], + ['559222105', false, undefined], + ['55922210546', false, undefined], + + // formatting + ['559222-1054', false, { allowFormatting: false }], + ['559222-1054', true, { allowFormatting: true }], + ])('validateOrganizationNumber(%s) -> %s', (input, expected, options) => { + expect(se.validateOrganizationNumber(input, options)).toBe(expected); + }); +}); diff --git a/packages/validation/tsconfig.json b/packages/validation/tsconfig.json new file mode 100644 index 0000000..dcbf63f --- /dev/null +++ b/packages/validation/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src/*"], + "compilerOptions": { + "outDir": "dist" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08bd49c..fa9b314 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,8 @@ importers: packages/format: {} + packages/validation: {} + packages: '@andrewbranch/untar.js@1.0.3':