Skip to content

Commit 54f3a07

Browse files
committed
Add string utility function formatSwissIbanNumber
1 parent e7ff99c commit 54f3a07

5 files changed

Lines changed: 203 additions & 146 deletions

File tree

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+
- `formatSwissIbanNumber` 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: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { isValidSwissIbanNumber, isValidSwissSocialSecurityNumber, formatSwissIbanNumber } 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+
["c93 0076 2011 6238 5295 7", false],
14+
["DE93 0076 2011 6238 5295 7", false],
15+
])("check if this swiss IBAN is valid or not", (unformattedIbanNumber, expected) => {
16+
expect(isValidSwissIbanNumber(unformattedIbanNumber)).toBe(expected);
17+
});
18+
19+
test.each([
20+
[null as unknown as string, false],
21+
[undefined as unknown as string, false],
22+
["7561234567891", false],
23+
["7569217076985", true],
24+
["756.92170769.85", false],
25+
["756.9217.0769.85", true],
26+
["756..9217.0769.85", false],
27+
["756.1234.5678.91", false],
28+
["test756.9217.0769.85", false],
29+
["7.56..9217...0769.85", false],
30+
])("check if the social insurance number is valid or not", (ahvNumber, expected) => {
31+
expect(isValidSwissSocialSecurityNumber(ahvNumber)).toBe(expected);
32+
});
33+
34+
test.each([
35+
[null as unknown as string, null, false],
36+
[undefined as unknown as string, undefined, false],
37+
["CH9300762011623852957", "CH93 0076 2011 6238 5295 7", true],
38+
["ch9300762011623852957", "CH93 0076 2011 6238 5295 7", true],
39+
["ch9301234567891011127", "CH93 0123 4567 8910 1112 7", false],
40+
["DE93 00 76 2011 62385295 7", "DE93 00 76 2011 62385295 7", false],
41+
["D 93 00 76 2011 62385295 7", "D 93 00 76 2011 62385295 7", false],
42+
["Ch 93 0076 20 1 162385 295 7", "CH93 0076 2011 6238 5295 7", true],
43+
])("Check if the IBAN number gets formatted correctly", (unformattedIbanNumber, expectedIbanNumber, expectedIsValid) => {
44+
const result = formatSwissIbanNumber(unformattedIbanNumber);
45+
46+
expect(result.ibanNumber).toBe(expectedIbanNumber);
47+
expect(result.isValidSwissIbanNumber).toBe(expectedIsValid);
48+
});
49+
});

src/lib/swissStandards.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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 Swiss IBAN number to the standard of "CHXX XXXX XXXX XXXX XXXX X"
113+
* @param unformattedIbanNumber the IBAN number to format
114+
* @returns a object containing the formatted IBAN number and a boolean indicating if the IBAN number was valid or not
115+
*/
116+
export function formatSwissIbanNumber(unformattedIbanNumber: string): {
117+
/**
118+
* The formatted IBAN number or the original input if the unformatted IBAN number was invalid
119+
*/
120+
ibanNumber: string;
121+
/**
122+
* The result if the IBAN number is valid or not
123+
*/
124+
isValidSwissIbanNumber: boolean;
125+
} {
126+
// 1. Check if the unformatted IBAN number is empty or only a whitespace
127+
if (isNullOrWhitespace(unformattedIbanNumber)) {
128+
return { ibanNumber: unformattedIbanNumber, isValidSwissIbanNumber: false };
129+
}
130+
131+
// 2. Remove all non-alphanumeric characters and convert letters to uppercase
132+
const cleanedIbanNumber = unformattedIbanNumber.replaceAll(/[^A-Z0-9]/gi, "").toUpperCase();
133+
134+
// 3. Check if it is possible to format a new IBAN number
135+
if (!/^CH\d{19}$/.test(cleanedIbanNumber)) {
136+
return { ibanNumber: unformattedIbanNumber, isValidSwissIbanNumber: false };
137+
}
138+
139+
// 4. Format the cleaned IBAN number into groups of 4 characters separated by spaces
140+
const formattedIbanNumber = cleanedIbanNumber.replaceAll(/(.{4})/g, "$1 ").trim();
141+
142+
// 5. If the Swiss IBAN number is valid return the formatted IBAN number with the true status
143+
if (isValidSwissIbanNumber(formattedIbanNumber)) {
144+
return { ibanNumber: formattedIbanNumber, isValidSwissIbanNumber: true };
145+
}
146+
147+
// 6. If the Swiss IBAN number is not valid return the formatted IBAN number with the false status
148+
return { ibanNumber: formattedIbanNumber, isValidSwissIbanNumber: false };
149+
}

0 commit comments

Comments
 (0)