taiwan-validator 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +214 -0
- package/README.md +214 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +23 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +128 -0
- package/src/index.ts +26 -0
- package/src/types.ts +22 -0
- package/src/validators/business-number.test.ts +63 -0
- package/src/validators/business-number.ts +59 -0
- package/src/validators/citizen-certificate.test.ts +73 -0
- package/src/validators/citizen-certificate.ts +41 -0
- package/src/validators/einvoice-donation-code.test.ts +78 -0
- package/src/validators/einvoice-donation-code.ts +42 -0
- package/src/validators/einvoice-mobile-barcode.test.ts +67 -0
- package/src/validators/einvoice-mobile-barcode.ts +45 -0
- package/src/validators/mobile-phone.test.ts +67 -0
- package/src/validators/mobile-phone.ts +41 -0
- package/src/validators/national-id.test.ts +99 -0
- package/src/validators/national-id.ts +181 -0
- package/src/validators/resident-certificate.test.ts +129 -0
- package/src/validators/resident-certificate.ts +181 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { validateMobilePhone } from "./mobile-phone";
|
|
2
|
+
|
|
3
|
+
describe("validateMobilePhone", () => {
|
|
4
|
+
describe("Valid Mobile Phone Numbers", () => {
|
|
5
|
+
test("should validate correct mobile phone numbers", () => {
|
|
6
|
+
expect(validateMobilePhone("0912345678").isValid).toBe(true);
|
|
7
|
+
expect(validateMobilePhone("0923456789").isValid).toBe(true);
|
|
8
|
+
expect(validateMobilePhone("0934567890").isValid).toBe(true);
|
|
9
|
+
expect(validateMobilePhone("0945678901").isValid).toBe(true);
|
|
10
|
+
expect(validateMobilePhone("0956789012").isValid).toBe(true);
|
|
11
|
+
expect(validateMobilePhone("0967890123").isValid).toBe(true);
|
|
12
|
+
expect(validateMobilePhone("0978901234").isValid).toBe(true);
|
|
13
|
+
expect(validateMobilePhone("0989012345").isValid).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("should validate phone numbers with separators", () => {
|
|
17
|
+
expect(validateMobilePhone("0912-345-678").isValid).toBe(true);
|
|
18
|
+
expect(validateMobilePhone("0912 345 678").isValid).toBe(true);
|
|
19
|
+
expect(validateMobilePhone("(0912) 345-678").isValid).toBe(true);
|
|
20
|
+
expect(validateMobilePhone("0912-345678").isValid).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("Invalid Mobile Phone Numbers", () => {
|
|
25
|
+
test("should reject numbers not starting with 09", () => {
|
|
26
|
+
expect(validateMobilePhone("0812345678").isValid).toBe(false);
|
|
27
|
+
expect(validateMobilePhone("0712345678").isValid).toBe(false);
|
|
28
|
+
expect(validateMobilePhone("1912345678").isValid).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("should reject incorrect length", () => {
|
|
32
|
+
expect(validateMobilePhone("091234567").isValid).toBe(false); // Too short
|
|
33
|
+
expect(validateMobilePhone("09123456789").isValid).toBe(false); // Too long
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("should reject non-numeric input (excluding valid separators)", () => {
|
|
37
|
+
expect(validateMobilePhone("091234567A").isValid).toBe(false);
|
|
38
|
+
expect(validateMobilePhone("ABCDEFGHIJ").isValid).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("Edge Cases", () => {
|
|
43
|
+
test("should handle whitespace", () => {
|
|
44
|
+
expect(validateMobilePhone(" 0912345678 ").isValid).toBe(true);
|
|
45
|
+
expect(validateMobilePhone("0912 345 678").isValid).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("should reject empty or invalid input", () => {
|
|
49
|
+
expect(validateMobilePhone("").isValid).toBe(false);
|
|
50
|
+
expect(validateMobilePhone(" ").isValid).toBe(false);
|
|
51
|
+
// @ts-expect-error Testing invalid input type
|
|
52
|
+
expect(validateMobilePhone(null).isValid).toBe(false);
|
|
53
|
+
// @ts-expect-error Testing invalid input type
|
|
54
|
+
expect(validateMobilePhone(undefined).isValid).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("should provide meaningful error messages", () => {
|
|
58
|
+
const result1 = validateMobilePhone("091234567");
|
|
59
|
+
expect(result1.isValid).toBe(false);
|
|
60
|
+
expect(result1.message).toBe("手機號碼必須以09開頭且為10位數字");
|
|
61
|
+
|
|
62
|
+
const result2 = validateMobilePhone("");
|
|
63
|
+
expect(result2.isValid).toBe(false);
|
|
64
|
+
expect(result2.message).toBe("手機號碼必須為非空字串");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ValidationResult } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 驗證台灣手機號碼
|
|
5
|
+
* 格式:09XXXXXXXX(10位數字,以09開頭)
|
|
6
|
+
*
|
|
7
|
+
* @param phone - 要驗證的手機號碼
|
|
8
|
+
* @returns 驗證結果
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* validateMobilePhone('0912345678');
|
|
13
|
+
* validateMobilePhone('0912-345-678'); // 含分隔符號
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export function validateMobilePhone(phone: string): ValidationResult {
|
|
17
|
+
if (!phone || typeof phone !== "string") {
|
|
18
|
+
return {
|
|
19
|
+
isValid: false,
|
|
20
|
+
message: "手機號碼必須為非空字串",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 移除常見的分隔符號(空格、破折號、括號)
|
|
25
|
+
const normalized = phone.trim().replace(/[\s\-()]/g, "");
|
|
26
|
+
|
|
27
|
+
// 檢查是否符合台灣手機號碼格式
|
|
28
|
+
// 以09開頭且總共10位數字
|
|
29
|
+
const pattern = /^09\d{8}$/;
|
|
30
|
+
|
|
31
|
+
if (!pattern.test(normalized)) {
|
|
32
|
+
return {
|
|
33
|
+
isValid: false,
|
|
34
|
+
message: "手機號碼必須以09開頭且為10位數字",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
isValid: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { validateNationalId } from "./national-id";
|
|
2
|
+
|
|
3
|
+
describe("validateNationalId", () => {
|
|
4
|
+
describe("Old Format", () => {
|
|
5
|
+
test("should validate correct old format National IDs", () => {
|
|
6
|
+
// These are example format IDs (not real person IDs)
|
|
7
|
+
expect(validateNationalId("A123456789", "old").isValid).toBe(true);
|
|
8
|
+
expect(validateNationalId("F223456786", "old").isValid).toBe(true);
|
|
9
|
+
expect(validateNationalId("O123456782", "old").isValid).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("should reject invalid old format National IDs", () => {
|
|
13
|
+
expect(validateNationalId("A123456788", "old").isValid).toBe(false); // Wrong checksum
|
|
14
|
+
expect(validateNationalId("A323456789", "old").isValid).toBe(false); // Invalid gender code
|
|
15
|
+
expect(validateNationalId("A12345678", "old").isValid).toBe(false); // Too short
|
|
16
|
+
expect(validateNationalId("A1234567890", "old").isValid).toBe(false); // Too long
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("should handle case insensitive input", () => {
|
|
20
|
+
expect(validateNationalId("a123456789", "old").isValid).toBe(true);
|
|
21
|
+
expect(validateNationalId("f223456786", "old").isValid).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("should handle whitespace", () => {
|
|
25
|
+
expect(validateNationalId(" A123456789 ", "old").isValid).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("New Format", () => {
|
|
30
|
+
test("should validate correct new format National IDs", () => {
|
|
31
|
+
// These are example format IDs (not real person IDs)
|
|
32
|
+
expect(validateNationalId("AA23456786", "new").isValid).toBe(true);
|
|
33
|
+
expect(validateNationalId("AB23456789", "new").isValid).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("should reject invalid new format National IDs", () => {
|
|
37
|
+
expect(validateNationalId("AA12345677", "new").isValid).toBe(false); // Wrong checksum
|
|
38
|
+
expect(validateNationalId("AA1234567", "new").isValid).toBe(false); // Too short
|
|
39
|
+
expect(validateNationalId("AA123456789", "new").isValid).toBe(false); // Too long
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("should handle case insensitive input", () => {
|
|
43
|
+
expect(validateNationalId("aa23456786", "new").isValid).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("should handle whitespace", () => {
|
|
47
|
+
expect(validateNationalId(" AB23456789 ", "new").isValid).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("Auto-detect Format", () => {
|
|
52
|
+
test("should auto-detect and validate old format", () => {
|
|
53
|
+
expect(validateNationalId("A123456789").isValid).toBe(true);
|
|
54
|
+
expect(validateNationalId("F223456786").isValid).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("should auto-detect and reject invalid old format", () => {
|
|
58
|
+
const result = validateNationalId("A123456780");
|
|
59
|
+
expect(result.isValid).toBe(false);
|
|
60
|
+
expect(result.message).toBe("無效的舊式身分證字號");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("should auto-detect and validate new format", () => {
|
|
64
|
+
expect(validateNationalId("AA23456786").isValid).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("should auto-detect and reject invalid new format", () => {
|
|
68
|
+
const result = validateNationalId("AA23456780");
|
|
69
|
+
expect(result.isValid).toBe(false);
|
|
70
|
+
expect(result.message).toBe("無效的新式身分證字號");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("should reject completely invalid formats", () => {
|
|
74
|
+
expect(validateNationalId("12345678").isValid).toBe(false);
|
|
75
|
+
expect(validateNationalId("ABCDEFGH").isValid).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("Edge Cases", () => {
|
|
80
|
+
test("should reject empty or invalid input", () => {
|
|
81
|
+
expect(validateNationalId("").isValid).toBe(false);
|
|
82
|
+
expect(validateNationalId(" ").isValid).toBe(false);
|
|
83
|
+
// @ts-expect-error Testing invalid input type
|
|
84
|
+
expect(validateNationalId(null).isValid).toBe(false);
|
|
85
|
+
// @ts-expect-error Testing invalid input type
|
|
86
|
+
expect(validateNationalId(undefined).isValid).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("should provide meaningful error messages", () => {
|
|
90
|
+
const result1 = validateNationalId("A123456788", "old");
|
|
91
|
+
expect(result1.isValid).toBe(false);
|
|
92
|
+
expect(result1.message).toBeDefined();
|
|
93
|
+
|
|
94
|
+
const result2 = validateNationalId("");
|
|
95
|
+
expect(result2.isValid).toBe(false);
|
|
96
|
+
expect(result2.message).toBe("身分證字號必須為非空字串");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { ValidationResult, NationalIdType } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 字母對應數字表(用於身分證字號驗證)
|
|
5
|
+
*/
|
|
6
|
+
const LETTER_MAPPING: Record<string, number> = {
|
|
7
|
+
A: 10,
|
|
8
|
+
B: 11,
|
|
9
|
+
C: 12,
|
|
10
|
+
D: 13,
|
|
11
|
+
E: 14,
|
|
12
|
+
F: 15,
|
|
13
|
+
G: 16,
|
|
14
|
+
H: 17,
|
|
15
|
+
I: 34,
|
|
16
|
+
J: 18,
|
|
17
|
+
K: 19,
|
|
18
|
+
L: 20,
|
|
19
|
+
M: 21,
|
|
20
|
+
N: 22,
|
|
21
|
+
O: 35,
|
|
22
|
+
P: 23,
|
|
23
|
+
Q: 24,
|
|
24
|
+
R: 25,
|
|
25
|
+
S: 26,
|
|
26
|
+
T: 27,
|
|
27
|
+
U: 28,
|
|
28
|
+
V: 29,
|
|
29
|
+
W: 32,
|
|
30
|
+
X: 30,
|
|
31
|
+
Y: 31,
|
|
32
|
+
Z: 33,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 驗證舊式身分證字號格式(1個字母 + 9個數字)
|
|
37
|
+
* 格式:A123456789
|
|
38
|
+
* - 第一個字元:地區代碼(字母)
|
|
39
|
+
* - 第二個字元:性別(1 = 男性,2 = 女性)
|
|
40
|
+
* - 最後一個字元:檢查碼
|
|
41
|
+
*/
|
|
42
|
+
function validateOldFormat(id: string): boolean {
|
|
43
|
+
const pattern = /^[A-Z][12]\d{8}$/;
|
|
44
|
+
if (!pattern.test(id)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const letter = id[0] as string;
|
|
49
|
+
const numbers = id.slice(1);
|
|
50
|
+
|
|
51
|
+
const letterValue = LETTER_MAPPING[letter] as number;
|
|
52
|
+
|
|
53
|
+
// 計算檢查碼
|
|
54
|
+
const d1 = Math.floor(letterValue / 10);
|
|
55
|
+
const d2 = letterValue % 10;
|
|
56
|
+
|
|
57
|
+
const weights = [1, 9, 8, 7, 6, 5, 4, 3, 2, 1, 1];
|
|
58
|
+
const digits = [d1, d2, ...numbers.split("").map(Number)];
|
|
59
|
+
|
|
60
|
+
const sum = digits.reduce(
|
|
61
|
+
(acc, digit, index) => acc + digit * weights[index]!,
|
|
62
|
+
0,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return sum % 10 === 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 驗證新式身分證字號格式(2個字母 + 8個數字)
|
|
70
|
+
* 格式:AA12345678
|
|
71
|
+
* - 第一個字元:地區代碼(字母)
|
|
72
|
+
* - 第二個字元:性別(8 = 男性,9 = 女性)以字母表示
|
|
73
|
+
* - 最後一個字元:檢查碼
|
|
74
|
+
*/
|
|
75
|
+
function validateNewFormat(id: string): boolean {
|
|
76
|
+
const pattern = /^[A-Z]{2}\d{8}$/;
|
|
77
|
+
if (!pattern.test(id)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const firstLetter = id[0] as string;
|
|
82
|
+
const secondLetter = id[1] as string;
|
|
83
|
+
const numbers = id.slice(2);
|
|
84
|
+
|
|
85
|
+
const firstLetterValue = LETTER_MAPPING[firstLetter] as number;
|
|
86
|
+
const secondLetterValue = LETTER_MAPPING[secondLetter] as number;
|
|
87
|
+
|
|
88
|
+
// 計算新式格式的檢查碼
|
|
89
|
+
const d1 = Math.floor(firstLetterValue / 10);
|
|
90
|
+
const d2 = firstLetterValue % 10;
|
|
91
|
+
const d3 = Math.floor(secondLetterValue / 10);
|
|
92
|
+
const d4 = secondLetterValue % 10;
|
|
93
|
+
|
|
94
|
+
const weights = [1, 9, 8, 7, 6, 5, 4, 3, 2, 1, 1, 1];
|
|
95
|
+
const digits = [d1, d2, d3, d4, ...numbers.split("").map(Number)];
|
|
96
|
+
|
|
97
|
+
const sum = digits.reduce(
|
|
98
|
+
(acc, digit, index) => acc + digit * weights[index]!,
|
|
99
|
+
0,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return sum % 10 === 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 偵測身分證字號格式類型
|
|
107
|
+
*/
|
|
108
|
+
function detectFormat(id: string): NationalIdType | null {
|
|
109
|
+
if (/^[A-Z][12]\d{8}$/.test(id)) {
|
|
110
|
+
return "old";
|
|
111
|
+
}
|
|
112
|
+
if (/^[A-Z]{2}\d{8}$/.test(id)) {
|
|
113
|
+
return "new";
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 驗證台灣身分證字號(支援新舊格式)
|
|
120
|
+
* @param id - 要驗證的身分證字號
|
|
121
|
+
* @param format - 可選:指定格式類型('old' 或 'new')
|
|
122
|
+
* @returns 驗證結果
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```typescript
|
|
126
|
+
* validateNationalId('A123456789'); // 舊式格式
|
|
127
|
+
* validateNationalId('AA12345678'); // 新式格式
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
export function validateNationalId(
|
|
131
|
+
id: string,
|
|
132
|
+
format?: NationalIdType,
|
|
133
|
+
): ValidationResult {
|
|
134
|
+
if (!id || typeof id !== "string") {
|
|
135
|
+
return {
|
|
136
|
+
isValid: false,
|
|
137
|
+
message: "身分證字號必須為非空字串",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const normalizedId = id.trim().toUpperCase();
|
|
142
|
+
|
|
143
|
+
// 如果指定了格式,只驗證該格式
|
|
144
|
+
if (format === "old") {
|
|
145
|
+
const isValid = validateOldFormat(normalizedId);
|
|
146
|
+
return {
|
|
147
|
+
isValid,
|
|
148
|
+
message: isValid ? undefined : "無效的舊式身分證字號",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (format === "new") {
|
|
153
|
+
const isValid = validateNewFormat(normalizedId);
|
|
154
|
+
return {
|
|
155
|
+
isValid,
|
|
156
|
+
message: isValid ? undefined : "無效的新式身分證字號",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 自動偵測格式
|
|
161
|
+
const detectedFormat = detectFormat(normalizedId);
|
|
162
|
+
|
|
163
|
+
if (!detectedFormat) {
|
|
164
|
+
return {
|
|
165
|
+
isValid: false,
|
|
166
|
+
message: "無效的身分證字號格式",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const isValid =
|
|
171
|
+
detectedFormat === "old"
|
|
172
|
+
? validateOldFormat(normalizedId)
|
|
173
|
+
: validateNewFormat(normalizedId);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
isValid,
|
|
177
|
+
message: isValid
|
|
178
|
+
? undefined
|
|
179
|
+
: `無效的${detectedFormat === "old" ? "舊式" : "新式"}身分證字號`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { validateResidentCertificate } from "./resident-certificate";
|
|
2
|
+
|
|
3
|
+
describe("validateResidentCertificate", () => {
|
|
4
|
+
describe("Old Format", () => {
|
|
5
|
+
test("should validate correct old format Resident Certificates", () => {
|
|
6
|
+
// These are example format IDs (not real person IDs)
|
|
7
|
+
expect(validateResidentCertificate("A823456783", "old").isValid).toBe(
|
|
8
|
+
true,
|
|
9
|
+
);
|
|
10
|
+
expect(validateResidentCertificate("B923456786", "old").isValid).toBe(
|
|
11
|
+
true,
|
|
12
|
+
);
|
|
13
|
+
expect(validateResidentCertificate("C823456785", "old").isValid).toBe(
|
|
14
|
+
true,
|
|
15
|
+
);
|
|
16
|
+
expect(validateResidentCertificate("D923456788", "old").isValid).toBe(
|
|
17
|
+
true,
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("should reject invalid old format Resident Certificates", () => {
|
|
22
|
+
expect(validateResidentCertificate("A823456780", "old").isValid).toBe(
|
|
23
|
+
false,
|
|
24
|
+
); // Wrong checksum
|
|
25
|
+
expect(validateResidentCertificate("E823456783", "old").isValid).toBe(
|
|
26
|
+
false,
|
|
27
|
+
); // Invalid first letter
|
|
28
|
+
expect(validateResidentCertificate("A723456783", "old").isValid).toBe(
|
|
29
|
+
false,
|
|
30
|
+
); // Invalid second digit (must be 8 or 9)
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("should handle case insensitive input", () => {
|
|
34
|
+
expect(validateResidentCertificate("a823456783", "old").isValid).toBe(
|
|
35
|
+
true,
|
|
36
|
+
);
|
|
37
|
+
expect(validateResidentCertificate("b923456786", "old").isValid).toBe(
|
|
38
|
+
true,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("should handle whitespace", () => {
|
|
43
|
+
expect(validateResidentCertificate(" A823456783 ", "old").isValid).toBe(
|
|
44
|
+
true,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("New Format", () => {
|
|
50
|
+
test("should validate correct new format Resident Certificates", () => {
|
|
51
|
+
// These are example format IDs (not real person IDs)
|
|
52
|
+
expect(validateResidentCertificate("AA23456786", "new").isValid).toBe(
|
|
53
|
+
true,
|
|
54
|
+
);
|
|
55
|
+
expect(validateResidentCertificate("AB23456789", "new").isValid).toBe(
|
|
56
|
+
true,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("should reject invalid new format Resident Certificates", () => {
|
|
61
|
+
expect(validateResidentCertificate("AA23456780", "new").isValid).toBe(
|
|
62
|
+
false,
|
|
63
|
+
); // Wrong checksum
|
|
64
|
+
expect(validateResidentCertificate("AA2345678", "new").isValid).toBe(
|
|
65
|
+
false,
|
|
66
|
+
); // Too short
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("should handle case insensitive input", () => {
|
|
70
|
+
expect(validateResidentCertificate("aa23456786", "new").isValid).toBe(
|
|
71
|
+
true,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("should handle whitespace", () => {
|
|
76
|
+
expect(validateResidentCertificate(" AB23456789 ", "new").isValid).toBe(
|
|
77
|
+
true,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("Auto-detect Format", () => {
|
|
83
|
+
test("should auto-detect and validate old format", () => {
|
|
84
|
+
expect(validateResidentCertificate("A823456783").isValid).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("should auto-detect and reject invalid old format", () => {
|
|
88
|
+
const result = validateResidentCertificate("A823456780");
|
|
89
|
+
expect(result.isValid).toBe(false);
|
|
90
|
+
expect(result.message).toBe("無效的舊式居留證號");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("should auto-detect and validate new format", () => {
|
|
94
|
+
expect(validateResidentCertificate("AA23456786").isValid).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("should auto-detect and reject invalid new format", () => {
|
|
98
|
+
const result = validateResidentCertificate("AA23456780");
|
|
99
|
+
expect(result.isValid).toBe(false);
|
|
100
|
+
expect(result.message).toBe("無效的新式居留證號");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("should reject completely invalid formats", () => {
|
|
104
|
+
expect(validateResidentCertificate("12345678").isValid).toBe(false);
|
|
105
|
+
expect(validateResidentCertificate("ABCDEFGH").isValid).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("Edge Cases", () => {
|
|
110
|
+
test("should reject empty or invalid input", () => {
|
|
111
|
+
expect(validateResidentCertificate("").isValid).toBe(false);
|
|
112
|
+
expect(validateResidentCertificate(" ").isValid).toBe(false);
|
|
113
|
+
// @ts-expect-error Testing invalid input type
|
|
114
|
+
expect(validateResidentCertificate(null).isValid).toBe(false);
|
|
115
|
+
// @ts-expect-error Testing invalid input type
|
|
116
|
+
expect(validateResidentCertificate(undefined).isValid).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("should provide meaningful error messages", () => {
|
|
120
|
+
const result1 = validateResidentCertificate("A823456780", "old");
|
|
121
|
+
expect(result1.isValid).toBe(false);
|
|
122
|
+
expect(result1.message).toBeDefined();
|
|
123
|
+
|
|
124
|
+
const result2 = validateResidentCertificate("");
|
|
125
|
+
expect(result2.isValid).toBe(false);
|
|
126
|
+
expect(result2.message).toBe("居留證號必須為非空字串");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { ValidationResult, ResidentCertificateType } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 字母對應數字表(用於居留證號驗證)
|
|
5
|
+
*/
|
|
6
|
+
const LETTER_MAPPING: Record<string, number> = {
|
|
7
|
+
A: 10,
|
|
8
|
+
B: 11,
|
|
9
|
+
C: 12,
|
|
10
|
+
D: 13,
|
|
11
|
+
E: 14,
|
|
12
|
+
F: 15,
|
|
13
|
+
G: 16,
|
|
14
|
+
H: 17,
|
|
15
|
+
I: 34,
|
|
16
|
+
J: 18,
|
|
17
|
+
K: 19,
|
|
18
|
+
L: 20,
|
|
19
|
+
M: 21,
|
|
20
|
+
N: 22,
|
|
21
|
+
O: 35,
|
|
22
|
+
P: 23,
|
|
23
|
+
Q: 24,
|
|
24
|
+
R: 25,
|
|
25
|
+
S: 26,
|
|
26
|
+
T: 27,
|
|
27
|
+
U: 28,
|
|
28
|
+
V: 29,
|
|
29
|
+
W: 32,
|
|
30
|
+
X: 30,
|
|
31
|
+
Y: 31,
|
|
32
|
+
Z: 33,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 驗證舊式居留證號格式(1個字母 + 9個數字)
|
|
37
|
+
* 格式:A800000000
|
|
38
|
+
* - 第一個字元:地區代碼(A、B、C 或 D 表示外國人士)
|
|
39
|
+
* - 第二個字元:性別/類型(8 = 男性,9 = 女性)
|
|
40
|
+
* - 最後一個字元:檢查碼
|
|
41
|
+
*/
|
|
42
|
+
function validateOldFormat(id: string): boolean {
|
|
43
|
+
const pattern = /^[A-D][89]\d{8}$/;
|
|
44
|
+
if (!pattern.test(id)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const letter = id[0] as string;
|
|
49
|
+
const numbers = id.slice(1);
|
|
50
|
+
|
|
51
|
+
const letterValue = LETTER_MAPPING[letter] as number;
|
|
52
|
+
|
|
53
|
+
// 計算檢查碼(與身分證字號相同的演算法)
|
|
54
|
+
const d1 = Math.floor(letterValue / 10);
|
|
55
|
+
const d2 = letterValue % 10;
|
|
56
|
+
|
|
57
|
+
const weights = [1, 9, 8, 7, 6, 5, 4, 3, 2, 1, 1];
|
|
58
|
+
const digits = [d1, d2, ...numbers.split("").map(Number)];
|
|
59
|
+
|
|
60
|
+
const sum = digits.reduce(
|
|
61
|
+
(acc, digit, index) => acc + digit * weights[index]!,
|
|
62
|
+
0,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return sum % 10 === 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 驗證新式居留證號格式(2個字母 + 8個數字)
|
|
70
|
+
* 格式:AA12345678
|
|
71
|
+
* - 第一個字元:地區代碼(字母)
|
|
72
|
+
* - 第二個字元:性別(8 = 男性,9 = 女性)以字母表示
|
|
73
|
+
* - 最後一個字元:檢查碼
|
|
74
|
+
*/
|
|
75
|
+
function validateNewFormat(id: string): boolean {
|
|
76
|
+
const pattern = /^[A-Z]{2}\d{8}$/;
|
|
77
|
+
if (!pattern.test(id)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const firstLetter = id[0] as string;
|
|
82
|
+
const secondLetter = id[1] as string;
|
|
83
|
+
const numbers = id.slice(2);
|
|
84
|
+
|
|
85
|
+
const firstLetterValue = LETTER_MAPPING[firstLetter] as number;
|
|
86
|
+
const secondLetterValue = LETTER_MAPPING[secondLetter] as number;
|
|
87
|
+
|
|
88
|
+
// 計算新式格式的檢查碼
|
|
89
|
+
const d1 = Math.floor(firstLetterValue / 10);
|
|
90
|
+
const d2 = firstLetterValue % 10;
|
|
91
|
+
const d3 = Math.floor(secondLetterValue / 10);
|
|
92
|
+
const d4 = secondLetterValue % 10;
|
|
93
|
+
|
|
94
|
+
const weights = [1, 9, 8, 7, 6, 5, 4, 3, 2, 1, 1, 1];
|
|
95
|
+
const digits = [d1, d2, d3, d4, ...numbers.split("").map(Number)];
|
|
96
|
+
|
|
97
|
+
const sum = digits.reduce(
|
|
98
|
+
(acc, digit, index) => acc + digit * weights[index]!,
|
|
99
|
+
0,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return sum % 10 === 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 偵測居留證號格式類型
|
|
107
|
+
*/
|
|
108
|
+
function detectFormat(id: string): ResidentCertificateType | null {
|
|
109
|
+
if (/^[A-D][89]\d{8}$/.test(id)) {
|
|
110
|
+
return "old";
|
|
111
|
+
}
|
|
112
|
+
if (/^[A-Z]{2}\d{8}$/.test(id)) {
|
|
113
|
+
return "new";
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 驗證台灣居留證號(支援新舊格式)
|
|
120
|
+
* @param id - 要驗證的居留證號
|
|
121
|
+
* @param format - 可選:指定格式類型('old' 或 'new')
|
|
122
|
+
* @returns 驗證結果
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```typescript
|
|
126
|
+
* validateResidentCertificate('A800000000'); // 舊式格式
|
|
127
|
+
* validateResidentCertificate('AA12345678'); // 新式格式
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
export function validateResidentCertificate(
|
|
131
|
+
id: string,
|
|
132
|
+
format?: ResidentCertificateType,
|
|
133
|
+
): ValidationResult {
|
|
134
|
+
if (!id || typeof id !== "string") {
|
|
135
|
+
return {
|
|
136
|
+
isValid: false,
|
|
137
|
+
message: "居留證號必須為非空字串",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const normalizedId = id.trim().toUpperCase();
|
|
142
|
+
|
|
143
|
+
// 如果指定了格式,只驗證該格式
|
|
144
|
+
if (format === "old") {
|
|
145
|
+
const isValid = validateOldFormat(normalizedId);
|
|
146
|
+
return {
|
|
147
|
+
isValid,
|
|
148
|
+
message: isValid ? undefined : "無效的舊式居留證號",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (format === "new") {
|
|
153
|
+
const isValid = validateNewFormat(normalizedId);
|
|
154
|
+
return {
|
|
155
|
+
isValid,
|
|
156
|
+
message: isValid ? undefined : "無效的新式居留證號",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 自動偵測格式
|
|
161
|
+
const detectedFormat = detectFormat(normalizedId);
|
|
162
|
+
|
|
163
|
+
if (!detectedFormat) {
|
|
164
|
+
return {
|
|
165
|
+
isValid: false,
|
|
166
|
+
message: "無效的居留證號格式",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const isValid =
|
|
171
|
+
detectedFormat === "old"
|
|
172
|
+
? validateOldFormat(normalizedId)
|
|
173
|
+
: validateNewFormat(normalizedId);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
isValid,
|
|
177
|
+
message: isValid
|
|
178
|
+
? undefined
|
|
179
|
+
: `無效的${detectedFormat === "old" ? "舊式" : "新式"}居留證號`,
|
|
180
|
+
};
|
|
181
|
+
}
|