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"); + }); +});