Skip to content

Commit 95f0729

Browse files
committed
Add string utility function formatSwissSocialInsuranceNumber
1 parent e7ff99c commit 95f0729

File tree

5 files changed

+194
-146
lines changed

5 files changed

+194
-146
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `formatSwissSocialInsuranceNumber` string utility function
13+
1014
## [2.1.0] - 2025-09-03
1115

1216
### Added

src/lib/string.spec.ts

Lines changed: 1 addition & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,4 @@
1-
import {
2-
isNullOrEmpty,
3-
isNullOrWhitespace,
4-
capitalize,
5-
uncapitalize,
6-
truncate,
7-
isValidSwissIbanNumber,
8-
isValidSwissSocialSecurityNumber,
9-
} from "./string";
1+
import { isNullOrEmpty, isNullOrWhitespace, capitalize, uncapitalize, truncate } from "./string";
102

113
describe("string tests", () => {
124
test.each([
@@ -128,33 +120,4 @@ describe("string tests", () => {
128120
])("truncate without suffix parameter", (value, maxLength, expected) => {
129121
expect(truncate(value, maxLength)).toBe(expected);
130122
});
131-
132-
test.each([
133-
[null as unknown as string, false],
134-
[undefined as unknown as string, false],
135-
["CH9300762011623852957", true],
136-
["CH93 0076 2011 6238 5295 7", true],
137-
["CH930076 20116238 5295 7", false],
138-
["CH93-0076-2011-6238-5295-7", false],
139-
["CH93 0000 0000 0000 0000 1", false],
140-
["ch93 0076 2011 6238 5295 7", false],
141-
["DE93 0076 2011 6238 5295 7", false],
142-
])("check if this swiss IBAN is valid or not", (unformattedIbanNumber, expected) => {
143-
expect(isValidSwissIbanNumber(unformattedIbanNumber)).toBe(expected);
144-
});
145-
146-
test.each([
147-
[null as unknown as string, false],
148-
[undefined as unknown as string, false],
149-
["7561234567891", false],
150-
["7569217076985", true],
151-
["756.92170769.85", false],
152-
["756.9217.0769.85", true],
153-
["756..9217.0769.85", false],
154-
["756.1234.5678.91", false],
155-
["test756.9217.0769.85", false],
156-
["7.56..9217...0769.85", false],
157-
])("check if the social insurance number is valid or not", (ahvNumber, expected) => {
158-
expect(isValidSwissSocialSecurityNumber(ahvNumber)).toBe(expected);
159-
});
160123
});

src/lib/string.ts

Lines changed: 0 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -64,111 +64,3 @@ export function truncate(value: string | undefined, maxLength: number, suffix =
6464

6565
return `${value.slice(0, maxLength)}${suffix}`;
6666
}
67-
68-
/**
69-
* Checks if the provided string is a valid swiss IBAN number
70-
* @param ibanNumber The provided IBAN number to check
71-
* Must be in one of the following formats:
72-
* - "CHXX XXXX XXXX XXXX XXXX X" with whitespaces
73-
* - "CHXXXXXXXXXXXXXXXXXXX" without whitespaces
74-
* @returns The result of the IBAN number check
75-
*/
76-
export function isValidSwissIbanNumber(ibanNumber: string): boolean {
77-
// 1. Reject null, undefined or whitespace-only strings
78-
if (isNullOrWhitespace(ibanNumber)) {
79-
return false;
80-
}
81-
82-
// 2. Define allowed strict formats
83-
// - with spaces: "CHXX XXXX XXXX XXXX XXXX X"
84-
const compactIbanNumberWithWhiteSpaces = new RegExp(/^CH\d{2}(?: \d{4}){4} \d{1}$/);
85-
// - without spaces: "CHXXXXXXXXXXXXXXXXXXX"
86-
const compactIbanNumberWithoutWhiteSpaces = new RegExp(/^CH\d{19}$/);
87-
88-
// 3. Check if input matches one of the allowed formats
89-
if (!compactIbanNumberWithWhiteSpaces.test(ibanNumber) && !compactIbanNumberWithoutWhiteSpaces.test(ibanNumber)) {
90-
return false;
91-
}
92-
93-
// 4. Remove all spaces to get a compact IBAN string
94-
const compactIbanNumber = ibanNumber.replaceAll(" ", "");
95-
96-
// 5. Rearrange IBAN for checksum calculation
97-
// - move first 4 characters (CH + 2 check digits) to the end
98-
const rearrangedIban = compactIbanNumber.slice(4) + compactIbanNumber.slice(0, 4);
99-
100-
// 6. Replace letters with numbers (A=10, B=11, ..., Z=35)
101-
const numericStr = rearrangedIban.replaceAll(/[A-Z]/g, (ch) => (ch.codePointAt(0)! - 55).toString());
102-
103-
// 7. Perform modulo 97 calculation to validate IBAN
104-
let restOfCalculation = 0;
105-
for (const digit of numericStr) {
106-
restOfCalculation = (restOfCalculation * 10 + Number(digit)) % 97;
107-
}
108-
109-
// 8. IBAN is valid only if the remainder equals 1
110-
return restOfCalculation === 1;
111-
}
112-
113-
/**
114-
* Validation of social insurance number with checking the checksum
115-
* Validation according to https://www.sozialversicherungsnummer.ch/aufbau-neu.htm
116-
* @param socialInsuranceNumber The social insurance number to check
117-
* Must be in one of the following formats:
118-
* - "756.XXXX.XXXX.XX" with dots as separators
119-
* - "756XXXXXXXXXX" with digits only
120-
* @returns The result if the social insurance number is valid or not
121-
*/
122-
export function isValidSwissSocialSecurityNumber(socialInsuranceNumber: string): boolean {
123-
// 1. Check if input is empty or only whitespace
124-
if (isNullOrWhitespace(socialInsuranceNumber)) {
125-
return false;
126-
}
127-
128-
/**
129-
* 2. Check if input matches accepted formats:
130-
* - With dots: 756.XXXX.XXXX.XX
131-
* - Without dots: 756XXXXXXXXXX
132-
*/
133-
const socialInsuranceNumberWithDots = new RegExp(/^756\.\d{4}\.\d{4}\.\d{2}$/);
134-
const socialInsuranceNumberWithoutDots = new RegExp(/^756\d{10}$/);
135-
136-
if (!socialInsuranceNumberWithDots.test(socialInsuranceNumber) && !socialInsuranceNumberWithoutDots.test(socialInsuranceNumber)) {
137-
return false;
138-
}
139-
140-
// 3. Remove all dots → get a string of 13 digits
141-
const compactNumber = socialInsuranceNumber.replaceAll(".", "");
142-
143-
/**
144-
* 4. Separate digits for checksum calculation
145-
* - first 12 digits: used to calculate checksum
146-
* - last digit: actual check digit
147-
*/
148-
const digits = compactNumber.slice(0, -1);
149-
const reversedDigits = [...digits].reverse().join("");
150-
const reversedDigitsArray = [...reversedDigits];
151-
152-
/*
153-
* 5. Calculate weighted sum for checksum
154-
* - Even positions (after reversing) ×3
155-
* - Odd positions ×1
156-
*/
157-
let sum = 0;
158-
for (const [i, element] of reversedDigitsArray.entries()) {
159-
sum += i % 2 === 0 ? Number(element) * 3 : Number(element) * 1;
160-
}
161-
162-
/*
163-
* 6. Calculate expected check digit
164-
* - Check digit = value to reach next multiple of 10
165-
*/
166-
const checksum = (10 - (sum % 10)) % 10;
167-
const checknumber = Number.parseInt(compactNumber.slice(-1));
168-
169-
/*
170-
* 7. Compare calculated check digit with actual last digit
171-
* - If equal → valid AHV number
172-
*/
173-
return checksum === checknumber;
174-
}

src/lib/swissStandards.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { formatSwissSocialInsuranceNumber, isValidSwissIbanNumber, isValidSwissSocialSecurityNumber } from "./swissStandards";
2+
3+
describe("Swiss standards test", () => {
4+
test.each([
5+
[null as unknown as string, false],
6+
[undefined as unknown as string, false],
7+
["CH9300762011623852957", true],
8+
["CH93 0076 2011 6238 5295 7", true],
9+
["CH930076 20116238 5295 7", false],
10+
["CH93-0076-2011-6238-5295-7", false],
11+
["CH93 0000 0000 0000 0000 1", false],
12+
["ch93 0076 2011 6238 5295 7", false],
13+
["DE93 0076 2011 6238 5295 7", false],
14+
])("check if this swiss IBAN is valid or not", (unformattedIbanNumber, expected) => {
15+
expect(isValidSwissIbanNumber(unformattedIbanNumber)).toBe(expected);
16+
});
17+
18+
test.each([
19+
[null as unknown as string, false],
20+
[undefined as unknown as string, false],
21+
["7561234567891", false],
22+
["7569217076985", true],
23+
["756.92170769.85", false],
24+
["756.9217.0769.85", true],
25+
["756..9217.0769.85", false],
26+
["756.1234.5678.91", false],
27+
["test756.9217.0769.85", false],
28+
["7.56..9217...0769.85", false],
29+
])("check if the social insurance number is valid or not", (ahvNumber, expected) => {
30+
expect(isValidSwissSocialSecurityNumber(ahvNumber)).toBe(expected);
31+
});
32+
33+
test.each([
34+
[null as unknown as string, null, false],
35+
["7569217076985", "756.9217.0769.85", true],
36+
["7561234567891", "756.1234.5678.91", false],
37+
["75 61 23 456789 1", "756.1234.5678.91", false],
38+
["75 69 21 707698 5", "756.9217.0769.85", true],
39+
])(
40+
"Check if the social insurance number gets formatted correctly",
41+
(unformattedSocialInsuranceNumber, expectedSocialInsuranceNumber, expectedIsValid) => {
42+
const result = formatSwissSocialInsuranceNumber(unformattedSocialInsuranceNumber);
43+
44+
expect(result.socialInsuranceNumber).toBe(expectedSocialInsuranceNumber);
45+
expect(result.isValidSwissSocialInsuranceNumber).toBe(expectedIsValid);
46+
},
47+
);
48+
});

src/lib/swissStandards.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { isNullOrWhitespace } from "./string";
2+
3+
/**
4+
* Checks if the provided string is a valid swiss IBAN number
5+
* @param ibanNumber The provided IBAN number to check
6+
* Must be in one of the following formats:
7+
* - "CHXX XXXX XXXX XXXX XXXX X" with whitespaces
8+
* - "CHXXXXXXXXXXXXXXXXXXX" without whitespaces
9+
* @returns The result of the IBAN number check
10+
*/
11+
export function isValidSwissIbanNumber(ibanNumber: string): boolean {
12+
// 1. Reject null, undefined or whitespace-only strings
13+
if (isNullOrWhitespace(ibanNumber)) {
14+
return false;
15+
}
16+
17+
// 2. Define allowed strict formats
18+
// - with spaces: "CHXX XXXX XXXX XXXX XXXX X"
19+
const compactIbanNumberWithWhiteSpaces = new RegExp(/^CH\d{2}(?: \d{4}){4} \d{1}$/);
20+
// - without spaces: "CHXXXXXXXXXXXXXXXXXXX"
21+
const compactIbanNumberWithoutWhiteSpaces = new RegExp(/^CH\d{19}$/);
22+
23+
// 3. Check if input matches one of the allowed formats
24+
if (!compactIbanNumberWithWhiteSpaces.test(ibanNumber) && !compactIbanNumberWithoutWhiteSpaces.test(ibanNumber)) {
25+
return false;
26+
}
27+
28+
// 4. Remove all spaces to get a compact IBAN string
29+
const compactIbanNumber = ibanNumber.replaceAll(" ", "");
30+
31+
// 5. Rearrange IBAN for checksum calculation
32+
// - move first 4 characters (CH + 2 check digits) to the end
33+
const rearrangedIban = compactIbanNumber.slice(4) + compactIbanNumber.slice(0, 4);
34+
35+
// 6. Replace letters with numbers (A=10, B=11, ..., Z=35)
36+
const numericStr = rearrangedIban.replaceAll(/[A-Z]/g, (ch) => (ch.codePointAt(0)! - 55).toString());
37+
38+
// 7. Perform modulo 97 calculation to validate IBAN
39+
let restOfCalculation = 0;
40+
for (const digit of numericStr) {
41+
restOfCalculation = (restOfCalculation * 10 + Number(digit)) % 97;
42+
}
43+
44+
// 8. IBAN is valid only if the remainder equals 1
45+
return restOfCalculation === 1;
46+
}
47+
48+
/**
49+
* Validation of social insurance number with checking the checksum
50+
* Validation according to https://www.sozialversicherungsnummer.ch/aufbau-neu.htm
51+
* @param socialInsuranceNumber The social insurance number to check
52+
* Must be in one of the following formats:
53+
* - "756.XXXX.XXXX.XX" with dots as separators
54+
* - "756XXXXXXXXXX" with digits only
55+
* @returns The result if the social insurance number is valid or not
56+
*/
57+
export function isValidSwissSocialSecurityNumber(socialInsuranceNumber: string): boolean {
58+
// 1. Check if input is empty or only whitespace
59+
if (isNullOrWhitespace(socialInsuranceNumber)) {
60+
return false;
61+
}
62+
63+
/**
64+
* 2. Check if input matches accepted formats:
65+
* - With dots: 756.XXXX.XXXX.XX
66+
* - Without dots: 756XXXXXXXXXX
67+
*/
68+
const socialInsuranceNumberWithDots = new RegExp(/^756\.\d{4}\.\d{4}\.\d{2}$/);
69+
const socialInsuranceNumberWithoutDots = new RegExp(/^756\d{10}$/);
70+
71+
if (!socialInsuranceNumberWithDots.test(socialInsuranceNumber) && !socialInsuranceNumberWithoutDots.test(socialInsuranceNumber)) {
72+
return false;
73+
}
74+
75+
// 3. Remove all dots → get a string of 13 digits
76+
const compactNumber = socialInsuranceNumber.replaceAll(".", "");
77+
78+
/**
79+
* 4. Separate digits for checksum calculation
80+
* - first 12 digits: used to calculate checksum
81+
* - last digit: actual check digit
82+
*/
83+
const digits = compactNumber.slice(0, -1);
84+
const reversedDigits = [...digits].reverse().join("");
85+
const reversedDigitsArray = [...reversedDigits];
86+
87+
/*
88+
* 5. Calculate weighted sum for checksum
89+
* - Even positions (after reversing) ×3
90+
* - Odd positions ×1
91+
*/
92+
let sum = 0;
93+
for (const [i, element] of reversedDigitsArray.entries()) {
94+
sum += i % 2 === 0 ? Number(element) * 3 : Number(element) * 1;
95+
}
96+
97+
/*
98+
* 6. Calculate expected check digit
99+
* - Check digit = value to reach next multiple of 10
100+
*/
101+
const checksum = (10 - (sum % 10)) % 10;
102+
const checknumber = Number.parseInt(compactNumber.slice(-1));
103+
104+
/*
105+
* 7. Compare calculated check digit with actual last digit
106+
* - If equal → valid AHV number
107+
*/
108+
return checksum === checknumber;
109+
}
110+
111+
/**
112+
* Formats a unformatted Swiss social insurance number to the standard format "756.XXXX.XXXX.XX"
113+
* @param unformattedSocialInsuranceNumber the unformatted Swiss social insurance number to format
114+
* @returns a object containing the formatted Swiss social insurance number and a boolean indicating if the number was valid or not
115+
*/
116+
export function formatSwissSocialInsuranceNumber(unformattedSocialInsuranceNumber: string): {
117+
/**
118+
* The formatted Swiss social insurance number or the original input if the Swiss social insurance number was invalid
119+
*/
120+
socialInsuranceNumber: string;
121+
/**
122+
* The result if the social insurance number is a valid Swiss social insurance number or not
123+
*/
124+
isValidSwissSocialInsuranceNumber: boolean;
125+
} {
126+
// 1. Check if the unformatted Swiss social insurance number is empty or only a whitespace
127+
if (isNullOrWhitespace(unformattedSocialInsuranceNumber)) {
128+
return { socialInsuranceNumber: unformattedSocialInsuranceNumber, isValidSwissSocialInsuranceNumber: false };
129+
}
130+
131+
// 2. Remove all non-digit characters, then format as Swiss social insurance number (XXX.XXXX.XXXX.XX)
132+
const cleaned = unformattedSocialInsuranceNumber.replaceAll(/\D+/g, "");
133+
const formattedSwissSocialInsuranceNumber = cleaned.replaceAll(/(\d{3})(\d{4})(\d{4})(\d{2})/g, "$1.$2.$3.$4");
134+
135+
// 3. If the Swiss social insurance number is valid return the formatted number with the true status
136+
if (isValidSwissSocialSecurityNumber(formattedSwissSocialInsuranceNumber)) {
137+
return { socialInsuranceNumber: formattedSwissSocialInsuranceNumber, isValidSwissSocialInsuranceNumber: true };
138+
}
139+
// 4. If the Swiss social insurance number is not valid return the formatted number with the false status
140+
return { socialInsuranceNumber: formattedSwissSocialInsuranceNumber, isValidSwissSocialInsuranceNumber: false };
141+
}

0 commit comments

Comments
 (0)