lanka-kit 0.0.0-stage → 1.1.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.md +328 -2
- package/lib/banks.d.ts +41 -0
- package/lib/banks.js +136 -0
- package/lib/currency.d.ts +25 -0
- package/lib/currency.js +251 -0
- package/lib/geo.d.ts +18 -0
- package/lib/geo.js +197 -0
- package/lib/identity-extra.d.ts +42 -0
- package/lib/identity-extra.js +146 -0
- package/lib/index.d.ts +16 -0
- package/lib/index.js +16 -0
- package/lib/lankaqr.d.ts +13 -0
- package/lib/lankaqr.js +113 -0
- package/lib/license.d.ts +16 -0
- package/lib/license.js +42 -0
- package/lib/nic.d.ts +38 -0
- package/lib/nic.js +268 -0
- package/lib/phone.d.ts +24 -0
- package/lib/phone.js +158 -0
- package/lib/types.d.ts +81 -0
- package/lib/types.js +4 -0
- package/lib/unicode.d.ts +29 -0
- package/lib/unicode.js +70 -0
- package/lib/vehicle.d.ts +28 -0
- package/lib/vehicle.js +146 -0
- package/lib/zod.d.ts +69 -0
- package/lib/zod.js +98 -0
- package/package.json +43 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lanka-kit
|
|
3
|
+
* Sri Lankan developer utility toolkit.
|
|
4
|
+
*/
|
|
5
|
+
export * from './types';
|
|
6
|
+
export * from './nic';
|
|
7
|
+
export * from './phone';
|
|
8
|
+
export * from './geo';
|
|
9
|
+
export * from './license';
|
|
10
|
+
export * from './lankaqr';
|
|
11
|
+
export * from './banks';
|
|
12
|
+
export * from './currency';
|
|
13
|
+
export * from './vehicle';
|
|
14
|
+
export * from './identity-extra';
|
|
15
|
+
export * from './unicode';
|
|
16
|
+
export * from './zod';
|
package/lib/lankaqr.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { LankaQrInfo } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Computes EMVCo standard CRC-16 (CCITT, polynomial 0x1021, initial 0xFFFF)
|
|
4
|
+
*/
|
|
5
|
+
export declare function computeCrc16(data: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Parses TLV (Tag-Length-Value) blocks according to EMVCo / LANKAQR standard.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseTlv(payload: string): Map<string, string>;
|
|
10
|
+
/**
|
|
11
|
+
* Parses and validates an EMVCo-compliant LANKAQR string.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseLankaQr(payload: string): LankaQrInfo;
|
package/lib/lankaqr.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computes EMVCo standard CRC-16 (CCITT, polynomial 0x1021, initial 0xFFFF)
|
|
3
|
+
*/
|
|
4
|
+
export function computeCrc16(data) {
|
|
5
|
+
let crc = 0xffff;
|
|
6
|
+
for (let i = 0; i < data.length; i++) {
|
|
7
|
+
crc ^= data.charCodeAt(i) << 8;
|
|
8
|
+
for (let j = 0; j < 8; j++) {
|
|
9
|
+
if ((crc & 0x8000) !== 0) {
|
|
10
|
+
crc = ((crc << 1) ^ 0x1021) & 0xffff;
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
crc = (crc << 1) & 0xffff;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return crc.toString(16).toUpperCase().padStart(4, '0');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parses TLV (Tag-Length-Value) blocks according to EMVCo / LANKAQR standard.
|
|
21
|
+
*/
|
|
22
|
+
export function parseTlv(payload) {
|
|
23
|
+
const map = new Map();
|
|
24
|
+
let cursor = 0;
|
|
25
|
+
while (cursor + 4 <= payload.length) {
|
|
26
|
+
const tag = payload.substring(cursor, cursor + 2);
|
|
27
|
+
const length = parseInt(payload.substring(cursor + 2, cursor + 4), 10);
|
|
28
|
+
cursor += 4;
|
|
29
|
+
if (isNaN(length) || cursor + length > payload.length) {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
const value = payload.substring(cursor, cursor + length);
|
|
33
|
+
cursor += length;
|
|
34
|
+
map.set(tag, value);
|
|
35
|
+
}
|
|
36
|
+
return map;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Parses and validates an EMVCo-compliant LANKAQR string.
|
|
40
|
+
*/
|
|
41
|
+
export function parseLankaQr(payload) {
|
|
42
|
+
const clean = payload.trim();
|
|
43
|
+
if (!clean) {
|
|
44
|
+
return {
|
|
45
|
+
isValid: false,
|
|
46
|
+
rawPayload: payload,
|
|
47
|
+
error: 'Empty LANKAQR payload',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const tlv = parseTlv(clean);
|
|
51
|
+
const formatIndicator = tlv.get('00');
|
|
52
|
+
const pointOfInitiationCode = tlv.get('01');
|
|
53
|
+
const countryCode = tlv.get('58');
|
|
54
|
+
const currencyCode = tlv.get('53');
|
|
55
|
+
const crc = tlv.get('63');
|
|
56
|
+
if (!formatIndicator) {
|
|
57
|
+
return {
|
|
58
|
+
isValid: false,
|
|
59
|
+
rawPayload: clean,
|
|
60
|
+
error: 'Missing EMVCo format indicator tag (00)',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// Validate CRC if present
|
|
64
|
+
let crcValid = undefined;
|
|
65
|
+
if (crc && clean.length >= 8) {
|
|
66
|
+
const dataWithoutCrcValue = clean.substring(0, clean.length - 4);
|
|
67
|
+
const calculatedCrc = computeCrc16(dataWithoutCrcValue);
|
|
68
|
+
crcValid = calculatedCrc.toUpperCase() === crc.toUpperCase();
|
|
69
|
+
}
|
|
70
|
+
// Extract merchant account info (tags 26 - 51)
|
|
71
|
+
const merchantAccountInfo = {};
|
|
72
|
+
for (let t = 26; t <= 51; t++) {
|
|
73
|
+
const tagStr = String(t).padStart(2, '0');
|
|
74
|
+
const val = tlv.get(tagStr);
|
|
75
|
+
if (val) {
|
|
76
|
+
merchantAccountInfo[tagStr] = val;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Parse additional data (tag 62)
|
|
80
|
+
let additionalData = undefined;
|
|
81
|
+
const tag62 = tlv.get('62');
|
|
82
|
+
if (tag62) {
|
|
83
|
+
const subTlv = parseTlv(tag62);
|
|
84
|
+
additionalData = {};
|
|
85
|
+
for (const [k, v] of subTlv.entries()) {
|
|
86
|
+
additionalData[k] = v;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const rawAmount = tlv.get('54');
|
|
90
|
+
const transactionAmount = rawAmount ? parseFloat(rawAmount) : undefined;
|
|
91
|
+
const isLanka = countryCode === 'LK' || currencyCode === '144';
|
|
92
|
+
return {
|
|
93
|
+
isValid: isLanka && (crcValid === undefined || crcValid === true),
|
|
94
|
+
rawPayload: clean,
|
|
95
|
+
formatIndicator,
|
|
96
|
+
pointOfInitiation: pointOfInitiationCode === '11'
|
|
97
|
+
? 'Static'
|
|
98
|
+
: pointOfInitiationCode === '12'
|
|
99
|
+
? 'Dynamic'
|
|
100
|
+
: undefined,
|
|
101
|
+
merchantAccountInfo,
|
|
102
|
+
merchantCategoryCode: tlv.get('52'),
|
|
103
|
+
transactionCurrency: currencyCode === '144' ? 'LKR (Sri Lankan Rupee)' : currencyCode,
|
|
104
|
+
transactionCurrencyCode: currencyCode,
|
|
105
|
+
transactionAmount,
|
|
106
|
+
countryCode,
|
|
107
|
+
merchantName: tlv.get('59'),
|
|
108
|
+
merchantCity: tlv.get('60'),
|
|
109
|
+
postalCode: tlv.get('61'),
|
|
110
|
+
additionalData,
|
|
111
|
+
crcValid,
|
|
112
|
+
};
|
|
113
|
+
}
|
package/lib/license.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sri Lankan Driving License verification
|
|
3
|
+
*/
|
|
4
|
+
export interface LicenseResult {
|
|
5
|
+
isValid: boolean;
|
|
6
|
+
clean: string;
|
|
7
|
+
format: 'NEW' | 'OLD' | 'UNKNOWN';
|
|
8
|
+
error?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Validates Sri Lankan Driving License format.
|
|
12
|
+
* - New format: 1 letter (usually 'B') followed by 7 digits (e.g. B1234567).
|
|
13
|
+
* - Legacy format: 7 or 8 digits.
|
|
14
|
+
*/
|
|
15
|
+
export declare function validateDrivingLicense(input: string): LicenseResult;
|
|
16
|
+
export declare function isValidDrivingLicense(input: string): boolean;
|
package/lib/license.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sri Lankan Driving License verification
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Validates Sri Lankan Driving License format.
|
|
6
|
+
* - New format: 1 letter (usually 'B') followed by 7 digits (e.g. B1234567).
|
|
7
|
+
* - Legacy format: 7 or 8 digits.
|
|
8
|
+
*/
|
|
9
|
+
export function validateDrivingLicense(input) {
|
|
10
|
+
if (typeof input !== 'string') {
|
|
11
|
+
return { isValid: false, clean: '', format: 'UNKNOWN', error: 'Input must be a string' };
|
|
12
|
+
}
|
|
13
|
+
const clean = input.trim().toUpperCase().replace(/[\s-]/g, '');
|
|
14
|
+
if (!clean) {
|
|
15
|
+
return { isValid: false, clean: '', format: 'UNKNOWN', error: 'License number cannot be empty' };
|
|
16
|
+
}
|
|
17
|
+
// Modern computerized format: e.g. B1234567
|
|
18
|
+
if (/^[A-Z][0-9]{7}$/.test(clean)) {
|
|
19
|
+
return {
|
|
20
|
+
isValid: true,
|
|
21
|
+
clean,
|
|
22
|
+
format: 'NEW',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// Legacy format: 7 or 8 digits
|
|
26
|
+
if (/^[0-9]{7,8}$/.test(clean)) {
|
|
27
|
+
return {
|
|
28
|
+
isValid: true,
|
|
29
|
+
clean,
|
|
30
|
+
format: 'OLD',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
isValid: false,
|
|
35
|
+
clean,
|
|
36
|
+
format: 'UNKNOWN',
|
|
37
|
+
error: 'Invalid driving license format. Expected modern format (e.g. B1234567) or legacy numeric format.',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function isValidDrivingLicense(input) {
|
|
41
|
+
return validateDrivingLicense(input).isValid;
|
|
42
|
+
}
|
package/lib/nic.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { NicResult, NicValidationSuccess } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Check if a given year is a leap year in the Gregorian calendar.
|
|
4
|
+
*/
|
|
5
|
+
export declare function isLeapYear(year: number): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Cleans and standardizes an NIC input string (trims whitespace, converts check letter to uppercase).
|
|
8
|
+
*/
|
|
9
|
+
export declare function sanitizeNic(input: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Converts an Old NIC (9 digits + V/X) to New NIC (12 digits).
|
|
12
|
+
*/
|
|
13
|
+
export declare function convertOldToNewNic(oldNic: string): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* Converts a New NIC (12 digits) to Old NIC (9 digits + V) if born between 1900 and 1999.
|
|
16
|
+
*/
|
|
17
|
+
export declare function convertNewToOldNic(newNic: string): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Converts an NIC to its alternate equivalent format if applicable.
|
|
20
|
+
*/
|
|
21
|
+
export declare function convertNic(nic: string): string | null;
|
|
22
|
+
/**
|
|
23
|
+
* Validates and extracts comprehensive identity information from a Sri Lankan NIC.
|
|
24
|
+
* Supports both Old (9 digits + V/X) and New (12 digits) formats.
|
|
25
|
+
*/
|
|
26
|
+
export declare function validateNic(input: string): NicResult;
|
|
27
|
+
/**
|
|
28
|
+
* Returns true if the given input is a valid Sri Lankan NIC.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isValidNic(input: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Parses an NIC and returns the extracted payload or throws an Error.
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseNic(input: string): NicValidationSuccess;
|
|
35
|
+
/**
|
|
36
|
+
* Formats an NIC nicely for display (e.g. 962451234V or 199624501234).
|
|
37
|
+
*/
|
|
38
|
+
export declare function formatNic(input: string): string;
|
package/lib/nic.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Month-day mapping according to Sri Lankan Department of Registration of Persons (DRP).
|
|
3
|
+
* DRP registers day-of-year on a fixed 366-day calendar where February is allocated 29 days
|
|
4
|
+
* regardless of whether the birth year is a leap year.
|
|
5
|
+
*/
|
|
6
|
+
const DRP_MONTH_DAYS = [
|
|
7
|
+
{ month: 1, name: 'January', days: 31 },
|
|
8
|
+
{ month: 2, name: 'February', days: 29 },
|
|
9
|
+
{ month: 3, name: 'March', days: 31 },
|
|
10
|
+
{ month: 4, name: 'April', days: 30 },
|
|
11
|
+
{ month: 5, name: 'May', days: 31 },
|
|
12
|
+
{ month: 6, name: 'June', days: 30 },
|
|
13
|
+
{ month: 7, name: 'July', days: 31 },
|
|
14
|
+
{ month: 8, name: 'August', days: 31 },
|
|
15
|
+
{ month: 9, name: 'September', days: 30 },
|
|
16
|
+
{ month: 10, name: 'October', days: 31 },
|
|
17
|
+
{ month: 11, name: 'November', days: 30 },
|
|
18
|
+
{ month: 12, name: 'December', days: 31 },
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Check if a given year is a leap year in the Gregorian calendar.
|
|
22
|
+
*/
|
|
23
|
+
export function isLeapYear(year) {
|
|
24
|
+
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Cleans and standardizes an NIC input string (trims whitespace, converts check letter to uppercase).
|
|
28
|
+
*/
|
|
29
|
+
export function sanitizeNic(input) {
|
|
30
|
+
if (typeof input !== 'string')
|
|
31
|
+
return '';
|
|
32
|
+
return input.trim().toUpperCase().replace(/[\s-]/g, '');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Calculates calendar month and day from the DRP day-of-year (1-366).
|
|
36
|
+
*/
|
|
37
|
+
function resolveDrpDate(year, dayOfYear) {
|
|
38
|
+
let remainingDays = dayOfYear;
|
|
39
|
+
let resolvedMonth = 1;
|
|
40
|
+
let resolvedDay = 1;
|
|
41
|
+
for (const m of DRP_MONTH_DAYS) {
|
|
42
|
+
if (remainingDays <= m.days) {
|
|
43
|
+
resolvedMonth = m.month;
|
|
44
|
+
resolvedDay = remainingDays;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
remainingDays -= m.days;
|
|
48
|
+
}
|
|
49
|
+
// Handle February 29 in non-leap years
|
|
50
|
+
let isFeb29Adjustment = false;
|
|
51
|
+
if (resolvedMonth === 2 && resolvedDay === 29 && !isLeapYear(year)) {
|
|
52
|
+
isFeb29Adjustment = true;
|
|
53
|
+
resolvedDay = 28; // fallback to Feb 28 for Date object validity
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
month: resolvedMonth,
|
|
57
|
+
day: resolvedDay,
|
|
58
|
+
isFeb29Adjustment,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Calculates current age based on birth date.
|
|
63
|
+
*/
|
|
64
|
+
function calculateAge(birthDate, referenceDate = new Date()) {
|
|
65
|
+
let age = referenceDate.getFullYear() - birthDate.getFullYear();
|
|
66
|
+
const monthDiff = referenceDate.getMonth() - birthDate.getMonth();
|
|
67
|
+
if (monthDiff < 0 ||
|
|
68
|
+
(monthDiff === 0 && referenceDate.getDate() < birthDate.getDate())) {
|
|
69
|
+
age--;
|
|
70
|
+
}
|
|
71
|
+
return age;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Formats a Date object to YYYY-MM-DD.
|
|
75
|
+
*/
|
|
76
|
+
function formatDateToIso(year, month, day) {
|
|
77
|
+
const mm = String(month).padStart(2, '0');
|
|
78
|
+
const dd = String(day).padStart(2, '0');
|
|
79
|
+
return `${year}-${mm}-${dd}`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Converts an Old NIC (9 digits + V/X) to New NIC (12 digits).
|
|
83
|
+
*/
|
|
84
|
+
export function convertOldToNewNic(oldNic) {
|
|
85
|
+
const clean = sanitizeNic(oldNic);
|
|
86
|
+
if (!/^[0-9]{9}[VX]$/.test(clean))
|
|
87
|
+
return null;
|
|
88
|
+
const year = '19' + clean.substring(0, 2);
|
|
89
|
+
const days = clean.substring(2, 5);
|
|
90
|
+
const serial = '0' + clean.substring(5, 8);
|
|
91
|
+
const check = clean.substring(8, 9);
|
|
92
|
+
return `${year}${days}${serial}${check}`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Converts a New NIC (12 digits) to Old NIC (9 digits + V) if born between 1900 and 1999.
|
|
96
|
+
*/
|
|
97
|
+
export function convertNewToOldNic(newNic) {
|
|
98
|
+
const clean = sanitizeNic(newNic);
|
|
99
|
+
if (!/^[0-9]{12}$/.test(clean))
|
|
100
|
+
return null;
|
|
101
|
+
const year = parseInt(clean.substring(0, 4), 10);
|
|
102
|
+
// Old NIC format was only used for individuals born before 2000
|
|
103
|
+
if (year < 1900 || year >= 2000)
|
|
104
|
+
return null;
|
|
105
|
+
// The 8th digit (index 7) in converted new NIC is always '0'
|
|
106
|
+
if (clean[7] !== '0')
|
|
107
|
+
return null;
|
|
108
|
+
const shortYear = clean.substring(2, 4);
|
|
109
|
+
const days = clean.substring(4, 7);
|
|
110
|
+
const serial = clean.substring(8, 11);
|
|
111
|
+
const check = clean.substring(11, 12);
|
|
112
|
+
return `${shortYear}${days}${serial}${check}V`;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Converts an NIC to its alternate equivalent format if applicable.
|
|
116
|
+
*/
|
|
117
|
+
export function convertNic(nic) {
|
|
118
|
+
const clean = sanitizeNic(nic);
|
|
119
|
+
if (clean.length === 10)
|
|
120
|
+
return convertOldToNewNic(clean);
|
|
121
|
+
if (clean.length === 12)
|
|
122
|
+
return convertNewToOldNic(clean);
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Validates and extracts comprehensive identity information from a Sri Lankan NIC.
|
|
127
|
+
* Supports both Old (9 digits + V/X) and New (12 digits) formats.
|
|
128
|
+
*/
|
|
129
|
+
export function validateNic(input) {
|
|
130
|
+
const clean = sanitizeNic(input);
|
|
131
|
+
if (!clean) {
|
|
132
|
+
return {
|
|
133
|
+
isValid: false,
|
|
134
|
+
nic: input,
|
|
135
|
+
error: 'NIC input cannot be empty',
|
|
136
|
+
code: 'EMPTY_INPUT',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// Check length
|
|
140
|
+
if (clean.length !== 10 && clean.length !== 12) {
|
|
141
|
+
return {
|
|
142
|
+
isValid: false,
|
|
143
|
+
nic: clean,
|
|
144
|
+
error: `Invalid NIC length (${clean.length}). Old NICs must be 10 characters (9 digits + V/X), New NICs must be 12 digits.`,
|
|
145
|
+
code: 'INVALID_LENGTH',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
let format;
|
|
149
|
+
let year;
|
|
150
|
+
let dayOfYearRaw;
|
|
151
|
+
let serialNumber;
|
|
152
|
+
let checkDigit;
|
|
153
|
+
let isVoter = true;
|
|
154
|
+
if (clean.length === 10) {
|
|
155
|
+
format = 'OLD';
|
|
156
|
+
if (!/^[0-9]{9}[VX]$/.test(clean)) {
|
|
157
|
+
return {
|
|
158
|
+
isValid: false,
|
|
159
|
+
nic: clean,
|
|
160
|
+
error: 'Old NIC format must contain exactly 9 numbers followed by V (voter) or X (non-voter).',
|
|
161
|
+
code: 'INVALID_CHARACTERS',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
year = 1900 + parseInt(clean.substring(0, 2), 10);
|
|
165
|
+
dayOfYearRaw = parseInt(clean.substring(2, 5), 10);
|
|
166
|
+
serialNumber = clean.substring(5, 8);
|
|
167
|
+
checkDigit = clean.substring(8, 9);
|
|
168
|
+
isVoter = clean.endsWith('V');
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
format = 'NEW';
|
|
172
|
+
if (!/^[0-9]{12}$/.test(clean)) {
|
|
173
|
+
return {
|
|
174
|
+
isValid: false,
|
|
175
|
+
nic: clean,
|
|
176
|
+
error: 'New NIC format must contain exactly 12 numeric digits.',
|
|
177
|
+
code: 'INVALID_CHARACTERS',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
year = parseInt(clean.substring(0, 4), 10);
|
|
181
|
+
dayOfYearRaw = parseInt(clean.substring(4, 7), 10);
|
|
182
|
+
serialNumber = clean.substring(7, 11);
|
|
183
|
+
checkDigit = clean.substring(11, 12);
|
|
184
|
+
}
|
|
185
|
+
// Gender and day-of-year offset logic
|
|
186
|
+
let gender;
|
|
187
|
+
let dayOfYear;
|
|
188
|
+
if (dayOfYearRaw > 500) {
|
|
189
|
+
gender = 'Female';
|
|
190
|
+
dayOfYear = dayOfYearRaw - 500;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
gender = 'Male';
|
|
194
|
+
dayOfYear = dayOfYearRaw;
|
|
195
|
+
}
|
|
196
|
+
// Validate day of year range (1 - 366)
|
|
197
|
+
if (dayOfYear < 1 || dayOfYear > 366) {
|
|
198
|
+
return {
|
|
199
|
+
isValid: false,
|
|
200
|
+
nic: clean,
|
|
201
|
+
error: `Invalid birth day-of-year (${dayOfYearRaw}). Day of year must be between 001-366 for males, or 501-866 for females.`,
|
|
202
|
+
code: 'INVALID_DAY_OF_YEAR',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
// Compute exact Date of Birth
|
|
206
|
+
const { month, day, isFeb29Adjustment } = resolveDrpDate(year, dayOfYear);
|
|
207
|
+
const dateOfBirth = new Date(Date.UTC(year, month - 1, day));
|
|
208
|
+
const now = new Date();
|
|
209
|
+
// Validate against future birth dates
|
|
210
|
+
if (dateOfBirth.getTime() > now.getTime()) {
|
|
211
|
+
return {
|
|
212
|
+
isValid: false,
|
|
213
|
+
nic: clean,
|
|
214
|
+
error: `Extracted date of birth (${formatDateToIso(year, month, day)}) is in the future.`,
|
|
215
|
+
code: 'FUTURE_BIRTH_DATE',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const age = calculateAge(dateOfBirth, now);
|
|
219
|
+
if (format === 'NEW') {
|
|
220
|
+
isVoter = age >= 18;
|
|
221
|
+
}
|
|
222
|
+
const equivalentNic = format === 'OLD' ? convertOldToNewNic(clean) : convertNewToOldNic(clean);
|
|
223
|
+
return {
|
|
224
|
+
isValid: true,
|
|
225
|
+
nic: clean,
|
|
226
|
+
format,
|
|
227
|
+
year,
|
|
228
|
+
dayOfYear,
|
|
229
|
+
gender,
|
|
230
|
+
dateOfBirth,
|
|
231
|
+
dateOfBirthFormatted: formatDateToIso(year, month, day),
|
|
232
|
+
age,
|
|
233
|
+
isVoter,
|
|
234
|
+
serialNumber,
|
|
235
|
+
checkDigit,
|
|
236
|
+
equivalentNic,
|
|
237
|
+
meta: {
|
|
238
|
+
isLeapYearRegistered: isFeb29Adjustment,
|
|
239
|
+
dayOfYearRaw,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Returns true if the given input is a valid Sri Lankan NIC.
|
|
245
|
+
*/
|
|
246
|
+
export function isValidNic(input) {
|
|
247
|
+
return validateNic(input).isValid;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Parses an NIC and returns the extracted payload or throws an Error.
|
|
251
|
+
*/
|
|
252
|
+
export function parseNic(input) {
|
|
253
|
+
const result = validateNic(input);
|
|
254
|
+
if (!result.isValid) {
|
|
255
|
+
throw new Error(`[lanka-id] ${result.error}`);
|
|
256
|
+
}
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Formats an NIC nicely for display (e.g. 962451234V or 199624501234).
|
|
261
|
+
*/
|
|
262
|
+
export function formatNic(input) {
|
|
263
|
+
const clean = sanitizeNic(input);
|
|
264
|
+
if (clean.length === 10) {
|
|
265
|
+
return `${clean.substring(0, 9)}${clean.charAt(9).toUpperCase()}`;
|
|
266
|
+
}
|
|
267
|
+
return clean;
|
|
268
|
+
}
|
package/lib/phone.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { PhoneResult } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Cleans phone input by removing spaces, brackets, hyphens, and leading +
|
|
4
|
+
*/
|
|
5
|
+
export declare function sanitizePhone(input: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Validates and parses a Sri Lankan phone number.
|
|
8
|
+
* Normalizes input variants like:
|
|
9
|
+
* - 0771234567
|
|
10
|
+
* - +94771234567
|
|
11
|
+
* - 0094771234567
|
|
12
|
+
* - 94771234567
|
|
13
|
+
* - 771234567
|
|
14
|
+
*/
|
|
15
|
+
export declare function validatePhone(input: string): PhoneResult;
|
|
16
|
+
/**
|
|
17
|
+
* Returns true if the phone number is a valid Sri Lankan phone number.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isValidPhone(input: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Formats a valid Sri Lankan phone number to E.164 (+94771234567).
|
|
22
|
+
* Returns null if invalid.
|
|
23
|
+
*/
|
|
24
|
+
export declare function toE164(input: string): string | null;
|
package/lib/phone.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mobile Carrier Prefix Mapping in Sri Lanka
|
|
3
|
+
*/
|
|
4
|
+
const MOBILE_PREFIX_MAP = {
|
|
5
|
+
'070': 'Mobitel',
|
|
6
|
+
'071': 'Mobitel',
|
|
7
|
+
'072': 'Hutch',
|
|
8
|
+
'074': 'Dialog',
|
|
9
|
+
'075': 'Airtel',
|
|
10
|
+
'076': 'Dialog',
|
|
11
|
+
'077': 'Dialog',
|
|
12
|
+
'078': 'Hutch',
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Major Fixed Line / Landline Area Codes in Sri Lanka
|
|
16
|
+
*/
|
|
17
|
+
const FIXED_AREA_CODES = {
|
|
18
|
+
'011': 'Colombo',
|
|
19
|
+
'021': 'Jaffna',
|
|
20
|
+
'023': 'Mannar',
|
|
21
|
+
'024': 'Vavuniya',
|
|
22
|
+
'025': 'Anuradhapura',
|
|
23
|
+
'026': 'Trincomalee',
|
|
24
|
+
'027': 'Polonnaruwa',
|
|
25
|
+
'031': 'Negombo',
|
|
26
|
+
'032': 'Chilaw',
|
|
27
|
+
'033': 'Gampaha',
|
|
28
|
+
'034': 'Kalutara',
|
|
29
|
+
'035': 'Kegalle',
|
|
30
|
+
'036': 'Avissawella',
|
|
31
|
+
'037': 'Kurunegala',
|
|
32
|
+
'038': 'Panadura',
|
|
33
|
+
'041': 'Matara',
|
|
34
|
+
'045': 'Ratnapura',
|
|
35
|
+
'047': 'Hambantota',
|
|
36
|
+
'051': 'Hatton',
|
|
37
|
+
'052': 'Nuwara Eliya',
|
|
38
|
+
'054': 'Nawalapitiya',
|
|
39
|
+
'055': 'Badulla',
|
|
40
|
+
'057': 'Bandarawela',
|
|
41
|
+
'063': 'Ampara',
|
|
42
|
+
'065': 'Batticaloa',
|
|
43
|
+
'066': 'Matale',
|
|
44
|
+
'067': 'Kalmunai',
|
|
45
|
+
'081': 'Kandy',
|
|
46
|
+
'091': 'Galle',
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Cleans phone input by removing spaces, brackets, hyphens, and leading +
|
|
50
|
+
*/
|
|
51
|
+
export function sanitizePhone(input) {
|
|
52
|
+
if (typeof input !== 'string')
|
|
53
|
+
return '';
|
|
54
|
+
return input.trim().replace(/[\s\-\(\)\.]/g, '');
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Validates and parses a Sri Lankan phone number.
|
|
58
|
+
* Normalizes input variants like:
|
|
59
|
+
* - 0771234567
|
|
60
|
+
* - +94771234567
|
|
61
|
+
* - 0094771234567
|
|
62
|
+
* - 94771234567
|
|
63
|
+
* - 771234567
|
|
64
|
+
*/
|
|
65
|
+
export function validatePhone(input) {
|
|
66
|
+
const raw = typeof input === 'string' ? input.trim() : '';
|
|
67
|
+
const sanitized = sanitizePhone(input);
|
|
68
|
+
if (!sanitized) {
|
|
69
|
+
return {
|
|
70
|
+
isValid: false,
|
|
71
|
+
raw,
|
|
72
|
+
error: 'Phone number cannot be empty',
|
|
73
|
+
code: 'EMPTY_INPUT',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// Handle leading international plus/zeros
|
|
77
|
+
let normalized = sanitized;
|
|
78
|
+
if (normalized.startsWith('+94')) {
|
|
79
|
+
normalized = normalized.substring(3);
|
|
80
|
+
}
|
|
81
|
+
else if (normalized.startsWith('0094')) {
|
|
82
|
+
normalized = normalized.substring(4);
|
|
83
|
+
}
|
|
84
|
+
else if (normalized.startsWith('94')) {
|
|
85
|
+
normalized = normalized.substring(2);
|
|
86
|
+
}
|
|
87
|
+
// If user omitted leading 0 (e.g. 771234567, length 9)
|
|
88
|
+
if (normalized.length === 9 && !normalized.startsWith('0')) {
|
|
89
|
+
normalized = '0' + normalized;
|
|
90
|
+
}
|
|
91
|
+
// Must be 10 digits starting with 0
|
|
92
|
+
if (!/^[0-9]+$/.test(normalized)) {
|
|
93
|
+
return {
|
|
94
|
+
isValid: false,
|
|
95
|
+
raw,
|
|
96
|
+
error: 'Phone number must contain only numbers',
|
|
97
|
+
code: 'INVALID_CHARACTERS',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (normalized.length !== 10 || !normalized.startsWith('0')) {
|
|
101
|
+
return {
|
|
102
|
+
isValid: false,
|
|
103
|
+
raw,
|
|
104
|
+
error: `Invalid Sri Lankan phone number length. Expected 10 digits starting with 0, got ${normalized.length} digits.`,
|
|
105
|
+
code: 'INVALID_LENGTH',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const prefix = normalized.substring(0, 3);
|
|
109
|
+
let carrier = 'Unknown';
|
|
110
|
+
let type = 'Unknown';
|
|
111
|
+
if (MOBILE_PREFIX_MAP[prefix]) {
|
|
112
|
+
carrier = MOBILE_PREFIX_MAP[prefix];
|
|
113
|
+
type = 'Mobile';
|
|
114
|
+
}
|
|
115
|
+
else if (FIXED_AREA_CODES[prefix]) {
|
|
116
|
+
carrier = 'SLT';
|
|
117
|
+
type = 'Fixed';
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
return {
|
|
121
|
+
isValid: false,
|
|
122
|
+
raw,
|
|
123
|
+
error: `Unknown Sri Lankan telephone area code or mobile operator prefix: ${prefix}`,
|
|
124
|
+
code: 'UNKNOWN_PREFIX',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const rest = normalized.substring(3);
|
|
128
|
+
const part1 = rest.substring(0, 3);
|
|
129
|
+
const part2 = rest.substring(3);
|
|
130
|
+
const e164 = `+94${normalized.substring(1)}`;
|
|
131
|
+
const national = `${prefix} ${part1} ${part2}`;
|
|
132
|
+
const international = `+94 ${prefix.substring(1)} ${part1} ${part2}`;
|
|
133
|
+
return {
|
|
134
|
+
isValid: true,
|
|
135
|
+
raw,
|
|
136
|
+
clean: normalized,
|
|
137
|
+
e164,
|
|
138
|
+
national,
|
|
139
|
+
international,
|
|
140
|
+
prefix,
|
|
141
|
+
carrier,
|
|
142
|
+
type,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Returns true if the phone number is a valid Sri Lankan phone number.
|
|
147
|
+
*/
|
|
148
|
+
export function isValidPhone(input) {
|
|
149
|
+
return validatePhone(input).isValid;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Formats a valid Sri Lankan phone number to E.164 (+94771234567).
|
|
153
|
+
* Returns null if invalid.
|
|
154
|
+
*/
|
|
155
|
+
export function toE164(input) {
|
|
156
|
+
const res = validatePhone(input);
|
|
157
|
+
return res.isValid ? res.e164 : null;
|
|
158
|
+
}
|