From ddb5c2f97ccfa21c1a6f58e74edca9fe2d75962d Mon Sep 17 00:00:00 2001 From: Juliano Bazzi Date: Wed, 21 Jan 2026 15:10:18 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20add=20Bolivian=20CI=20(C=C3=A9dula=20de?= =?UTF-8?q?=20Identidad)=20validator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + src/bo/ci.spec.ts | 183 ++++++++++++++++++++++++++++++++++++++++++++++ src/bo/ci.ts | 142 +++++++++++++++++++++++++++++++++++ src/bo/index.ts | 1 + src/index.ts | 2 + 5 files changed, 329 insertions(+) create mode 100644 src/bo/ci.spec.ts create mode 100644 src/bo/ci.ts create mode 100644 src/bo/index.ts diff --git a/README.md b/README.md index 8277c57..916e912 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ How you can help! This library currently support about half the countries in the | Bulgaria | BG | EGN | Person | ЕГН, Единен граждански номер, Bulgarian personal identity codes | | Bulgaria | BG | PNF | Person | PNF (ЛНЧ, Личен номер на чужденец, Bulgarian number of a foreigner). | | Bulgaria | BG | VAT | Company | Идентификационен номер по ДДС, Bulgarian VAT number | +| Bolivia | BO | CI | Person | Person Identifier (Cédula de Identidad) | | Brazil | BR | CPF | Person | Brazilian identity number (Cadastro de Pessoas Físicas) | | Brazil | BR | CNPJ | Company | Brazilian company number (Cadastro Nacional da Pessoa Jurídica) | | Belarus | BY | UNP | Person/Company | Учетный номер плательщика, the Belarus VAT number | diff --git a/src/bo/ci.spec.ts b/src/bo/ci.spec.ts new file mode 100644 index 0000000..e721925 --- /dev/null +++ b/src/bo/ci.spec.ts @@ -0,0 +1,183 @@ +import { validate, format } from './ci'; +import { InvalidLength, InvalidFormat, InvalidComponent } from '../exceptions'; + +describe('bo/ci', () => { + // Format tests + it('format:1234567SC', () => { + const result = format('1234567SC'); + + expect(result).toEqual('1234567-SC'); + }); + + it('format:12345678LP', () => { + const result = format('12345678LP'); + + expect(result).toEqual('12345678-LP'); + }); + + it('format:1234567SC1', () => { + const result = format('1234567SC1'); + + expect(result).toEqual('1234567-SC-1'); + }); + + it('format:1234567-OR-AB', () => { + const result = format('1234567-OR-AB'); + + expect(result).toEqual('1234567-OR-AB'); + }); + + it('format: 9876543.PT.2', () => { + const result = format('9876543.PT.2'); + + expect(result).toEqual('9876543-PT-2'); + }); + + // Valid cases + it('validate:1234567-SC', () => { + const result = validate('1234567-SC'); + + expect(result.isValid && result.compact).toEqual('1234567SC'); + }); + + it('validate:12345678-LP', () => { + const result = validate('12345678-LP'); + + expect(result.isValid && result.compact).toEqual('12345678LP'); + }); + + it('validate:1234567-OR-1', () => { + const result = validate('1234567-OR-1'); + + expect(result.isValid && result.compact).toEqual('1234567OR1'); + }); + + it('validate:9876543.CB.AB', () => { + const result = validate('9876543.CB.AB'); + + expect(result.isValid && result.compact).toEqual('9876543CBAB'); + }); + + it('validate:5555555-PT', () => { + const result = validate('5555555-PT'); + + expect(result.isValid && result.compact).toEqual('5555555PT'); + }); + + it('validate:12345678-CH-A', () => { + const result = validate('12345678-CH-A'); + + expect(result.isValid && result.compact).toEqual('12345678CHA'); + }); + + it('validate:7654321-TJ-99', () => { + const result = validate('7654321-TJ-99'); + + expect(result.isValid && result.compact).toEqual('7654321TJ99'); + }); + + it('validate:8888888-BE', () => { + const result = validate('8888888-BE'); + + expect(result.isValid && result.compact).toEqual('8888888BE'); + }); + + it('validate:11111111-PD-B', () => { + const result = validate('11111111-PD-B'); + + expect(result.isValid && result.compact).toEqual('11111111PDB'); + }); + + // Invalid length - too short + it('validate:123-SC', () => { + const result = validate('123-SC'); + + expect(result.error).toBeInstanceOf(InvalidLength); + }); + + it('validate:123456-LP', () => { + const result = validate('123456-LP'); + + expect(result.error).toBeInstanceOf(InvalidLength); + }); + + // Invalid length - too long + it('validate:123456789-OR', () => { + const result = validate('123456789-OR'); + + expect(result.error).toBeInstanceOf(InvalidLength); + }); + + // Invalid format - extension too long (3 characters) + it('validate:1234567-SC-ABC', () => { + const result = validate('1234567-SC-ABC'); + + expect(result.error).toBeInstanceOf(InvalidFormat); + }); + + // Invalid format - special characters + it('validate:1234567=SC', () => { + const result = validate('1234567=SC'); + + expect(result.error).toBeInstanceOf(InvalidFormat); + }); + + // Invalid format - letters instead of numbers + it('validate:ABCDEFG-SC', () => { + const result = validate('ABCDEFG-SC'); + + expect(result.error).toBeInstanceOf(InvalidFormat); + }); + + // Invalid format - special characters in extension + it('validate:1234567-SC-@#', () => { + const result = validate('1234567-SC-@#'); + + expect(result.error).toBeInstanceOf(InvalidFormat); + }); + + // Invalid length - department code with numbers (total length check) + it('validate:9999999-12', () => { + const result = validate('9999999-12'); + + expect(result.error).toBeInstanceOf(InvalidLength); + }); + + // Invalid length - department code mixed (total length check) + it('validate:1234567-1C', () => { + const result = validate('1234567-1C'); + + expect(result.error).toBeInstanceOf(InvalidLength); + }); + + // Invalid department code + it('validate:1234567-XX', () => { + const result = validate('1234567-XX'); + + expect(result.error).toBeInstanceOf(InvalidComponent); + }); + + it('validate:1234567-BR', () => { + const result = validate('1234567-BR'); + + expect(result.error).toBeInstanceOf(InvalidComponent); + }); + + it('validate:12345678-ZZ', () => { + const result = validate('12345678-ZZ'); + + expect(result.error).toBeInstanceOf(InvalidComponent); + }); + + it('validate:1234567-AA-1', () => { + const result = validate('1234567-AA-1'); + + expect(result.error).toBeInstanceOf(InvalidComponent); + }); + + it('validate:9876543-QQ', () => { + const result = validate('9876543-QQ'); + + expect(result.error).toBeInstanceOf(InvalidComponent); + }); +}); diff --git a/src/bo/ci.ts b/src/bo/ci.ts new file mode 100644 index 0000000..2cb063c --- /dev/null +++ b/src/bo/ci.ts @@ -0,0 +1,142 @@ +/** + * CI - Cédula de Identidad (Bolivia) + * + * The Bolivian CI is a national identity document. + * It consists of 7-8 digits followed by a department code and an optional extension. + * + * Format: XXXXXXX-DD-E or XXXXXXXX-DD-E + * Where: + * - X = digits (7 or 8) + * - DD = department code (2 letters) + * - E = extension (optional, 1-2 characters) + * + * Source: https://www.segip.gob.bo/ + */ + +import * as exceptions from '../exceptions'; +import { strings } from '../util'; +import { Validator, ValidateReturn } from '../types'; + +// Valid department codes for Bolivia +const VALID_DEPARTMENTS = [ + 'LP', // La Paz + 'OR', // Oruro + 'PT', // Potosí + 'CB', // Cochabamba + 'CH', // Chuquisaca + 'TJ', // Tarija + 'SC', // Santa Cruz + 'BE', // Beni + 'PD', // Pando +]; + +function clean(input: string): ReturnType { + return strings.cleanUnicode(input, ' -.'); +} + +const impl: Validator = { + name: 'Bolivian National Identity Card', + localName: 'Cédula de Identidad', + abbreviation: 'CI', + + compact(input: string): string { + const [value, err] = clean(input); + + if (err) { + throw err; + } + + return value; + }, + + format(input: string): string { + const [value] = clean(input); + + // Format: XXXXXXX-DD or XXXXXXX-DD-E + const match = value.match(/^(\d{7,8})([A-Z]{2})(\w{0,2})$/); + + if (!match) { + return value; + } + + const [, number, dept, ext] = match; + + if (ext) { + return `${number}-${dept}-${ext}`; + } + + return `${number}-${dept}`; + }, + + validate(input: string): ValidateReturn { + const [value, error] = clean(input); + + if (error) { + return { isValid: false, error }; + } + + // Basic format check: must contain only alphanumeric characters + if (!/^[A-Z0-9]+$/i.test(value)) { + return { isValid: false, error: new exceptions.InvalidFormat() }; + } + + // More flexible initial match to extract parts + const basicMatch = value.match(/^(\d+)(.*)$/); + + if (!basicMatch) { + return { isValid: false, error: new exceptions.InvalidFormat() }; + } + + const [, numberPart, rest] = basicMatch; + + // Validate number part length (7 or 8 digits) + if (numberPart.length < 7 || numberPart.length > 8) { + return { isValid: false, error: new exceptions.InvalidLength() }; + } + + // Check if we have at least department code + if (rest.length < 2) { + return { isValid: false, error: new exceptions.InvalidLength() }; + } + + // Extract department and extension + const department = rest.substring(0, 2).toUpperCase(); + const extension = rest.substring(2); + + // Department must be letters only + if (!/^[A-Z]{2}$/.test(department)) { + return { isValid: false, error: new exceptions.InvalidFormat() }; + } + + // Validate department code + if (!VALID_DEPARTMENTS.includes(department)) { + return { isValid: false, error: new exceptions.InvalidComponent() }; + } + + // Validate extension if present (alphanumeric, max 2 chars) + if (extension.length > 2) { + return { isValid: false, error: new exceptions.InvalidFormat() }; + } + + if (extension && !/^[A-Z0-9]+$/i.test(extension)) { + return { isValid: false, error: new exceptions.InvalidFormat() }; + } + + // Final length check + const totalLength = + numberPart.length + department.length + extension.length; + if (totalLength < 9 || totalLength > 12) { + return { isValid: false, error: new exceptions.InvalidLength() }; + } + + return { + isValid: true, + compact: value.toUpperCase(), + isIndividual: true, + isCompany: false, + }; + }, +}; + +export const { name, localName, abbreviation, validate, format, compact } = + impl; diff --git a/src/bo/index.ts b/src/bo/index.ts new file mode 100644 index 0000000..135c7d1 --- /dev/null +++ b/src/bo/index.ts @@ -0,0 +1 @@ +export * as ci from './ci'; diff --git a/src/index.ts b/src/index.ts index b52050b..4974644 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import * as AZ from './az'; import * as BA from './ba'; import * as BE from './be'; import * as BG from './bg'; +import * as BO from './bo'; import * as BR from './br'; import * as BY from './by'; import * as BZ from './bz'; @@ -103,6 +104,7 @@ export const stdnum: Record> = { BA, BE, BG, + BO, BR, BY, BZ,