Skip to content

Commit 2da3dc2

Browse files
authored
feat(#57): add Dominican Republic cédula validator (#158)
1 parent 3625501 commit 2da3dc2

11 files changed

Lines changed: 930 additions & 22 deletions

CHANGELOG.md

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

1212
- Costa Rica (CRI) Cédula de Identidad validator — validates the 9-digit national ID (`P-TTTT-AAAA`: province 1-9, tomo, asiento); notably has no check digit, since Costa Rica confirms validity via Registro Civil / TRIBU-CR database lookup rather than arithmetic ([#56](https://github.com/identique/idnumbers-npm/issues/56))
13+
- Dominican Republic (DOM) Cédula de Identidad y Electoral validator — 11-digit number (series + document number + check digit) validated with a standard Luhn checksum, plus a documented 576-entry exception list (sourced from `python-stdnum`, with attribution) covering legitimately-issued cédulas — including modern 402-series cards — that fail the Luhn check; a `validate()` result of `false` means the checksum failed, not that the person does not exist ([#57](https://github.com/identique/idnumbers-npm/issues/57))
1314
- Ecuador (ECU) Cédula de Identidad validator — 10-digit national ID with province code (01-24, or 30 for citizens registered abroad), person-type digit (0-5, narrowed to 4-5 for the consular province 30), and a Luhn (mod 10) check digit; adds `parse()` field decomposition and registers ECU/EC in the country registry ([#55](https://github.com/identique/idnumbers-npm/issues/55))
1415

1516
## [1.9.0] - 2026-07-17

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
# idnumbers
22

3-
A comprehensive TypeScript/JavaScript library for validating and parsing national identification numbers from 83 countries across 6 continents.
3+
A comprehensive TypeScript/JavaScript library for validating and parsing national identification numbers from 84 countries across 6 continents.
44

55
[![npm version](https://img.shields.io/npm/v/idnumbers.svg)](https://www.npmjs.com/package/idnumbers)
66
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
77
[![TypeScript](https://img.shields.io/badge/TypeScript-5.2-blue.svg)](https://www.typescriptlang.org/)
88

99
## Features
1010

11-
-**83 countries supported** - Comprehensive coverage across all continents
11+
-**84 countries supported** - Comprehensive coverage across all continents
1212
- 🔍 **Validation** - Verify ID number format and checksums
1313
- 📊 **Parsing** - Extract information like birth date, gender, and citizenship
1414
- 🛡️ **Type-safe** - Full TypeScript support with type definitions
@@ -195,12 +195,13 @@ console.log(format);
195195

196196
## Supported Countries
197197

198-
### North America (4)
198+
### North America (5)
199199

200200
- 🇺🇸 **USA** - Social Security Number (SSN)
201201
- 🇨🇦 **CAN** - Social Insurance Number (SIN)
202202
- 🇲🇽 **MEX** - CURP (Clave Única de Registro de Población)
203203
- 🇨🇷 **CRI** - Cédula de Identidad
204+
- 🇩🇴 **DOM** - Cédula de Identidad y Electoral
204205

205206
### South America (6)
206207

@@ -476,7 +477,7 @@ if (!validation.valid) {
476477

477478
## Testing
478479

479-
The library includes comprehensive test coverage with 2270 tests covering:
480+
The library includes comprehensive test coverage with 2303 tests covering:
480481

481482
- Format validation
482483
- Checksum verification

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "idnumbers",
33
"version": "1.9.0",
4-
"description": "A TypeScript library for verifying and parsing national ID numbers - supports 83 countries across 6 continents including USA, UK, France, Germany, Japan, China, India, Brazil, and many more",
4+
"description": "A TypeScript library for verifying and parsing national ID numbers - supports 84 countries across 6 continents including USA, UK, France, Germany, Japan, China, India, Brazil, and many more",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",
77
"scripts": {

src/__tests__/getCountryIdFormat-migration.test.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ describe('getCountryIdFormat returns IdFormat for all registered countries', ()
4141
{ code: 'CHL', name: 'Chile', idType: 'RUN/RUT' },
4242
{ code: 'CHN', name: 'China', idType: 'Resident Identity Number' },
4343
{ code: 'COL', name: 'Colombia', idType: 'Unique Personal ID' },
44+
{ code: 'DOM', name: 'Dominican Republic', idType: 'Cédula de Identidad y Electoral' },
4445
{ code: 'EST', name: 'Estonia', idType: 'Personal ID Number' },
4546
{ code: 'GRC', name: 'Greece', idType: 'Tax Identity Number' },
4647
{ code: 'HUN', name: 'Hungary', idType: 'Personal ID Number' },
@@ -127,6 +128,7 @@ describe('Format display strings', () => {
127128
{ code: 'CHL', format: '##.###.###-C' },
128129
{ code: 'COL', format: '##(#).###.###-C' },
129130
{ code: 'CRI', format: '#-####-####' },
131+
{ code: 'DOM', format: 'NNN-NNNNNNN-N' },
130132
{ code: 'ECU', format: 'PPTSSSSSSC' },
131133
{ code: 'IND', format: 'XXXX XXXX XXXX' },
132134
{ code: 'JPN', format: 'XXXXXXXXXXXX' },
@@ -236,21 +238,7 @@ describe('Edge cases and unregistered codes', () => {
236238

237239
// Former stub entries for unregistered countries now return null
238240
// (EC is no longer a stub -- it now resolves to ECU, see issue #55)
239-
const formerStubs = [
240-
'QA',
241-
'UY',
242-
'BO',
243-
'PY',
244-
'PA',
245-
'DO',
246-
'GT',
247-
'HN',
248-
'SV',
249-
'NI',
250-
'JO',
251-
'LB',
252-
'OM',
253-
];
241+
const formerStubs = ['QA', 'UY', 'BO', 'PY', 'PA', 'GT', 'HN', 'SV', 'NI', 'JO', 'LB', 'OM'];
254242

255243
test.each(formerStubs)('former stub %s now returns null', code => {
256244
expect(getCountryIdFormat(code)).toBeNull();

src/__tests__/issue-57-dom.test.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* Dominican Republic Cedula de Identidad y Electoral (Issue #57)
3+
*/
4+
import { Cedula } from '../countries/dom';
5+
import { CEDULA_LUHN_EXCEPTION_SET } from '../countries/dom/exceptions';
6+
import { validateNationalId, parseIdInfo } from '../index';
7+
8+
describe('Dominican Republic Cedula (DOM)', () => {
9+
describe('METADATA', () => {
10+
it('should have a valid example that passes validateNationalId', () => {
11+
expect(validateNationalId('DOM', Cedula.METADATA.example!).isValid).toBe(true);
12+
});
13+
14+
it('should declare the expected shape', () => {
15+
expect(Cedula.METADATA.iso3166Alpha2).toBe('DO');
16+
expect(Cedula.METADATA.minLength).toBe(11);
17+
expect(Cedula.METADATA.maxLength).toBe(11);
18+
expect(Cedula.METADATA.parsable).toBe(true);
19+
expect(Cedula.METADATA.checksum).toBe(true);
20+
expect(Cedula.METADATA.officialName).toBe('Cédula de Identidad y Electoral');
21+
});
22+
});
23+
24+
describe('validate() - standard Luhn vectors', () => {
25+
it('accepts a valid modern 402-series number (sum=8, check=2)', () => {
26+
expect(Cedula.validate('40200000012')).toBe(true);
27+
});
28+
29+
it('accepts a valid legacy-series number (sum=3, check=7)', () => {
30+
expect(Cedula.validate('00100000017')).toBe(true);
31+
});
32+
33+
it('accepts a valid number that exercises the >9 digit-doubling fold', () => {
34+
// 5*2=10->1, 7*2=14->5; sum=33, check=7
35+
expect(Cedula.validate('03112345677')).toBe(true);
36+
});
37+
38+
it('rejects the 402-series vector with the check digit off by one', () => {
39+
expect(Cedula.validate('40200000013')).toBe(false);
40+
});
41+
42+
it('rejects the legacy-series vector with the check digit off by one', () => {
43+
expect(Cedula.validate('00100000018')).toBe(false);
44+
});
45+
46+
it('rejects the fold-exercising vector with the check digit off by one', () => {
47+
expect(Cedula.validate('03112345678')).toBe(false);
48+
});
49+
});
50+
51+
describe('validate() - exception list (the critical case)', () => {
52+
it('accepts a real modern-series cedula that fails standard Luhn', () => {
53+
// Without the exception list this returns false: 4,0,2,0,0,0,4,0,1,3 -> check=9, not 4.
54+
expect(Cedula.validate('40200401324')).toBe(true);
55+
});
56+
57+
it('rejects the exception-list vector if the check digit is altered', () => {
58+
// Sanity check: the exception match is exact-string, not "close enough".
59+
expect(Cedula.validate('40200401325')).toBe(false);
60+
});
61+
});
62+
63+
describe('validate() - series range', () => {
64+
it('does not reject series "000"', () => {
65+
expect(Cedula.validate('00000000018')).toBe(true);
66+
});
67+
});
68+
69+
describe('validate() - format handling', () => {
70+
it('accepts the canonical NNN-NNNNNNN-N display format', () => {
71+
expect(Cedula.validate('402-0000001-2')).toBe(true);
72+
});
73+
74+
it('accepts input with surrounding/embedded whitespace', () => {
75+
expect(Cedula.validate(' 402 0000001 2 ')).toBe(true);
76+
});
77+
78+
it('rejects non-string input', () => {
79+
expect(Cedula.validate(undefined as unknown as string)).toBe(false);
80+
expect(Cedula.validate(12345678901 as unknown as string)).toBe(false);
81+
});
82+
83+
it('rejects input that is too short', () => {
84+
expect(Cedula.validate('4020000001')).toBe(false);
85+
});
86+
87+
it('rejects input that is too long', () => {
88+
expect(Cedula.validate('402000000122')).toBe(false);
89+
});
90+
91+
it('rejects input containing non-digit characters', () => {
92+
expect(Cedula.validate('4020000001A')).toBe(false);
93+
});
94+
});
95+
96+
describe('parse()', () => {
97+
it('returns series, sequence, and checkDigit for a valid number', () => {
98+
expect(Cedula.parse('40200000012')).toEqual({
99+
series: '402',
100+
sequence: '0000001',
101+
checkDigit: 2,
102+
});
103+
});
104+
105+
it('parses formatted input identically to compact input', () => {
106+
expect(Cedula.parse('402-0000001-2')).toEqual(Cedula.parse('40200000012'));
107+
});
108+
109+
it('returns null for an invalid number', () => {
110+
expect(Cedula.parse('40200000013')).toBeNull();
111+
});
112+
});
113+
114+
describe('checksum()', () => {
115+
it('computes the expected Luhn check digit from the first 10 digits', () => {
116+
expect(Cedula.checksum('40200000012')).toBe(2);
117+
expect(Cedula.checksum('00100000017')).toBe(7);
118+
expect(Cedula.checksum('03112345677')).toBe(7);
119+
});
120+
});
121+
122+
describe('exception data set', () => {
123+
it('contains exactly 576 entries', () => {
124+
expect(CEDULA_LUHN_EXCEPTION_SET.size).toBe(576);
125+
});
126+
127+
it('contains only 11-digit numeric strings', () => {
128+
for (const entry of CEDULA_LUHN_EXCEPTION_SET) {
129+
expect(entry).toMatch(/^\d{11}$/);
130+
}
131+
});
132+
133+
it('does not contain the two known upstream 10-digit typo entries', () => {
134+
expect(CEDULA_LUHN_EXCEPTION_SET.has('0094662667')).toBe(false);
135+
expect(CEDULA_LUHN_EXCEPTION_SET.has('0710208838')).toBe(false);
136+
});
137+
138+
it('every entry fails the standard Luhn check (that is the point of the list)', () => {
139+
for (const entry of CEDULA_LUHN_EXCEPTION_SET) {
140+
const expectedCheck = Cedula.checksum(entry);
141+
const actualCheck = parseInt(entry[10], 10);
142+
expect(expectedCheck).not.toBe(actualCheck);
143+
}
144+
});
145+
});
146+
147+
describe('registry integration', () => {
148+
it('validates via the DOM alpha-3 key', () => {
149+
expect(validateNationalId('DOM', '40200000012').isValid).toBe(true);
150+
});
151+
152+
it('validates via the DO alpha-2 alias with the same result', () => {
153+
const viaAlpha3 = validateNationalId('DOM', '40200000012');
154+
const viaAlpha2 = validateNationalId('DO', '40200000012');
155+
expect(viaAlpha2.isValid).toBe(viaAlpha3.isValid);
156+
expect(viaAlpha2.countryCode).toBe('DOM');
157+
});
158+
159+
it('parses via the registry for a valid number', () => {
160+
expect(parseIdInfo('DOM', '40200000012')).toEqual({
161+
series: '402',
162+
sequence: '0000001',
163+
checkDigit: 2,
164+
});
165+
});
166+
167+
it('returns null via the registry for an invalid number', () => {
168+
expect(parseIdInfo('DOM', '40200000013')).toBeNull();
169+
});
170+
});
171+
});

src/__tests__/parseIdInfo-migration.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import { adaptMetadata, createValidator } from '../registry/adapters';
1515
// Registry population tests
1616
// ---------------------------------------------------------------------------
1717
describe('Registry population', () => {
18-
it('should have 83 primary keys registered', () => {
19-
expect(registry.list().length).toBe(83);
18+
it('should have 84 primary keys registered', () => {
19+
expect(registry.list().length).toBe(84);
2020
});
2121

2222
it('should resolve all expected alpha-3 keys', () => {
@@ -103,6 +103,7 @@ describe('Registry population', () => {
103103
'TWN',
104104
'VEN',
105105
'CRI',
106+
'DOM',
106107
'ECU',
107108
];
108109

@@ -155,6 +156,7 @@ describe('Registry population', () => {
155156
RS: 'SRB',
156157
TW: 'TWN',
157158
VE: 'VEN',
159+
DO: 'DOM',
158160
ID: 'IDN',
159161
KR: 'KOR',
160162
MX: 'MEX',
@@ -375,6 +377,7 @@ describe('parseIdInfo parity (registry vs old switch)', () => {
375377
{ code: 'SRB', alias: 'RS', validId: '0101990700002', description: 'Serbia JMBG' },
376378
{ code: 'TWN', alias: 'TW', validId: 'A123456789', description: 'Taiwan National ID' },
377379
{ code: 'VEN', alias: 'VE', validId: 'V-12345678', description: 'Venezuela Cedula' },
380+
{ code: 'DOM', alias: 'DO', validId: '40200000012', description: 'Dominican Republic Cedula' },
378381
{ code: 'IDN', alias: 'ID', validId: '1101010101900001', description: 'Indonesia NIK' },
379382
{ code: 'KOR', alias: 'KR', validId: '800101-1234567', description: 'South Korea RRN' },
380383
{ code: 'MEX', alias: 'MX', validId: 'HEGG560427MVZRRL04', description: 'Mexico CURP' },

0 commit comments

Comments
 (0)