Skip to content

Commit 6794b6a

Browse files
committed
feat: add more formatter, parser and validator
1 parent 337fa44 commit 6794b6a

4 files changed

Lines changed: 385 additions & 1 deletion

File tree

src/format/number.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
formatCompact,
55
formatCurrency,
66
formatCHF,
7+
parseCHF,
78
formatPercent,
89
formatBytes,
910
} from './number';
@@ -54,6 +55,45 @@ describe('formatCHF', () => {
5455
});
5556

5657

58+
describe('parseCHF', () => {
59+
it('parses apostrophe-thousands with CHF suffix', () => {
60+
expect(parseCHF("1'234.50 CHF")).toBe(1234.5);
61+
});
62+
63+
it('parses a plain amount with no separators', () => {
64+
expect(parseCHF('50')).toBe(50);
65+
});
66+
67+
it('parses the whole-franc marker ".-"', () => {
68+
expect(parseCHF('50.-')).toBe(50);
69+
expect(parseCHF("1'234.-")).toBe(1234);
70+
});
71+
72+
it('parses "Fr." and "SFr." labels', () => {
73+
expect(parseCHF('Fr. 50.-')).toBe(50);
74+
expect(parseCHF('SFr. 1\'234.50')).toBe(1234.5);
75+
expect(parseCHF('sfr 50.-')).toBe(50);
76+
});
77+
78+
it('parses a space thousands separator', () => {
79+
expect(parseCHF('1 234.50')).toBe(1234.5);
80+
});
81+
82+
it('parses a decimal comma', () => {
83+
expect(parseCHF('1234,50')).toBe(1234.5);
84+
expect(parseCHF("1'234,50")).toBe(1234.5);
85+
});
86+
87+
it('handles negative amounts', () => {
88+
expect(parseCHF("-1'234.50")).toBe(-1234.5);
89+
});
90+
91+
it('returns null for unparseable input', () => {
92+
expect(parseCHF('not a number')).toBeNull();
93+
expect(parseCHF('')).toBeNull();
94+
});
95+
});
96+
5797
describe('formatPercent', () => {
5898
it('formats a decimal as a percentage (fr-CH)', () => {
5999
expect(formatPercent(0.456)).toBe('45,6%');

src/format/number.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,38 @@ export function formatCHF(amount: number): string {
3434
return `${intFormatted}.${dec} CHF`;
3535
}
3636

37+
/**
38+
* Parses a Swiss-formatted amount into a number. Accepts apostrophe or space thousands
39+
* separators, "." or "," as the decimal separator, a trailing ".-" whole-franc marker
40+
* (e.g. "50.-" -> 50), and an optional "CHF" / "Fr." / "SFr." label. Returns null if unparseable.
41+
*/
42+
export function parseCHF(input: string): number | null {
43+
let s = input.trim();
44+
if (!s) return null;
45+
46+
s = s
47+
.replace(/^(CHF|SFr\.?|Fr\.?)\s*/i, '')
48+
.replace(/\s*(CHF|SFr\.?|Fr\.?)$/i, '')
49+
.trim();
50+
s = s.replace(/\.-\s*$/, '.00');
51+
s = s.replace(/['\s]/g, '');
52+
53+
const lastComma = s.lastIndexOf(',');
54+
const lastDot = s.lastIndexOf('.');
55+
if (lastComma !== -1 && lastDot !== -1) {
56+
s = lastComma > lastDot
57+
? s.replace(/\./g, '').replace(',', '.')
58+
: s.replace(/,/g, '');
59+
} else if (lastComma !== -1) {
60+
const [whole, frac] = s.split(',');
61+
s = frac !== undefined && frac.length <= 2 ? `${whole}.${frac}` : s.replace(/,/g, '');
62+
}
63+
64+
if (!/^-?\d+(\.\d+)?$/.test(s)) return null;
65+
const value = parseFloat(s);
66+
return Number.isFinite(value) ? value : null;
67+
}
68+
3769
/** Format a number as a percentage. e.g. 0.456 -> "45,6%" (fr-CH). `fractionDigits` is the maximum decimal places (trailing zeros stripped). */
3870
export function formatPercent(
3971
value: number,

src/format/swiss.test.ts

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
import { describe, it, expect } from 'vitest';
2-
import { formatPhone, formatIBAN } from './swiss';
2+
import {
3+
formatPhone,
4+
isValidPhone,
5+
formatIBAN,
6+
isValidIBAN,
7+
formatAHV,
8+
isValidAHV,
9+
formatQRReference,
10+
isValidQRReference,
11+
formatUID,
12+
isValidUID,
13+
isValidSwissPostalCode,
14+
} from './swiss';
315

416
describe('formatPhone', () => {
517
it('formats a number with leading 0', () => {
@@ -27,6 +39,33 @@ describe('formatPhone', () => {
2739
});
2840
});
2941

42+
describe('isValidPhone', () => {
43+
it('accepts a valid mobile number in any supported form', () => {
44+
expect(isValidPhone('0791234567')).toBe(true);
45+
expect(isValidPhone('+41791234567')).toBe(true);
46+
expect(isValidPhone('0041 79 123 45 67')).toBe(true);
47+
});
48+
49+
it('accepts a valid landline number', () => {
50+
expect(isValidPhone('0441234567')).toBe(true);
51+
});
52+
53+
it('rejects a national number starting with 0 or 1', () => {
54+
expect(isValidPhone('0123456789')).toBe(false);
55+
expect(isValidPhone('0012345678')).toBe(false);
56+
});
57+
58+
it('rejects the wrong length', () => {
59+
expect(isValidPhone('079123456')).toBe(false);
60+
expect(isValidPhone('07912345678')).toBe(false);
61+
});
62+
63+
it('rejects unparseable input', () => {
64+
expect(isValidPhone('not-a-phone')).toBe(false);
65+
expect(isValidPhone('')).toBe(false);
66+
});
67+
});
68+
3069
describe('formatIBAN', () => {
3170
it('groups a raw IBAN into blocks of 4', () => {
3271
expect(formatIBAN('CH5604835012345678009')).toBe('CH56 0483 5012 3456 7800 9');
@@ -44,3 +83,144 @@ describe('formatIBAN', () => {
4483
expect(formatIBAN('')).toBe('');
4584
});
4685
});
86+
87+
describe('isValidIBAN', () => {
88+
it('accepts a valid Swiss IBAN', () => {
89+
expect(isValidIBAN('CH5604835012345678009')).toBe(true);
90+
});
91+
92+
it('accepts a spaced IBAN', () => {
93+
expect(isValidIBAN('CH56 0483 5012 3456 7800 9')).toBe(true);
94+
});
95+
96+
it('accepts lowercase input', () => {
97+
expect(isValidIBAN('ch5604835012345678009')).toBe(true);
98+
});
99+
100+
it('accepts valid IBANs from other countries', () => {
101+
expect(isValidIBAN('GB29 NWBK 6016 1331 9268 19')).toBe(true);
102+
expect(isValidIBAN('DE89 3704 0044 0532 0130 00')).toBe(true);
103+
});
104+
105+
it('rejects an IBAN with a bad check digit', () => {
106+
expect(isValidIBAN('CH5604835012345678008')).toBe(false);
107+
});
108+
109+
it('rejects malformed input', () => {
110+
expect(isValidIBAN('not an iban')).toBe(false);
111+
expect(isValidIBAN('')).toBe(false);
112+
expect(isValidIBAN('CH12')).toBe(false);
113+
});
114+
});
115+
116+
describe('formatAHV', () => {
117+
it('groups a 13-digit AHV number', () => {
118+
expect(formatAHV('7569217076985')).toBe('756.9217.0769.85');
119+
});
120+
121+
it('strips existing separators before formatting', () => {
122+
expect(formatAHV('756.9217.0769.85')).toBe('756.9217.0769.85');
123+
});
124+
125+
it('returns input unchanged if not 13 digits', () => {
126+
expect(formatAHV('12345')).toBe('12345');
127+
});
128+
});
129+
130+
describe('isValidAHV', () => {
131+
it('accepts a valid AHV number', () => {
132+
expect(isValidAHV('756.9217.0769.85')).toBe(true);
133+
expect(isValidAHV('7569217076985')).toBe(true);
134+
});
135+
136+
it('rejects a wrong check digit', () => {
137+
expect(isValidAHV('756.9217.0769.86')).toBe(false);
138+
});
139+
140+
it('rejects a non-756 prefix', () => {
141+
expect(isValidAHV('123.9217.0769.85')).toBe(false);
142+
});
143+
144+
it('rejects the wrong length', () => {
145+
expect(isValidAHV('756.9217.0769')).toBe(false);
146+
});
147+
});
148+
149+
describe('formatQRReference', () => {
150+
it('groups a 27-digit reference in blocks of 5 from the right', () => {
151+
expect(formatQRReference('210000000003139471430009017')).toBe(
152+
'21 00000 00003 13947 14300 09017'
153+
);
154+
});
155+
156+
it('returns input unchanged for non-numeric input', () => {
157+
expect(formatQRReference('not-a-reference')).toBe('not-a-reference');
158+
});
159+
});
160+
161+
describe('isValidQRReference', () => {
162+
it('accepts a valid QR-bill reference', () => {
163+
expect(isValidQRReference('210000000003139471430009017')).toBe(true);
164+
expect(isValidQRReference('21 00000 00003 13947 14300 09017')).toBe(true);
165+
});
166+
167+
it('rejects a wrong check digit', () => {
168+
expect(isValidQRReference('210000000003139471430009018')).toBe(false);
169+
});
170+
171+
it('rejects malformed input', () => {
172+
expect(isValidQRReference('not-a-reference')).toBe(false);
173+
expect(isValidQRReference('')).toBe(false);
174+
expect(isValidQRReference('1')).toBe(false);
175+
});
176+
});
177+
178+
describe('formatUID', () => {
179+
it('formats a 9-digit UID with CHE prefix', () => {
180+
expect(formatUID('116281710')).toBe('CHE-116.281.710');
181+
});
182+
183+
it('strips existing separators before formatting', () => {
184+
expect(formatUID('CHE-116.281.710')).toBe('CHE-116.281.710');
185+
});
186+
187+
it('returns input unchanged if not 9 digits', () => {
188+
expect(formatUID('123')).toBe('123');
189+
});
190+
});
191+
192+
describe('isValidUID', () => {
193+
it('accepts a valid UID (with or without CHE prefix)', () => {
194+
expect(isValidUID('CHE-116.281.710')).toBe(true);
195+
expect(isValidUID('116281710')).toBe(true);
196+
expect(isValidUID('CHE-107.787.577')).toBe(true);
197+
});
198+
199+
it('rejects a wrong check digit', () => {
200+
expect(isValidUID('CHE-116.281.711')).toBe(false);
201+
});
202+
203+
it('rejects malformed input', () => {
204+
expect(isValidUID('not-a-uid')).toBe(false);
205+
expect(isValidUID('')).toBe(false);
206+
});
207+
});
208+
209+
describe('isValidSwissPostalCode', () => {
210+
it('accepts a valid postal code', () => {
211+
expect(isValidSwissPostalCode('1000')).toBe(true);
212+
expect(isValidSwissPostalCode('8001')).toBe(true);
213+
expect(isValidSwissPostalCode('9658')).toBe(true);
214+
});
215+
216+
it('rejects codes outside the assigned range', () => {
217+
expect(isValidSwissPostalCode('0999')).toBe(false);
218+
expect(isValidSwissPostalCode('9659')).toBe(false);
219+
});
220+
221+
it('rejects malformed input', () => {
222+
expect(isValidSwissPostalCode('12345')).toBe(false);
223+
expect(isValidSwissPostalCode('abcd')).toBe(false);
224+
expect(isValidSwissPostalCode('')).toBe(false);
225+
});
226+
});

0 commit comments

Comments
 (0)