From f7b44abb7c6db5be061a4096dc5fff062b2a3aea Mon Sep 17 00:00:00 2001 From: Aaqibhafeez Khan Date: Wed, 28 Oct 2026 06:31:10 +0530 Subject: [PATCH 1/3] feat: add Romanian licence plate validation --- CHANGELOG.md | 4 +++ README.md | 23 ++++++++++---- src/index.ts | 2 ++ src/plate.ts | 79 ++++++++++++++++++++++++++++++++++++++++++++++ test/plate.test.ts | 52 ++++++++++++++++++++++++++++++ 5 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 src/plate.ts create mode 100644 test/plate.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c3f050d..a1990e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project are documented here. The format follows [Kee ## [Unreleased] +### Added + +- `validatePlate` / `isValidPlate` for Romanian registration plates, including county lookup and input normalization + ## [0.1.0] - 2026-10-27 ### Added diff --git a/README.md b/README.md index a82e679..77d80f1 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Try it in your browser

-Validate and decode Romanian identifiers (**CNP**, **CUI/CIF**, **IBAN** and postal codes) with error messages you can show directly to users. Zero dependencies, fully typed, runs in Node and in the browser. +Validate and decode Romanian identifiers (**CNP**, **CUI/CIF**, **IBAN**, postal codes and licence plates) with error messages you can show directly to users. Zero dependencies, fully typed, runs in Node and in the browser. ```bash npm install github:Robert-GeorgeDev/ValidRO @@ -25,13 +25,13 @@ The package is not on the npm registry yet; this installs it straight from GitHu - **Current rules.** Accepts county code `70`, which the civil-status system (SIIEASC) now assigns to newly generated CNPs. Many older validators reject these valid codes. - **Explains the error.** Messages are in Romanian and say what is wrong: `Cifra de control este greșită (așteptat 7)`. -- **Decodes, not just validates.** Sex, birth date, county, bank code. +- **Decodes, not just validates.** Sex, birth date, county, bank code and plate county. - **Private.** Everything runs locally. Nothing is sent anywhere. ## Usage ```ts -import { validateCnp, validateCui, validateIbanRo } from "validro"; +import { validateCnp, validateCui, validateIbanRo, validatePlate } from "validro"; validateCnp("3000101221147"); // { @@ -51,6 +51,12 @@ validateIbanRo("ro49 aaaa 1b31 0075 9384 0000"); // { valid: true, error: null, // data: { iban: "RO49AAAA1B31007593840000", formatted: "RO49 AAAA 1B31 0075 9384 0000", // bankCode: "AAAA", treasury: false } } + +validatePlate("cj-12 abc"); +// { valid: true, error: null, data: { plate: "CJ12ABC", countyCode: "CJ", county: "Cluj" } } + +validatePlate("B 123 ABC"); +// { valid: true, error: null, data: { plate: "B123ABC", countyCode: "B", county: "București" } } ``` Every `validate*` function returns the same shape, so you can handle all of them the same way: @@ -61,7 +67,7 @@ type ValidationResult = | { valid: false; error: string; data: null }; ``` -Only need true or false? Use `isValidCnp`, `isValidCui`, `isValidIbanRo` and `isValidPostalCode`. +Only need true or false? Use `isValidCnp`, `isValidCui`, `isValidIbanRo`, `isValidPostalCode` and `isValidPlate`. ## API @@ -71,11 +77,13 @@ Only need true or false? Use `isValidCnp`, `isValidCui`, `isValidIbanRo` and `is | `validateCui(cui)` | `ValidationResult` | | `validateIbanRo(iban)` | `ValidationResult` | | `validatePostalCode(postalCode)` | `ValidationResult`; exactly 6 digits | -| `isValidCnp`, `isValidCui`, `isValidIbanRo`, `isValidPostalCode` | `boolean` | +| `validatePlate(plate)` | `ValidationResult`; normalized plate and county details | +| `isValidCnp`, `isValidCui`, `isValidIbanRo`, `isValidPostalCode`, `isValidPlate` | `boolean` | | `cnpControlDigit(first12)` | the CNP control digit | | `cuiControlDigit(body)` | the CUI control digit | | `ibanChecksumOk(iban)` | mod-97 check, works for any country | -| `CNP_COUNTIES` | county codes and names | +| `CNP_COUNTIES` | county codes and names for CNPs | +| `PLATE_COUNTIES` | registration-plate county codes and names | ## What is checked @@ -85,6 +93,7 @@ Only need true or false? Use `isValidCnp`, `isValidCui`, `isValidIbanRo` and `is | CUI / CIF | optional `RO` prefix, 2–10 digits, no leading zero, control digit (key `753217532`) | | IBAN (RO) | 24 characters: `RO`, 2 check digits, 4-letter bank code, 16 alphanumerics, ISO 13616 mod-97 | | Postal code | exactly 6 digits | +| Licence plate | known Romanian county code, 2 digits and 3 letters; București (`B`) accepts 2 or 3 digits; spaces, dots and dashes are ignored | Spaces, dots and dashes in the input are ignored. @@ -102,7 +111,7 @@ All examples in this repository are fictitious: sample CNPs use birth dates in t ## Roadmap -- [ ] Romanian licence plates +- [x] Romanian licence plates - [ ] Phone numbers, normalized to `+40` - [x] Postal codes - [ ] Python port diff --git a/src/index.ts b/src/index.ts index 6758db9..78ce769 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,5 +6,7 @@ export { validateIbanRo, isValidIbanRo, ibanChecksumOk } from "./iban.js"; export type { IbanInfo } from "./iban.js"; export { validatePostalCode, isValidPostalCode } from "./postal-code.js"; export type { PostalCodeInfo } from "./postal-code.js"; +export { validatePlate, isValidPlate, PLATE_COUNTIES } from "./plate.js"; +export type { PlateInfo } from "./plate.js"; export { CNP_COUNTIES } from "./counties.js"; export type { ValidationResult } from "./result.js"; diff --git a/src/plate.ts b/src/plate.ts new file mode 100644 index 0000000..c4ac14e --- /dev/null +++ b/src/plate.ts @@ -0,0 +1,79 @@ +import { clean, fail, ok, type ValidationResult } from "./result.js"; + +export const PLATE_COUNTIES: Readonly> = Object.freeze({ + AB: "Alba", + AR: "Arad", + AG: "Argeș", + BC: "Bacău", + BH: "Bihor", + BN: "Bistrița-Năsăud", + BT: "Botoșani", + BV: "Brașov", + BR: "Brăila", + BZ: "Buzău", + CS: "Caraș-Severin", + CL: "Călărași", + CT: "Constanța", + CV: "Covasna", + DB: "Dâmbovița", + DJ: "Dolj", + GL: "Galați", + GR: "Giurgiu", + GJ: "Gorj", + HR: "Harghita", + HD: "Hunedoara", + IL: "Ialomița", + IS: "Iași", + IF: "Ilfov", + MM: "Maramureș", + MH: "Mehedinți", + MS: "Mureș", + NT: "Neamț", + OT: "Olt", + PH: "Prahova", + SM: "Satu Mare", + SJ: "Sălaj", + SB: "Sibiu", + SV: "Suceava", + TR: "Teleorman", + TM: "Timiș", + TL: "Tulcea", + VS: "Vaslui", + VL: "Vâlcea", + VN: "Vrancea", + B: "București", +}); + +export interface PlateInfo { + plate: string; + countyCode: string; + county: string; +} + +/** + * Validates the standard Romanian registration-plate format. + * Spaces, dots and dashes are ignored; the returned plate is uppercase and compact. + */ +export function validatePlate(input: string): ValidationResult { + if (typeof input !== "string") return fail("Numărul de înmatriculare trebuie să fie un șir de caractere"); + + const plate = clean(input).toUpperCase(); + const bucharestMatch = /^B(\d{2,3})([A-Z]{3})$/.exec(plate); + const countyMatch = /^([A-Z]{2})(\d{2})([A-Z]{3})$/.exec(plate); + + let countyCode: string; + if (bucharestMatch) { + countyCode = "B"; + } else if (countyMatch) { + countyCode = countyMatch[1]; + } else { + return fail("Numărul trebuie să conțină indicativul județului, 2 cifre (3 pentru București sunt permise) și 3 litere"); + } + + const county = PLATE_COUNTIES[countyCode]; + if (!county) return fail("Indicativul județului nu este valid"); + + return ok({ plate, countyCode, county }); +} + +export const isValidPlate = (input: string): boolean => validatePlate(input).valid; diff --git a/test/plate.test.ts b/test/plate.test.ts new file mode 100644 index 0000000..45c861f --- /dev/null +++ b/test/plate.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { isValidPlate, validatePlate } from "../src/index.js"; + +describe("validatePlate", () => { + it("accepts county and Bucharest plates and returns county data", () => { + expect(validatePlate("CJ 12 ABC")).toEqual({ + valid: true, + error: null, + data: { plate: "CJ12ABC", countyCode: "CJ", county: "Cluj" }, + }); + expect(validatePlate("B 123 ABC").data).toEqual({ + plate: "B123ABC", + countyCode: "B", + county: "București", + }); + expect(validatePlate("B-12-ABC").valid).toBe(true); + }); + + it("normalizes separators and letter case", () => { + expect(validatePlate(" cj-12 abc ").data).toEqual({ + plate: "CJ12ABC", + countyCode: "CJ", + county: "Cluj", + }); + expect(validatePlate("b.123.abc").data?.plate).toBe("B123ABC"); + }); + + it("accepts known county codes", () => { + expect(isValidPlate("AB12ABC")).toBe(true); + expect(isValidPlate("IF12ABC")).toBe(true); + expect(isValidPlate("VN12ABC")).toBe(true); + }); + + it.each([ + "CJ1ABC", + "CJ1234ABC", + "CJ12AB", + "CJ12ABCD", + "XX12ABC", + "B1ABC", + "B1234ABC", + "B12AB", + "B123ABCD", + "12ABC", + ])("rejects invalid plate %s", (plate) => { + expect(isValidPlate(plate)).toBe(false); + }); + + it("explains malformed input", () => { + expect(validatePlate("CJ1ABC").error).toContain("indicativul județului"); + }); +}); From 33ac3b84c89695e947f714809bc581921d8faec4 Mon Sep 17 00:00:00 2001 From: Aaqibhafeez Khan Date: Wed, 28 Oct 2026 06:34:33 +0530 Subject: [PATCH 2/3] feat: add Romanian phone number validation --- CHANGELOG.md | 4 ++++ README.md | 18 +++++++++++++----- src/index.ts | 2 ++ src/phone-ro.ts | 39 +++++++++++++++++++++++++++++++++++++++ test/phone-ro.test.ts | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 src/phone-ro.ts create mode 100644 test/phone-ro.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c3f050d..d986813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project are documented here. The format follows [Kee ## [Unreleased] +### Added + +- `validatePhoneRo` / `isValidPhoneRo` for Romanian mobile and landline numbers, normalized to E.164 + ## [0.1.0] - 2026-10-27 ### Added diff --git a/README.md b/README.md index a82e679..011c30e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Try it in your browser

-Validate and decode Romanian identifiers (**CNP**, **CUI/CIF**, **IBAN** and postal codes) with error messages you can show directly to users. Zero dependencies, fully typed, runs in Node and in the browser. +Validate and decode Romanian identifiers (**CNP**, **CUI/CIF**, **IBAN**, phone numbers and postal codes) with error messages you can show directly to users. Zero dependencies, fully typed, runs in Node and in the browser. ```bash npm install github:Robert-GeorgeDev/ValidRO @@ -31,7 +31,7 @@ The package is not on the npm registry yet; this installs it straight from GitHu ## Usage ```ts -import { validateCnp, validateCui, validateIbanRo } from "validro"; +import { validateCnp, validateCui, validateIbanRo, validatePhoneRo } from "validro"; validateCnp("3000101221147"); // { @@ -51,6 +51,12 @@ validateIbanRo("ro49 aaaa 1b31 0075 9384 0000"); // { valid: true, error: null, // data: { iban: "RO49AAAA1B31007593840000", formatted: "RO49 AAAA 1B31 0075 9384 0000", // bankCode: "AAAA", treasury: false } } + +validatePhoneRo("0723 456 789"); +// { valid: true, error: null, data: { normalized: "+40723456789" } } + +validatePhoneRo("0040723456789"); +// { valid: true, error: null, data: { normalized: "+40723456789" } } ``` Every `validate*` function returns the same shape, so you can handle all of them the same way: @@ -61,7 +67,7 @@ type ValidationResult = | { valid: false; error: string; data: null }; ``` -Only need true or false? Use `isValidCnp`, `isValidCui`, `isValidIbanRo` and `isValidPostalCode`. +Only need true or false? Use `isValidCnp`, `isValidCui`, `isValidIbanRo`, `isValidPostalCode` and `isValidPhoneRo`. ## API @@ -71,7 +77,8 @@ Only need true or false? Use `isValidCnp`, `isValidCui`, `isValidIbanRo` and `is | `validateCui(cui)` | `ValidationResult` | | `validateIbanRo(iban)` | `ValidationResult` | | `validatePostalCode(postalCode)` | `ValidationResult`; exactly 6 digits | -| `isValidCnp`, `isValidCui`, `isValidIbanRo`, `isValidPostalCode` | `boolean` | +| `validatePhoneRo(phone)` | `ValidationResult`; `data.normalized` is in E.164 format | +| `isValidCnp`, `isValidCui`, `isValidIbanRo`, `isValidPostalCode`, `isValidPhoneRo` | `boolean` | | `cnpControlDigit(first12)` | the CNP control digit | | `cuiControlDigit(body)` | the CUI control digit | | `ibanChecksumOk(iban)` | mod-97 check, works for any country | @@ -85,6 +92,7 @@ Only need true or false? Use `isValidCnp`, `isValidCui`, `isValidIbanRo` and `is | CUI / CIF | optional `RO` prefix, 2–10 digits, no leading zero, control digit (key `753217532`) | | IBAN (RO) | 24 characters: `RO`, 2 check digits, 4-letter bank code, 16 alphanumerics, ISO 13616 mod-97 | | Postal code | exactly 6 digits | +| Phone (RO) | Romanian mobile (`07`) and landline (`02` / `03`) formats; accepts national, `+40` and `0040` prefixes; normalizes to `+40` E.164 | Spaces, dots and dashes in the input are ignored. @@ -103,7 +111,7 @@ All examples in this repository are fictitious: sample CNPs use birth dates in t ## Roadmap - [ ] Romanian licence plates -- [ ] Phone numbers, normalized to `+40` +- [x] Phone numbers, normalized to `+40` - [x] Postal codes - [ ] Python port diff --git a/src/index.ts b/src/index.ts index 6758db9..3155707 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,5 +6,7 @@ export { validateIbanRo, isValidIbanRo, ibanChecksumOk } from "./iban.js"; export type { IbanInfo } from "./iban.js"; export { validatePostalCode, isValidPostalCode } from "./postal-code.js"; export type { PostalCodeInfo } from "./postal-code.js"; +export { validatePhoneRo, isValidPhoneRo } from "./phone-ro.js"; +export type { PhoneRoInfo } from "./phone-ro.js"; export { CNP_COUNTIES } from "./counties.js"; export type { ValidationResult } from "./result.js"; diff --git a/src/phone-ro.ts b/src/phone-ro.ts new file mode 100644 index 0000000..940792d --- /dev/null +++ b/src/phone-ro.ts @@ -0,0 +1,39 @@ +import { clean, fail, ok, type ValidationResult } from "./result.js"; + +export interface PhoneRoInfo { + /** Romanian phone number normalized to E.164 format. */ + normalized: string; +} + +/** + * Validates a Romanian mobile or landline number and normalizes it to E.164. + * Accepts national numbers (07..., 02... or 03...) and +40 / 0040 prefixes. + * Spaces, dots and dashes are ignored. + */ +export function validatePhoneRo(input: string): ValidationResult { + if (typeof input !== "string") return fail("Numărul de telefon trebuie să fie un șir de caractere"); + + const phone = clean(input); + let nationalNumber: string; + + if (phone.startsWith("+40")) { + nationalNumber = phone.slice(3); + } else if (phone.startsWith("0040")) { + nationalNumber = phone.slice(4); + } else if (/^0/.test(phone)) { + if (!/^0[237]\d{8}$/.test(phone)) { + return fail("Numărul de telefon trebuie să fie un număr mobil sau fix din România"); + } + nationalNumber = phone.slice(1); + } else { + return fail("Numărul de telefon trebuie să înceapă cu 0, +40 sau 0040"); + } + + if (!/^[237]\d{8}$/.test(nationalNumber)) { + return fail("Numărul de telefon trebuie să fie un număr mobil sau fix din România"); + } + + return ok({ normalized: `+40${nationalNumber}` }); +} + +export const isValidPhoneRo = (input: string): boolean => validatePhoneRo(input).valid; diff --git a/test/phone-ro.test.ts b/test/phone-ro.test.ts new file mode 100644 index 0000000..0b04b72 --- /dev/null +++ b/test/phone-ro.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { isValidPhoneRo, validatePhoneRo } from "../src/phone-ro.js"; + +describe("Romanian phone number", () => { + it("normalizes national mobile numbers", () => { + expect(validatePhoneRo("0723 456 789")).toEqual({ + valid: true, + error: null, + data: { normalized: "+40723456789" }, + }); + expect(validatePhoneRo("07-23-456-789").data).toEqual({ normalized: "+40723456789" }); + }); + + it("accepts international prefixes and normalizes to E.164", () => { + expect(validatePhoneRo("+40 723 456 789").data).toEqual({ normalized: "+40723456789" }); + expect(validatePhoneRo("0040723456789").data).toEqual({ normalized: "+40723456789" }); + }); + + it("accepts Romanian landline numbers", () => { + expect(validatePhoneRo("021 345 6789").data).toEqual({ normalized: "+40213456789" }); + expect(validatePhoneRo("0313456789").data).toEqual({ normalized: "+40313456789" }); + }); + + it("rejects unsupported prefixes and malformed lengths", () => { + expect(isValidPhoneRo("0123456789")).toBe(false); + expect(isValidPhoneRo("072345678")).toBe(false); + expect(isValidPhoneRo("+44123456789")).toBe(false); + expect(isValidPhoneRo("not a phone number")).toBe(false); + }); + + it("returns Romanian error messages", () => { + expect(validatePhoneRo("123").error).toContain("trebuie să înceapă"); + }); +}); From 5db07bea32d57de6c75632aa5056cb34c9a93fbc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 14:41:36 +0000 Subject: [PATCH 3/3] fix(plate): add the missing Cluj county code (CJ) Follow-up to #17: CJ was missing from PLATE_COUNTIES, so Cluj plates were rejected. Adds a test that all 41 counties and Bucharest are listed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LqqTowH2NxGHN3urqpLyyY --- src/plate.ts | 1 + test/plate.test.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/plate.ts b/src/plate.ts index c4ac14e..deb7efc 100644 --- a/src/plate.ts +++ b/src/plate.ts @@ -13,6 +13,7 @@ export const PLATE_COUNTIES: Readonly> = Object.freeze({ BZ: "Buzău", CS: "Caraș-Severin", CL: "Călărași", + CJ: "Cluj", CT: "Constanța", CV: "Covasna", DB: "Dâmbovița", diff --git a/test/plate.test.ts b/test/plate.test.ts index 45c861f..1d08e7b 100644 --- a/test/plate.test.ts +++ b/test/plate.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isValidPlate, validatePlate } from "../src/index.js"; +import { PLATE_COUNTIES, isValidPlate, validatePlate } from "../src/index.js"; describe("validatePlate", () => { it("accepts county and Bucharest plates and returns county data", () => { @@ -25,6 +25,11 @@ describe("validatePlate", () => { expect(validatePlate("b.123.abc").data?.plate).toBe("B123ABC"); }); + it("knows all 41 counties and Bucharest", () => { + expect(Object.keys(PLATE_COUNTIES).length).toBe(42); + expect(PLATE_COUNTIES.CJ).toBe("Cluj"); + }); + it("accepts known county codes", () => { expect(isValidPlate("AB12ABC")).toBe(true); expect(isValidPlate("IF12ABC")).toBe(true);