Skip to content

Commit 1a8e168

Browse files
authored
fix: add Bolivian CI (Cédula de Identidad) validator (#144)
1 parent 5451bba commit 1a8e168

5 files changed

Lines changed: 329 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ How you can help! This library currently support about half the countries in the
5555
| Bulgaria | BG | EGN | Person | ЕГН, Единен граждански номер, Bulgarian personal identity codes |
5656
| Bulgaria | BG | PNF | Person | PNF (ЛНЧ, Личен номер на чужденец, Bulgarian number of a foreigner). |
5757
| Bulgaria | BG | VAT | Company | Идентификационен номер по ДДС, Bulgarian VAT number |
58+
| Bolivia | BO | CI | Person | Person Identifier (Cédula de Identidad) |
5859
| Brazil | BR | CPF | Person | Brazilian identity number (Cadastro de Pessoas Físicas) |
5960
| Brazil | BR | CNPJ | Company | Brazilian company number (Cadastro Nacional da Pessoa Jurídica) |
6061
| Belarus | BY | UNP | Person/Company | Учетный номер плательщика, the Belarus VAT number |

src/bo/ci.spec.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { validate, format } from './ci';
2+
import { InvalidLength, InvalidFormat, InvalidComponent } from '../exceptions';
3+
4+
describe('bo/ci', () => {
5+
// Format tests
6+
it('format:1234567SC', () => {
7+
const result = format('1234567SC');
8+
9+
expect(result).toEqual('1234567-SC');
10+
});
11+
12+
it('format:12345678LP', () => {
13+
const result = format('12345678LP');
14+
15+
expect(result).toEqual('12345678-LP');
16+
});
17+
18+
it('format:1234567SC1', () => {
19+
const result = format('1234567SC1');
20+
21+
expect(result).toEqual('1234567-SC-1');
22+
});
23+
24+
it('format:1234567-OR-AB', () => {
25+
const result = format('1234567-OR-AB');
26+
27+
expect(result).toEqual('1234567-OR-AB');
28+
});
29+
30+
it('format: 9876543.PT.2', () => {
31+
const result = format('9876543.PT.2');
32+
33+
expect(result).toEqual('9876543-PT-2');
34+
});
35+
36+
// Valid cases
37+
it('validate:1234567-SC', () => {
38+
const result = validate('1234567-SC');
39+
40+
expect(result.isValid && result.compact).toEqual('1234567SC');
41+
});
42+
43+
it('validate:12345678-LP', () => {
44+
const result = validate('12345678-LP');
45+
46+
expect(result.isValid && result.compact).toEqual('12345678LP');
47+
});
48+
49+
it('validate:1234567-OR-1', () => {
50+
const result = validate('1234567-OR-1');
51+
52+
expect(result.isValid && result.compact).toEqual('1234567OR1');
53+
});
54+
55+
it('validate:9876543.CB.AB', () => {
56+
const result = validate('9876543.CB.AB');
57+
58+
expect(result.isValid && result.compact).toEqual('9876543CBAB');
59+
});
60+
61+
it('validate:5555555-PT', () => {
62+
const result = validate('5555555-PT');
63+
64+
expect(result.isValid && result.compact).toEqual('5555555PT');
65+
});
66+
67+
it('validate:12345678-CH-A', () => {
68+
const result = validate('12345678-CH-A');
69+
70+
expect(result.isValid && result.compact).toEqual('12345678CHA');
71+
});
72+
73+
it('validate:7654321-TJ-99', () => {
74+
const result = validate('7654321-TJ-99');
75+
76+
expect(result.isValid && result.compact).toEqual('7654321TJ99');
77+
});
78+
79+
it('validate:8888888-BE', () => {
80+
const result = validate('8888888-BE');
81+
82+
expect(result.isValid && result.compact).toEqual('8888888BE');
83+
});
84+
85+
it('validate:11111111-PD-B', () => {
86+
const result = validate('11111111-PD-B');
87+
88+
expect(result.isValid && result.compact).toEqual('11111111PDB');
89+
});
90+
91+
// Invalid length - too short
92+
it('validate:123-SC', () => {
93+
const result = validate('123-SC');
94+
95+
expect(result.error).toBeInstanceOf(InvalidLength);
96+
});
97+
98+
it('validate:123456-LP', () => {
99+
const result = validate('123456-LP');
100+
101+
expect(result.error).toBeInstanceOf(InvalidLength);
102+
});
103+
104+
// Invalid length - too long
105+
it('validate:123456789-OR', () => {
106+
const result = validate('123456789-OR');
107+
108+
expect(result.error).toBeInstanceOf(InvalidLength);
109+
});
110+
111+
// Invalid format - extension too long (3 characters)
112+
it('validate:1234567-SC-ABC', () => {
113+
const result = validate('1234567-SC-ABC');
114+
115+
expect(result.error).toBeInstanceOf(InvalidFormat);
116+
});
117+
118+
// Invalid format - special characters
119+
it('validate:1234567=SC', () => {
120+
const result = validate('1234567=SC');
121+
122+
expect(result.error).toBeInstanceOf(InvalidFormat);
123+
});
124+
125+
// Invalid format - letters instead of numbers
126+
it('validate:ABCDEFG-SC', () => {
127+
const result = validate('ABCDEFG-SC');
128+
129+
expect(result.error).toBeInstanceOf(InvalidFormat);
130+
});
131+
132+
// Invalid format - special characters in extension
133+
it('validate:1234567-SC-@#', () => {
134+
const result = validate('1234567-SC-@#');
135+
136+
expect(result.error).toBeInstanceOf(InvalidFormat);
137+
});
138+
139+
// Invalid length - department code with numbers (total length check)
140+
it('validate:9999999-12', () => {
141+
const result = validate('9999999-12');
142+
143+
expect(result.error).toBeInstanceOf(InvalidLength);
144+
});
145+
146+
// Invalid length - department code mixed (total length check)
147+
it('validate:1234567-1C', () => {
148+
const result = validate('1234567-1C');
149+
150+
expect(result.error).toBeInstanceOf(InvalidLength);
151+
});
152+
153+
// Invalid department code
154+
it('validate:1234567-XX', () => {
155+
const result = validate('1234567-XX');
156+
157+
expect(result.error).toBeInstanceOf(InvalidComponent);
158+
});
159+
160+
it('validate:1234567-BR', () => {
161+
const result = validate('1234567-BR');
162+
163+
expect(result.error).toBeInstanceOf(InvalidComponent);
164+
});
165+
166+
it('validate:12345678-ZZ', () => {
167+
const result = validate('12345678-ZZ');
168+
169+
expect(result.error).toBeInstanceOf(InvalidComponent);
170+
});
171+
172+
it('validate:1234567-AA-1', () => {
173+
const result = validate('1234567-AA-1');
174+
175+
expect(result.error).toBeInstanceOf(InvalidComponent);
176+
});
177+
178+
it('validate:9876543-QQ', () => {
179+
const result = validate('9876543-QQ');
180+
181+
expect(result.error).toBeInstanceOf(InvalidComponent);
182+
});
183+
});

src/bo/ci.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* CI - Cédula de Identidad (Bolivia)
3+
*
4+
* The Bolivian CI is a national identity document.
5+
* It consists of 7-8 digits followed by a department code and an optional extension.
6+
*
7+
* Format: XXXXXXX-DD-E or XXXXXXXX-DD-E
8+
* Where:
9+
* - X = digits (7 or 8)
10+
* - DD = department code (2 letters)
11+
* - E = extension (optional, 1-2 characters)
12+
*
13+
* Source: https://www.segip.gob.bo/
14+
*/
15+
16+
import * as exceptions from '../exceptions';
17+
import { strings } from '../util';
18+
import { Validator, ValidateReturn } from '../types';
19+
20+
// Valid department codes for Bolivia
21+
const VALID_DEPARTMENTS = [
22+
'LP', // La Paz
23+
'OR', // Oruro
24+
'PT', // Potosí
25+
'CB', // Cochabamba
26+
'CH', // Chuquisaca
27+
'TJ', // Tarija
28+
'SC', // Santa Cruz
29+
'BE', // Beni
30+
'PD', // Pando
31+
];
32+
33+
function clean(input: string): ReturnType<typeof strings.cleanUnicode> {
34+
return strings.cleanUnicode(input, ' -.');
35+
}
36+
37+
const impl: Validator = {
38+
name: 'Bolivian National Identity Card',
39+
localName: 'Cédula de Identidad',
40+
abbreviation: 'CI',
41+
42+
compact(input: string): string {
43+
const [value, err] = clean(input);
44+
45+
if (err) {
46+
throw err;
47+
}
48+
49+
return value;
50+
},
51+
52+
format(input: string): string {
53+
const [value] = clean(input);
54+
55+
// Format: XXXXXXX-DD or XXXXXXX-DD-E
56+
const match = value.match(/^(\d{7,8})([A-Z]{2})(\w{0,2})$/);
57+
58+
if (!match) {
59+
return value;
60+
}
61+
62+
const [, number, dept, ext] = match;
63+
64+
if (ext) {
65+
return `${number}-${dept}-${ext}`;
66+
}
67+
68+
return `${number}-${dept}`;
69+
},
70+
71+
validate(input: string): ValidateReturn {
72+
const [value, error] = clean(input);
73+
74+
if (error) {
75+
return { isValid: false, error };
76+
}
77+
78+
// Basic format check: must contain only alphanumeric characters
79+
if (!/^[A-Z0-9]+$/i.test(value)) {
80+
return { isValid: false, error: new exceptions.InvalidFormat() };
81+
}
82+
83+
// More flexible initial match to extract parts
84+
const basicMatch = value.match(/^(\d+)(.*)$/);
85+
86+
if (!basicMatch) {
87+
return { isValid: false, error: new exceptions.InvalidFormat() };
88+
}
89+
90+
const [, numberPart, rest] = basicMatch;
91+
92+
// Validate number part length (7 or 8 digits)
93+
if (numberPart.length < 7 || numberPart.length > 8) {
94+
return { isValid: false, error: new exceptions.InvalidLength() };
95+
}
96+
97+
// Check if we have at least department code
98+
if (rest.length < 2) {
99+
return { isValid: false, error: new exceptions.InvalidLength() };
100+
}
101+
102+
// Extract department and extension
103+
const department = rest.substring(0, 2).toUpperCase();
104+
const extension = rest.substring(2);
105+
106+
// Department must be letters only
107+
if (!/^[A-Z]{2}$/.test(department)) {
108+
return { isValid: false, error: new exceptions.InvalidFormat() };
109+
}
110+
111+
// Validate department code
112+
if (!VALID_DEPARTMENTS.includes(department)) {
113+
return { isValid: false, error: new exceptions.InvalidComponent() };
114+
}
115+
116+
// Validate extension if present (alphanumeric, max 2 chars)
117+
if (extension.length > 2) {
118+
return { isValid: false, error: new exceptions.InvalidFormat() };
119+
}
120+
121+
if (extension && !/^[A-Z0-9]+$/i.test(extension)) {
122+
return { isValid: false, error: new exceptions.InvalidFormat() };
123+
}
124+
125+
// Final length check
126+
const totalLength =
127+
numberPart.length + department.length + extension.length;
128+
if (totalLength < 9 || totalLength > 12) {
129+
return { isValid: false, error: new exceptions.InvalidLength() };
130+
}
131+
132+
return {
133+
isValid: true,
134+
compact: value.toUpperCase(),
135+
isIndividual: true,
136+
isCompany: false,
137+
};
138+
},
139+
};
140+
141+
export const { name, localName, abbreviation, validate, format, compact } =
142+
impl;

src/bo/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * as ci from './ci';

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as AZ from './az';
88
import * as BA from './ba';
99
import * as BE from './be';
1010
import * as BG from './bg';
11+
import * as BO from './bo';
1112
import * as BR from './br';
1213
import * as BY from './by';
1314
import * as BZ from './bz';
@@ -103,6 +104,7 @@ export const stdnum: Record<string, Record<string, Validator>> = {
103104
BA,
104105
BE,
105106
BG,
107+
BO,
106108
BR,
107109
BY,
108110
BZ,

0 commit comments

Comments
 (0)