diff --git a/CHANGELOG.md b/CHANGELOG.md index c15e1c6..ec8feb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `isValidSwissSocialSecurityNumber` string utility function + ## [2.0.0] - 2025-07-29 ### Added diff --git a/src/lib/string.spec.ts b/src/lib/string.spec.ts index 94fc71e..f9049f8 100644 --- a/src/lib/string.spec.ts +++ b/src/lib/string.spec.ts @@ -1,4 +1,4 @@ -import { isNullOrEmpty, isNullOrWhitespace, capitalize, uncapitalize, truncate } from "./string"; +import { isNullOrEmpty, isNullOrWhitespace, capitalize, uncapitalize, truncate, isValidSwissSocialSecurityNumber } from "./string"; describe("string tests", () => { test.each([ @@ -120,4 +120,19 @@ describe("string tests", () => { ])("truncate without suffix parameter", (value, maxLength, expected) => { expect(truncate(value, maxLength)).toBe(expected); }); + + test.each([ + [null as unknown as string, false], + [undefined as unknown as string, false], + ["7561234567891", false], + ["7569217076985", true], + ["756.92170769.85", false], + ["756.9217.0769.85", true], + ["756..9217.0769.85", false], + ["756.1234.5678.91", false], + ["test756.9217.0769.85", false], + ["7.56..9217...0769.85", false], + ])("check if the social insurance number is valid or not", (ahvNumber, expected) => { + expect(isValidSwissSocialSecurityNumber(ahvNumber)).toBe(expected); + }); }); diff --git a/src/lib/string.ts b/src/lib/string.ts index c0666b3..c14768c 100644 --- a/src/lib/string.ts +++ b/src/lib/string.ts @@ -64,3 +64,66 @@ export function truncate(value: string | undefined, maxLength: number, suffix = return `${value.slice(0, maxLength)}${suffix}`; } + +/** + * Validation of social insurance number with checking the checksum + * Validation according to https://www.sozialversicherungsnummer.ch/aufbau-neu.htm + * @param socialInsuranceNumber The social insurance number to check + * Must be in one of the following formats: + * - "756.XXXX.XXXX.XX" with dots as separators + * - "756XXXXXXXXXX" with digits only + * @returns The result if the social insurance number is valid or not + */ +export function isValidSwissSocialSecurityNumber(socialInsuranceNumber: string): boolean { + // 1. Check if input is empty or only whitespace + if (isNullOrWhitespace(socialInsuranceNumber)) { + return false; + } + + /** + * 2. Check if input matches accepted formats: + * - With dots: 756.XXXX.XXXX.XX + * - Without dots: 756XXXXXXXXXX + */ + const socialInsuranceNumberWithDots = new RegExp(/^756\.\d{4}\.\d{4}\.\d{2}$/); + const socialInsuranceNumberWithoutDots = new RegExp(/^756\d{10}$/); + + if (!socialInsuranceNumberWithDots.test(socialInsuranceNumber) && !socialInsuranceNumberWithoutDots.test(socialInsuranceNumber)) { + return false; + } + + // 3. Remove all dots → get a string of 13 digits + const compactNumber = socialInsuranceNumber.replaceAll(".", ""); + + /** + * 4. Separate digits for checksum calculation + * - first 12 digits: used to calculate checksum + * - last digit: actual check digit + */ + const digits = compactNumber.slice(0, -1); + const reversedDigits = [...digits].reverse().join(""); + const reversedDigitsArray = [...reversedDigits]; + + /* + * 5. Calculate weighted sum for checksum + * - Even positions (after reversing) ×3 + * - Odd positions ×1 + */ + let sum = 0; + for (const [i, element] of reversedDigitsArray.entries()) { + sum += i % 2 === 0 ? Number(element) * 3 : Number(element) * 1; + } + + /* + * 6. Calculate expected check digit + * - Check digit = value to reach next multiple of 10 + */ + const checksum = (10 - (sum % 10)) % 10; + const checknumber = Number.parseInt(compactNumber.slice(-1)); + + /* + * 7. Compare calculated check digit with actual last digit + * - If equal → valid AHV number + */ + return checksum === checknumber; +}